Docker Compose basics for your VPS
Install Docker Engine and Compose v2 on Ubuntu 24.04, write a real two-service compose.yml, dodge the ufw port-publishing trap, and back up volumes.
What you are building
Docker Compose is the substrate under nearly everything else on this site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — every one of those guides opens with "write this compose file", and this is the page that explains what that file actually means. You will install Docker Engine and the Compose v2 plugin from Docker's own apt repository on Ubuntu 24.04, then stand up a real two-service stack — Miniflux, a small RSS reader, plus PostgreSQL — because that pair exercises every pattern the bigger apps use: pinned images, a database with a healthcheck, a named volume, secrets in a .env file, and a port published only to localhost.
The install takes five minutes. The rest of this guide covers the parts that hurt later: the docker group being root by another name, published ports walking straight past your ufw rules, and the one flag on docker compose down that deletes your database with no confirmation prompt.
Prerequisites: a fresh Ubuntu 24.04 KVM VPS, a user with sudo, and a gigabyte of RAM or more. An existing Docker install is fine too — the first section covers what to remove.
Install from Docker's repo, not Ubuntu's
Two wrong turns to refuse before the first command. Ubuntu's own docker.io package works, but trails Docker's releases and misses the plugin layout everything else assumes. And the standalone docker-compose binary — the one with the hyphen — is Compose v1: Python, end-of-life since 2023, and the reason older tutorials break. Compose today is docker compose with a space, a CLI plugin, installed from the same repository as the engine.
If any of that is already on the box, clear it first — including docker-compose-v2, Ubuntu's own packaging of the plugin, so everything comes from one repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc
Package 'docker.io' is not installed, so not removed is the normal output on a fresh VPS. Then add Docker's repository and install:
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
Verify all three layers:
docker --version
docker compose version
sudo docker run --rm hello-world
The first two print version strings — Docker Compose version v2.x.x confirms you have the plugin, not the dead v1 binary. The hello-world run should end with Hello from Docker!. The package enables the service at boot; systemctl is-enabled docker prints enabled.
The docker group is root — decide with your eyes open
Right now every docker command needs sudo, because the daemon's socket at /var/run/docker.sock is owned by root and the docker group. Without membership you get the single most-googled Docker error:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock
The standard fix:
sudo usermod -aG docker $USER
Group membership applies at login, so the error persists in your current shell. Run newgrp docker for this session, or log out and back in; id should then list docker in your groups.
Now the honest part, stated plainly: membership of the docker group is root on the host. Not "root-ish", not "elevated" — root. Anyone in that group can run docker run --rm -it -v /:/host alpine chroot /host and own the entire filesystem, no password asked. The group exists for convenience, not containment.
Docker's rootless mode is the real alternative — the daemon itself runs as your unprivileged user. It costs you: ports below 1024 need extra setup, networking runs through a userspace shim with measurable overhead, and some images misbehave without real root. On a single-admin VPS where the only login already holds sudo, the group changes nothing in practice, and it is what every guide here assumes — just never hand it out as if it were less than sudo.
Anatomy of a compose file
Give each stack its own directory — the directory name becomes the project name, which prefixes containers, networks, and volumes:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux
Create compose.yml (the modern name; docker-compose.yml still works). Skip the old version: key — it is obsolete and Compose warns if it sees one.
services:
miniflux:
image: miniflux/miniflux:2.2.9
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
- DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=miniflux
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
Every line above is a decision. Take them one at a time.
Pin image versions — :latest plus a pull is an unattended upgrade
postgres:16-alpine, not postgres:latest. A tag is not frozen: :latest re-resolves to whatever the maintainer most recently pushed, every time you pull. Combine that with the routine upgrade habit you are about to learn — docker compose pull && docker compose up -d — and :latest means major-version jumps land whenever upstream ships them, not when you choose. With PostgreSQL that is not hypothetical: a surprise jump from 16 to 17 leaves the container crash-looping on an incompatible data directory, because Postgres major upgrades require a dump and restore, not a restart.
Pin at least the major version (postgres:16-alpine follows 16.x patch releases), and pin applications to an exact release like miniflux/miniflux:2.2.9 — check the project's releases page and use whatever is current when you write the file. An upgrade then becomes a one-line edit you made on purpose, visible in git diff.
Publish to 127.0.0.1, because Docker walks around ufw
"127.0.0.1:8080:8080" — host address, host port, container port. Most tutorials write "8080:8080", which is shorthand for 0.0.0.0:8080:8080: listening on every interface, including the public one.
Here is the trap, and it bites almost everyone once. ufw filters traffic in iptables' INPUT chain — packets addressed to the host itself. But Docker publishes a port by writing a DNAT rule into the nat table's PREROUTING chain, which rewrites the packet's destination to the container's internal IP before filtering. The rewritten packet then traverses the FORWARD path through Docker's own DOCKER chain — and never touches INPUT, where your ufw rules live. So sudo ufw deny 8080 reports success, ufw status shows the port denied, and the service still answers the whole internet. Your firewall is not broken; it is being bypassed by design.
The habit that makes the whole problem vanish: bind published ports to 127.0.0.1 unless you have a specific reason not to, and put a reverse proxy in front for anything that should face the world. That is exactly what the Traefik reverse proxy guide builds as the next step after this page — one container that owns ports 80 and 443 and routes to everything else by hostname, with TLS.
Verify the bind after starting the stack: sudo ss -tlnp | grep 8080 should show 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data is a named volume: Docker creates and manages a directory under /var/lib/docker/volumes/ and mounts it into the container. The alternative is a bind mount, ./data:/var/lib/postgresql/data, which maps a path you chose on the host.
The split that holds up in practice: named volumes for data only containers touch — databases above all, since Docker initialises the volume with the ownership the image expects and file permissions just work. Bind mounts for files you touch from the host — config files you edit with a text editor, a media library you rsync into, anything whose path you want to be obvious. The classic bind-mount failure is ownership: the container runs as UID 999, your host directory is owned by UID 1000, and the app dies on startup with permission denied in its logs. Named volumes make that class of bug mostly disappear, at the cost of the data living at a Docker-managed path — covered below.
environment and .env — keep secrets out of git
${POSTGRES_PASSWORD} is not read from your shell; Compose interpolates it from a file named .env sitting next to compose.yml. Create it:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore
Generate real values with openssl rand -hex 24. Hex, not base64, on purpose: this password lands inside the DATABASE_URL connection string, and the /, +, and = characters base64 produces break URL parsing — a failure that surfaces as an authentication error, not a syntax error, and costs an evening. The .gitignore line goes in before the first commit: the compose file is safe to publish and version, the .env file never is, and a secret that has touched git history is a secret you rotate. If you start the stack with a variable missing, Compose warns loudly and continues with an empty string — which for a Postgres password means a broken deployment:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.
docker compose config prints the fully-interpolated file — the fastest way to check what the containers will actually receive; remember its output includes your secrets.
depends_on waits for nothing — unless you add a healthcheck
A bare depends_on: [db] controls start order only: Compose launches Postgres first and the app a moment later, while Postgres is still seconds away from accepting connections. The app hits the database, fails, and crashes or retries depending on how well it was written.
The reliable version is what the file above uses: the db service defines a healthcheck (Postgres ships pg_isready for exactly this), and the app declares depends_on with condition: service_healthy. Compose starts the database, polls the check every 10 seconds, and only starts Miniflux once the check passes. If the database never becomes healthy — bad password, corrupt volume — the app never starts and Compose tells you which dependency failed:
dependency failed to start: container miniflux-db-1 is unhealthy
That message points you at docker compose logs db, which is where the real error lives.
restart: unless-stopped
restart: unless-stopped on both services means the containers come back after a crash and after a VPS reboot, but stay down if you deliberately ran docker compose stop. The alternative always resurrects containers even after a manual stop — rarely what you meant. Without a restart policy, a kernel-update reboot at 4 a.m. quietly takes your services down until you notice.
The daily verbs
Everything day-to-day is five commands, run from the project directory.
docker compose up -d # create and start; idempotent, recreates only what changed
docker compose ps # status, ports, and health of this project's containers
docker compose logs -f miniflux # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d # upgrade to the pinned tags
docker compose down # stop and remove containers and the network
up -d is safe to run repeatedly — it diffs the file against reality and only touches services whose config or image changed. The upgrade pair fetches whatever your pinned tags now point at: patch releases under postgres:16-alpine, nothing for an exact pin until you edit it — which is the point. Old images accumulate after upgrades; reclaim disk with docker image prune -f.
Now the destructive one, said loudly: docker compose down is safe — containers and the network are disposable, and your data is in the volume. docker compose down -v deletes the named volumes too. That is your database, gone, instantly, with no confirmation prompt and no undo. The -v flag exists for tearing down experiments; on a stack holding real data, treat it the way you treat rm -rf. There is no trash can under /var/lib/docker/volumes/.
For a one-off shell inside a running container: docker compose exec db psql -U miniflux drops you into the database, and docker compose exec miniflux sh gets you a shell in the app.
Where your data actually lives
Named volumes get the project prefix, so db-data in a directory called miniflux becomes miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-data
The inspect output includes the line that matters:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"
That directory is the database — root-owned, on the host filesystem, and it survives down, upgrades, and container rebuilds. It is also exactly what your backups must capture.
Back up a named volume
The standard pattern is a throwaway container that mounts the volume read-only next to a host directory, and tars across:
docker run --rm \
-v miniflux_db-data:/data:ro \
-v "$PWD":/backup \
alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .
No install, nothing left running, and the restore is the mirror image — tar xzf into a fresh empty volume with the same mounts reversed.
One caveat for databases: tarring a running Postgres data directory can capture a mid-write state that will not start cleanly. Either docker compose stop for the seconds the tar takes, or — better — take a logical dump, which is consistent by construction:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz
The -T disables the pseudo-terminal Compose allocates by default — piping dump output through a TTY can corrupt it. Put one of these in cron and copy the result off the VPS; a backup on the same disk as the data it protects is a copy, not a backup. The Nextcloud guide builds a full scheduled routine around exactly these two patterns.
Failure modes, with the strings you will see
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — you are not in the docker group yet, or you are but the session predates it. id shows your effective groups; newgrp docker fixes the current shell, logging out and back in fixes all of them.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — different problem: the daemon itself is down. sudo systemctl status docker and sudo journalctl -u docker -n 50 say why. On a VPS the classic cause is a full disk — df -h /var/lib/docker first.
Bind for 127.0.0.1:8080 failed: port is already allocated — another container already published that host port. docker ps shows which; a stale container from an experimental docker run weeks ago is the usual culprit. If docker ps is clean, a non-Docker process holds the port: sudo ss -tlnp | grep 8080 names it.
yaml: line 14: did not find expected key — an indentation error at or just above the line named. Compose files are YAML: two-space indentation, spaces only, and a tab character anywhere is fatal. docker compose config validates the file without starting anything, and running it after every edit is a cheap habit.
The ufw surprise prints no error at all, which is what makes it dangerous: the deploy works, ufw status looks right, and a port scan from outside finds your database anyway. Re-read the ports section above, check every ports: entry for a missing 127.0.0.1: prefix, and confirm from a different machine with curl http://your-vps-ip:8080 — connection refused is the answer you want.
From here, the Traefik guide turns this single stack into many apps behind one HTTPS entry point, and what's worth self-hosting in 2026 is the shopping list to run through it.
FAQ
Why do I get "permission denied while trying to connect to the Docker daemon socket"?
Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.
Does docker compose down delete my data?
Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.
What is the difference between docker-compose and docker compose?
docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.
Why can I reach my Docker container from the internet even though ufw blocks the port?
Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.
Should I use a named volume or a bind mount?
Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.