Docker on a VPS: installation, Compose, and firewall traps

Docker installs in three commands. The trap comes later: a port published by Docker walks straight through UFW. Here is why, and the fix.

Stack of containers behind a firewall wall with a single open port

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.

bash
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
bash
sudo docker run --rm hello-world
docker compose version

2. Use Docker without sudo

bash
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.

bash
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:

bash
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.

bash
# 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:

yaml
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:

bash
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.

compose.yaml
# /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:
bash
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

bash
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.0 without an explicit reason.
  • Exposure verified from an outside machine.
  • restart: unless-stopped on everything that must survive a reboot.
  • Named volumes for data, logical backups on top of snapshots.