This guide walks you through a production-ready Docker setup on a single Ubuntu 24.04 VPS for small server deployments. By the end, you'll have Docker installed and locked to a stable version, a non-root deploy user, a running Nginx container with restart policy, and log rotation configured so your $5 Hetzner box doesn't fill its disk overnight.
Prerequisites
- A fresh Ubuntu 24.04 VPS (1 vCPU / 2 GB RAM minimum — a Hetzner CX22 at ~$4.50/month works fine)
- A non-root user with
sudoprivileges - SSH access to the server
- Basic familiarity with
systemdand the Linux command line - Ports 80 and 443 open in your firewall or cloud security group
Step 1 — Remove Conflicting Packages
Ubuntu 24.04 ships with stub Docker packages that conflict with the official release. Remove them first.
sudo apt-get remove -y docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc
Expected output ends with 0 upgraded, 0 newly installed, N removed.
Step 2 — Add the Official Docker APT Repository
Install the dependencies needed to fetch Docker's signed repository.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
Create the keyring directory and download Docker's GPG key.
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Add the repository to APT sources.
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Update the package index so APT sees the new source.
sudo apt-get update
Step 3 — Install Docker Engine at a Pinned Version
Pinning a version prevents an unattended upgrade from breaking your deployment mid-week. List available versions first.
apt-cache madison docker-ce | head -5
Sample output:
docker-ce | 5:27.3.1-1~ubuntu.24.04~noble | https://download.docker.com/linux/ubuntu noble/stable amd64 Packages
docker-ce | 5:27.2.1-1~ubuntu.24.04~noble | https://download.docker.com/linux/ubuntu noble/stable amd64 Packages
Install the latest stable version shown (substitute the version string if yours differs).
VERSION_STRING="5:27.3.1-1~ubuntu.24.04~noble"
sudo apt-get install -y \
docker-ce=$VERSION_STRING \
docker-ce-cli=$VERSION_STRING \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
Pin the packages so apt upgrade won't touch them.
sudo apt-mark hold docker-ce docker-ce-cli containerd.io
Step 4 — Configure the Docker Daemon for Small Servers
The default Docker daemon settings are tuned for machines with large disks and abundant RAM. Override them for a small VPS.
Create or edit /etc/docker/daemon.json.
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2",
"live-restore": true
}
EOF
What each option does:
log-driver+log-opts— caps container logs at 30 MB total (3 × 10 MB) per container, preventing disk exhaustion.storage-driver: overlay2— the correct driver for ext4 and xfs on modern kernels; avoids the deprecateddevicemapper.live-restore: true— containers keep running if the Docker daemon restarts (e.g., after a daemon config change), giving you zero-downtime daemon upgrades.
Reload the daemon to apply the config.
sudo systemctl reload docker
Step 5 — Create a Non-Root Deploy User
Adding your deploy user to the docker group lets it run containers without sudo. Never add your personal admin account to this group — docker group membership is effectively root.
Create a dedicated deploy user.
sudo useradd -m -s /bin/bash deploy
Add it to the docker group.
sudo usermod -aG docker deploy
Switch to the deploy user to confirm group membership takes effect.
sudo -u deploy docker info | grep -i "server version"
Expected output:
Server Version: 27.3.1
Step 6 — Deploy a Container with a Restart Policy
This step runs Nginx as a real-world example. The same pattern applies to any image you deploy.
Create a directory for your app's data and config under the deploy user's home.
sudo mkdir -p /home/deploy/apps/nginx/html
echo '<h1>Running on Docker</h1>' | sudo tee /home/deploy/apps/nginx/html/index.html
sudo chown -R deploy:deploy /home/deploy/apps
Start the container as the deploy user with --restart unless-stopped so it survives reboots and daemon restarts.
sudo -u deploy docker run -d \
--name nginx-app \
--restart unless-stopped \
-p 80:80 \
-v /home/deploy/apps/nginx/html:/usr/share/nginx/html:ro \
nginx:1.27-alpine
Flags explained:
-d— detached mode; container runs in the background.--restart unless-stopped— restarts automatically unless you explicitly rundocker stop.-v … :ro— mounts your HTML directory read-only inside the container; the container process cannot modify it.nginx:1.27-alpine— pinned minor version on the Alpine base image; roughly 45 MB vs 190 MB for the Debian variant.
Step 7 — Configure Log Rotation at the OS Level
The daemon-level log cap from Step 4 handles container log files. For the Docker daemon's own log (written by journald), cap it separately.
Edit /etc/systemd/journald.conf.
sudo tee -a /etc/systemd/journald.conf > /dev/null <<'EOF'
# Docker daemon log limits
SystemMaxUse=200M
SystemKeepFree=100M
EOF
Restart journald to apply.
sudo systemctl restart systemd-journald
Verify It Works
Run each check in order. All should pass before you call the setup complete.
1. Docker daemon is active.
sudo systemctl is-active docker
active
2. Container is running with the correct restart policy.
sudo -u deploy docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
NAMES STATUS PORTS
nginx-app Up 2 minutes 0.0.0.0:80->80/tcp
3. HTTP response from the container.
curl -s http://localhost | grep -o 'Running on Docker'
Running on Docker
4. Log rotation config is applied.
sudo -u deploy docker inspect nginx-app \
--format '{{.HostConfig.LogConfig.Type}} max-size={{index .HostConfig.LogConfig.Config "max-size"}}'
json-file max-size=10m
5. Container survives a daemon restart.
sudo systemctl restart docker
sleep 5
sudo -u deploy docker ps --filter name=nginx-app --format "{{.Status}}"
Up 4 seconds
Troubleshooting
docker: command not found after installation
The APT repository was not added correctly. Re-run Step 2 and confirm /etc/apt/sources.list.d/docker.list exists, then re-run Step 3.
permission denied when the deploy user runs docker
The group change requires a new login session. Run sudo -u deploy docker info or log out and back in as deploy.
Port 80 already in use (Bind for 0.0.0.0:80 failed)
Identify the occupying process with sudo ss -tlnp | grep ':80'. Stop the conflicting service (commonly apache2 or a host-level nginx) with sudo systemctl stop apache2.
Container exits immediately
Check the container logs: sudo -u deploy docker logs nginx-app. An image misconfiguration or missing mount path is the usual cause.
Disk usage growing despite log caps
Untagged images and stopped containers accumulate. Schedule a weekly prune: sudo -u deploy docker system prune -f. Add it to cron with crontab -e -u deploy and the line 0 3 * * 0 /usr/bin/docker system prune -f.
live-restore warning in daemon logs
Some older kernel versions log a warning about live-restore and user namespaces together. Check with sudo journalctl -u docker | grep -i warn. If it's cosmetic and containers run fine, ignore it; otherwise remove live-restore from /etc/docker/daemon.json and reload.
Next Steps
With Docker for small server deployments running and hardened, the logical next steps are:
- Add Docker Compose —
docker compose(the plugin installed in Step 3) lets you define multi-container stacks in a singlecompose.ymlfile. Commit that file to a private Git repo and your deploys become repeatable. - Set up a reverse proxy — Run Caddy or Nginx Proxy Manager as a container to terminate TLS and route traffic to multiple apps on the same VPS without exposing additional ports.
- Automate image updates — Watchtower (run as a container) polls Docker Hub and restarts containers when a new image tag appears. Combine it with pinned patch versions to stay current without surprise breakage.
- Add resource limits — For multi-tenant or multi-app VPS setups, add
--memory 256m --cpus 0.5to yourdocker runcommands (or the equivalent incompose.yml) to prevent one misbehaving container from starving the others.