Docker Compose basics for your VPS: Ubuntu 24.04
Install Docker Engine and Compose v2 on Ubuntu 24.04, run Miniflux plus PostgreSQL, avoid the UFW published-port trap, and back up named volumes.
Wetín you dey build
Docker Compose na the foundation under almost everything else for this site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat, every one of those guides dey start with “write this compose file”, and na this page explain wetin that file really mean. You go install Docker Engine and the Compose v2 plugin from Docker own apt repository for Ubuntu 24.04, then set up real two-service stack: Miniflux, one small RSS reader, plus PostgreSQL. That pair dey show every pattern wey the bigger apps dey use: pinned images, database wey get healthcheck, named volume, secrets for one .env file, and port wey publish only to localhost.
The installation go take five minutes. The rest of this guide cover the things wey fit cause problem later: docker group wey be root by another name, published ports wey dey bypass your ufw rules, and the one flag for docker compose down wey go delete your database without confirmation prompt.
Prerequisites: fresh Ubuntu 24.04 KVM VPS, user wey get sudo, and one gigabyte RAM or more. Existing Docker installation dey okay too; the first section explain wetin you need remove.
Install from Docker repo, no be Ubuntu own
Before you run the first command, reject these two wrong options. Ubuntu own docker.io package dey work, but e dey behind Docker releases and e no get the plugin layout wey everything else dey expect. The standalone docker-compose binary, the one wey get hyphen, na Compose v1: Python, support don end since 2023, and na why older tutorials dey break. Compose today na docker compose with space, a CLI plugin wey you install from the same repository as the engine.
If any of dem already dey for the server, remove am first. This include docker-compose-v2, Ubuntu own packaging of the plugin, so everything go come from one repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage 'docker.io' is not installed, so not removed na the normal output for a fresh VPS. Then add Docker repository and install am:
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-pluginVerify all three layers:
docker --version
docker compose version
sudo docker run --rm hello-worldThe first two go print version strings. Docker Compose version v2.x.x go confirm say you get the plugin, no be the dead v1 binary. The hello-world run suppose end with Hello from Docker!. The package enables the service when system boot. systemctl is-enabled docker go print enabled.
docker group na root, so make decision with clear eye
For now every docker command need sudo, because daemon socket for /var/run/docker.sock belong to root and docker group. If you no dey that group, you go see the Docker error wey people search pass:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockThe standard fix na:
sudo usermod -aG docker $USERGroup membership go apply when you log in, so error go still dey for your current shell. Run newgrp docker for this session, or log out and log back in; id suppose then list docker among your groups.
Now make we talk the honest part plainly: membership of the docker group mean root access for the host. E no be "root-ish", e no be "elevated"; na root. Anybody wey dey that group fit run docker run --rm -it -v /:/host alpine chroot /host and take control of the whole filesystem, without password. The group dey for convenience, no be for containment.
Docker rootless mode na the real alternative. For there, the daemon itself dey run as your unprivileged user. But e get cost: ports below 1024 need extra setup, networking dey pass through userspace shim with measurable overhead, and some images fit misbehave when real root no dey. For one-admin VPS where the only login already get sudo, the group no change anything for practice. Na wetin every guide here assume, but no ever give am out as if e be less powerful than sudo.
Compose file anatomy
Give each stack its own directory. The directory name becomes the project name, and e go prefix container, network, and volume names:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxCreate compose.yml (na the modern name; docker-compose.yml still dey work). No use the old version: key. E don obsolete, and Compose go warn if e see am.
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 na one decision. Handle dem one by one.
Pin image versions, :latest plus a pull na unattended upgrade
Use postgres:16-alpine, no be postgres:latest. Tag no dey frozen: :latest go resolve again to whatever version maintainer push most recently, every time you pull. Join this with the routine upgrade habit wey you go learn now, docker compose pull && docker compose up -d, and :latest means major-version jumps go land anytime upstream release dem, no be when you choose. With PostgreSQL, this one fit really happen: unexpected jump from 16 to 17 go leave container dey crash-loop because data directory no compatible, since Postgres major upgrades need dump and restore, no be restart.
Pin at least the major version (postgres:16-alpine follows 16.x patch releases), and pin applications to exact release like miniflux/miniflux:2.2.9. Check the project's releases page and use the current version when you write the file. Upgrade go then become one-line edit wey you make intentionally, visible for git diff.
Publish to 127.0.0.1, because Docker dey bypass ufw
"127.0.0.1:8080:8080" means host address, host port, and container port. Most tutorials write "8080:8080". This na shorthand for 0.0.0.0:8080:8080: e dey listen on every interface, including the public one.
Here na the trap, and e catch almost everybody at least once. Docker publishes port by writing DNAT rule wey rewrite packet destination to the container internal IP before filtering. So packet follow the FORWARD path and e never touch INPUT, where your ufw rules dey live. sudo ufw deny 8080 reports success, ufw status shows say port dey denied, but service still answers the whole internet. Your firewall no spoil; Docker dey bypass am by design. Why Docker bypasses ufw, and how to filter container traffic for real explains the mechanism and the DOCKER-USER fix for ports wey must remain public.
The habit wey removes the whole problem: bind published ports to 127.0.0.1 unless you get specific reason not to. Put reverse proxy in front of anything wey should face the world. Na exactly this the Traefik reverse proxy guide builds as the next step after this page: one container wey owns ports 80 and 443 and routes request to every other service by hostname, with TLS. (If you dey come from old Traefik v2 setup, the Traefik v2 to v3 migration guide covers the renames and rule changes.)
After you start the stack, verify the bind: sudo ss -tlnp | grep 8080 suppose show 127.0.0.1:8080, no be 0.0.0.0:8080 or *:8080.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data na named volume. Docker creates and manages directory under /var/lib/docker/volumes/, then mounts am inside the container. The alternative na bind mount, ./data:/var/lib/postgresql/data, wey maps host path wey you choose.
The split wey works well for practice: use named volumes for data wey only containers dey touch, especially databases. Docker go initialise the volume with the ownership wey image expects, so file permissions go work. Use bind mounts for files wey you dey touch from the host: config files wey you edit with text editor, media library wey you rsync into, and anything wey you want make the path clear. The common bind-mount failure na ownership: container runs as UID 999, host directory belongs to UID 1000, and app dies during startup with permission denied for its logs. Named volumes make this type of problem mostly disappear, but the data go live for Docker-managed path, as explained below.
environment and .env, keep secrets out of git
${POSTGRES_PASSWORD} no dey come from your shell. Compose interpolates am from file named .env wey dey beside compose.yml. Create am:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreGenerate real values with openssl rand -hex 24. Use hex, no be base64, intentionally. This password go enter DATABASE_URL connection string, and the /, +, and = characters wey base64 produces go break URL parsing. The failure go appear as authentication error, no be syntax error, and e fit waste one evening. Put the .gitignore line before the first commit: you fit publish and version the compose file safely, but never publish or version .env file. Any secret wey don enter git history na secret wey you must rotate. If you start the stack with variable missing, Compose go warn clearly but continue with empty string. For Postgres password, this means broken deployment:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config prints the fully-interpolated file. Na the fastest way to check wetin containers go actually receive. Remember say the output includes your secrets.
depends_on waits for nothing, unless you add a healthcheck
Bare depends_on: [db] controls only start order. Compose launches Postgres first and the app shortly after, while Postgres still needs some seconds before e fit accept connections. App tries the database, fails, then crashes or retries depending on how well dem write am.
The reliable version na wetin the file above uses: db service defines healthcheck (Postgres ships pg_isready exactly for this), and app declares depends_on with condition: service_healthy. Compose starts database, checks am every 10 seconds, and starts Miniflux only after the check passes. If database never becomes healthy, maybe because password bad or volume corrupt, app no go start and Compose go tell you which dependency fail:
dependency failed to start: container miniflux-db-1 is unhealthyThat message points you to docker compose logs db. Na there the real error dey.
restart: unless-stopped
restart: unless-stopped for both services means containers go come back after crash and after VPS reboot, but dem go remain down if you deliberately run docker compose stop. The alternative always go bring containers back even after manual stop, and that rarely na wetin you mean. Without restart policy, kernel-update reboot at 4 a.m. fit quietly take your services down until you notice.
As cinco comando wey you dey use everyday
Everything wey you dey do everyday na five commands, and you go run dem 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 networkup -d safe to run many times. E compare the file with wetin dey run for real, and e only touch services wey their config or image don change. The upgrade pair go fetch anything wey your pinned tags now dey point to: patch releases under postgres:16-alpine. For exact pin, e no go fetch anything until you edit am, and na so e suppose be. Old images go pile up after upgrades; use docker image prune -f recover disk space.
Now the destructive one, make we talk am clearly: docker compose down safe, because containers and the network fit disposable, and your data dey inside the volume. docker compose down -v go delete the named volumes too. Na your database be that, e go disappear immediately, with no confirmation prompt and no undo. The -v flag dey for when you wan tear down experiments. For stack wey hold real data, treat am the same way you dey treat rm -rf. No trash can dey under /var/lib/docker/volumes/.
If you need one-off shell inside container wey dey run: docker compose exec db psql -U miniflux go enter the database, and docker compose exec miniflux sh go give you shell for the app.
Where your data dey actually live
Named volumes dey get the project prefix, so db-data for directory wey dem call miniflux go become miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataThe inspect output get the line wey matter:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"That directory na the database. root own am for the host filesystem. E survive down, upgrades, and container rebuilds. Na exactly that thing your backups must capture.
Back up a named volume
The standard pattern na container wey you go throw away. E mount the volume as read-only beside one host directory, then e tar the files 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 installation dey needed, and nothing remain running. To restore am, na the reverse process: tar xzf into one fresh empty volume, with the mounts swapped.
One thing to note for databases: if you tar a running Postgres data directory, e fit capture data while write dey happen. That kind state fit make the database no start cleanly. Either docker compose stop for the few seconds wey tar take, or, better, make you take a logical dump. Logical dump dey consistent by design:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gzThe -T disables the pseudo-terminal wey Compose dey allocate by default. If dump output pass through TTY, e fit corrupt the output. Put one of these commands for cron, then copy the result off the VPS. Backup wey dey on the same disk with the data wey e protect na just copy, e no be backup. The Nextcloud guide builds a complete scheduled routine around exactly these two patterns.
Failure modes, with the strings you go see
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, you never join the docker group yet, or you join am but this session old pass before the change. id go show the groups wey your current session dey use; newgrp docker go fix the current shell, but logging out and logging back in go fix all of dem.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, na different problem: the daemon itself dey down. sudo systemctl status docker and sudo journalctl -u docker -n 50 go talk why. For VPS, full disk na the common cause, so run df -h /var/lib/docker first.
Bind for 127.0.0.1:8080 failed: port is already allocated, another container don publish that host port already. docker ps go show which one; stale container from experimental docker run wey you run weeks ago na usually the culprit. If docker ps clean, process wey no be Docker process dey hold the port: sudo ss -tlnp | grep 8080 go name am.
yaml: line 14: did not find expected key, na indentation error for the line wey e name or just above am. Compose files na YAML: use two-space indentation, spaces only, and any tab character for anywhere go cause fatal error. docker compose config go validate the file without starting anything, and to run am after every edit na cheap habit.
The ufw surprise no print any error at all, and na this make am dangerous: the deploy work, ufw status look correct, but port scan from outside still find your database. Read the ports section above again, check every ports: entry for missing 127.0.0.1: prefix, and confirm from a different machine with curl http://your-vps-ip:8080; connection refused na the answer wey you want.
From here, the Traefik guide go turn this single stack into many apps behind one HTTPS entry point, and wetin worth self-hosting for 2026 na the shopping list to run through am. Once several of those stacks dey run and each one don create its own login form, self-hosted SSO server like Authentik go bring dem back to one account behind that same proxy.
Game server like Minecraft server for a VPS na friendly first Compose project wey you fit use practise. If you prefer learn with something wey you open every day, openGym, self-hosted workout tracker na small stack wey pin to git tag instead of image tag, and e need TLS for front before you register the first passkey. Photos na usually the first thing people want move away from another person cloud, and comparing PhotoPrism and Immich go help you settle the RAM floor and backup routine wey you dey agree to before you commit a volume to either one. When two services no dey feel enough again, setting up AFFiNE as Notion-style workspace use the same patterns for four containers, and e go properly test whether the pinned tags, healthchecks, and named volumes above don become habit.
FAQ
Why permission denied dey show when I try connect to Docker daemon socket?
Your user no dey inside docker group, or dem add am after the current session don start. Group membership only apply when you log in. Run sudo usermod -aG docker $USER, then newgrp docker, or log out and log in again, then confirm with id. This group gives root-equivalent access to the host, so only add users wey you go give sudo.
docker compose down dey delete my data?
Plain docker compose down no dey delete data. E removes containers and the project network. Named volumes dey remain, and the next up -d go attach dem again. docker compose down -v na the destructive form. E deletes the named volumes, including your database, without confirmation and without undo. Never run -v for stack wey get real data unless you get verified backup.
Wetin be the difference between docker-compose and docker compose?
docker-compose (hyphen) na Compose v1, standalone Python binary wey reach end of life for 2023. You no suppose install am for new servers. docker compose (space) na Compose v2, Go plugin for Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML almost fully compatible, so when old tutorial talk say docker-compose up, type docker compose up.
Why I fit reach my Docker container from internet even though ufw block the port?
Na because Docker dey publish ports with DNAT rules inside iptables' PREROUTING chain. The rewritten packets dey pass through FORWARD path inside Docker's own chains, so dem no reach INPUT chain where ufw rules dey apply. Because of this, ufw deny 8080 no do anything to published container port. Fix am from the source: publish to 127.0.0.1: and expose services through reverse proxy instead.
Make I use named volume or bind mount?
Use named volumes for data wey na only the container dey touch, especially databases. Docker go set the ownership wey the image expect, and permissions go work as expected. Use bind mounts for files wey you also handle from the host, like configs wey you edit and media wey you upload, or anything wey you want make the path obvious. If container no start and permission denied dey show for bind mount, first check whether host UID and container UID no match.