Skip to content
VPS and servers · 6 min

Docker and Docker Compose on a VPS, and the firewall trap

Most self-hosted software today ships as a docker-compose.yml. Getting Docker onto a VPS takes one command. Running it safely needs one more thing almost every guide skips: Docker's published ports go around ufw.

Which plan

Docker itself is light. What you run in it isn't. As a rough guide: a single app with a database fits on Pulse (2 vCPU, 2 GB), a stack of three to five services on Drive (4 GB, $15/month) or Forge (8 GB, $22/month). Images and volumes grow quietly, so keep an eye on disk space as well as memory.

1. Install

The official convenience script installs Docker Engine and the Compose plugin from Docker's own repository:

curl -fsSL https://get.docker.com | sh
docker run --rm hello-world
docker compose version

Working as a regular user? usermod -aG docker youruser and log in again. Keep in mind that membership in the docker group is effectively root on the host.

2. A real stack

/opt/app/docker-compose.yml: an app and PostgreSQL. The app is published only on localhost; nginx on the host will proxy to it.

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db:/var/lib/postgresql/data

  app:
    image: ghcr.io/example/app:latest
    restart: unless-stopped
    depends_on: [db]
    environment:
      DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/postgres
    ports:
      - "127.0.0.1:8080:8080"

volumes:
  db:
cd /opt/app && echo 'DB_PASSWORD=change-this' > .env && chmod 600 .env
docker compose up -d
docker compose ps
docker compose logs -f app

restart: unless-stopped brings containers back after a crash and after a server reboot.

3. The firewall trap

This one catches people every week. You enable ufw, allow only SSH and HTTPS, and publish a container with ports: - "5432:5432". The database is now open to the whole internet. Docker writes its own iptables rules, and they're evaluated before ufw's.

Two habits avoid it:

  • Publish to localhost (127.0.0.1:8080:8080) anything that only nginx or you over an SSH tunnel should reach.
  • Don't publish database ports at all. Containers in the same compose project talk to each other by service name (db:5432 above) without any ports: entry.

Check from the outside what is actually open: nmap -Pn <server IP> from your own machine.

4. Logs and disk

By default Docker keeps container logs forever. A chatty app can fill the disk in a few weeks. Cap them in /etc/docker/daemon.json:

{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "3" } }
systemctl restart docker
docker system df            # what takes space
docker image prune -a       # remove unused images

The log limit applies to containers created after the change, so recreate yours with docker compose up -d --force-recreate.

What Docker won't do for you

Back up the volumes. docker compose down -v deletes them, and so does a careless prune. For PostgreSQL, a nightly docker compose exec -T db pg_dump -U postgres postgres > dump.sql copied off the server is the minimum.

Docker and Docker Compose on a VPS, and the firewall trap · dedisrv