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:5432above) without anyports: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.