Docker makes a VPS immediately useful: a whole stack, application, database, cache, proxy, fits in one versioned file and redeploys identically. Installation takes three minutes. What deserves your attention comes after: log management, persistence, and above all how Docker interacts with your firewall.
1. Install Docker Engine
The docker.io package in the Ubuntu repositories often trails by
several releases. Use the official repository: it is also the only one shipping an
up-to-date Compose v2 plugin.
sudo apt update
sudo apt install -y ca-certificates curl
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
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
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo docker run --rm hello-world
docker compose version
2. Use Docker without sudo
sudo usermod -aG docker $USER
newgrp docker
docker ps
Weigh this: membership in the docker group is
equivalent to root on the machine, a container can mount the host filesystem.
On a VM shared by several people, prefer sudo docker or rootless
mode.
3. Cap log growth
By default Docker keeps every container's entire standard output, with no rotation. One chatty service can fill a 40 GB disk in a few weeks, it is the most mundane outage on a containerized VPS.
sudo tee /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"live-restore": true
}
EOF
sudo systemctl restart docker
The limit only applies to containers created afterwards; recreate existing ones for it to take effect.
4. The trap: Docker bypasses UFW
This is the part that surprises people most. You configured
ufw default deny incoming, then you run:
docker run -d -p 5432:5432 postgres:16
…and your database is reachable from the internet. Docker inserts
its own NAT rules directly into netfilter's PREROUTING chain, upstream
of the chains UFW operates in. Traffic is translated before it is ever filtered.
sudo ufw status still shows a closed firewall, and that is accurate, it simply is not on the path.
The simplest fix: never publish on all interfaces.
# instead of -p 5432:5432
docker run -d -p 127.0.0.1:5432:5432 postgres:16
Only the proxy and local services can then reach the port. In Compose:
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432"
If a port genuinely must stay public but source-filtered, write the rule in the
DOCKER-USER chain, which Docker consults before its own rules and
never rewrites:
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5432 \
-s 203.0.113.0/24 -j ACCEPT
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5432 -j DROP
sudo apt install -y iptables-persistent # to survive a reboot
Always verify from outside, never from the machine itself:
nmap -Pn -p 5432 198.51.100.42 from another host gives you the answer
that matters.
5. A first Compose stack
A complete example: a Caddy proxy that obtains and renews its own certificates, in front of an application. Only ports 80 and 443 are published.
# /srv/myapp/compose.yaml
services:
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- app
app:
image: ghcr.io/example/myapp:1.4.2
restart: unless-stopped
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 5
volumes:
caddy_data:
pgdata:
cd /srv/myapp
echo "DB_PASSWORD=$(openssl rand -base64 24)" > .env
chmod 600 .env
docker compose up -d
docker compose ps
Three details that pay off over time: restart: unless-stopped brings
containers back after a VM reboot; image tags are pinned rather than
latest, so docker compose pull stays a deliberate
decision; data lives in named volumes, not in the container layer.
6. Day-to-day operations
docker compose logs -f --tail=100 app # follow one service
docker compose pull && docker compose up -d # update
docker compose exec db psql -U app # get inside a container
docker system df # what Docker occupies on disk
docker system prune -a --volumes # ⚠ also removes orphaned volumes
Backups: a VM snapshot captures the disk state, including a
database mid-write. For a reliable restore, pair it with a scheduled
pg_dump to separate storage. The snapshot brings the machine back;
the dump brings consistent data back.
Checklist
- Docker installed from the official repository, Compose v2 available.
- Log rotation configured in
daemon.json. - No port published on
0.0.0.0without an explicit reason. - Exposure verified from an outside machine.
restart: unless-stoppedon everything that must survive a reboot.- Named volumes for data, logical backups on top of snapshots.