Docker Compose Basics for Ubuntu 24.04 VPS
Install Docker Engine and Compose on Ubuntu 24.04, run Miniflux with PostgreSQL, avoid the ufw port trap, and back up volumes before data loss.
Wetin 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 start with “write this compose file”, and na this page explain wetin that file really mean. You go install Docker Engine and the Compose plugin from Docker own apt repository for Ubuntu 24.04, then start one real two-service stack: Miniflux, one small RSS reader, plus PostgreSQL. This pair dey show every pattern wey the bigger apps dey use: pinned images, database wey get healthcheck, named volume, secrets inside one .env file, and port wey publish only to localhost.
The install go take five minutes. The remaining part of this guide cover the things wey fit cause problem later: the docker group wey be root by another name, published ports wey dey bypass your ufw rules directly, and the one flag for docker compose down wey fit delete your database without any confirmation prompt.
Prerequisites: fresh Ubuntu 24.04 KVM VPS, user wey get sudo, and 1 gigabyte of RAM or more. Existing Docker install dey okay too; the first section explain wetin you need remove.
Install from Docker repo, no be Ubuntu own repo
Before you run the first command, avoid these two wrong approaches. Ubuntu own docker.io package dey work, but e dey behind Docker releases and e no get the plugin layout wey other tools expect. The hyphenated docker-compose command na the path wey older tutorials dey recommend. For older server, that command usually na Compose v1, wey dem write for Python. Docker don declare am end of life, and na why those tutorials dey fail. Docker still dey publish standalone docker-compose binary wey dem build from current Go code, but Docker own install page talk say dem no recommend that method and dem only support am for backward compatibility. 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 and docker-buildx, wey be Ubuntu own packaging of the plugins. That way, everything go come from one repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runcPackage 'docker.io' is not installed, so not removed na the normal output for fresh VPS. Then add Docker 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-pluginVerify all three layers:
docker --version
docker compose version
sudo docker run --rm hello-worldThe first two go print version strings. Docker Compose version v5.x.x confirm say na the plugin you get, no be the old v1 binary. The plugin move from v2 straight to v5 for December 2025. E skip 3 and 4 to avoid confusion with old compose file format versions. So guides wey show v2.x.x here dey describe the same tool. The hello-world run suppose end with Hello from Docker!. The package enable 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 wey explain how e dey work
Give each stack im own directory. The directory name go become the project name, and e go prefix containers, networks, and volumes:
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 after another.
Pin image versions; :latest plus a pull na unattended upgrade
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 mean say major-version jumps go enter whenever upstream releases dem, no be when you choose. With PostgreSQL, this no be theory: surprise jump from 16 to 17 go leave the container for crash loop because data directory no compatible. PostgreSQL major upgrades need dump and restore, no be restart.
Pin at least the major version (postgres:16-alpine go follow 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 then become one-line edit wey you intentionally make, and e go show for git diff.
Publish to 127.0.0.1, because Docker dey bypass ufw
"127.0.0.1:8080:8080", host address, host port, container port. Plenty tutorials write "8080:8080", wey na shorthand for 0.0.0.0:8080:8080: e dey listen on every interface, including public one.
Na here the trap dey, and e don catch almost everybody at least once. Docker publishes port by writing DNAT rule wey rewrite packet destination to container internal IP before filtering. So packet follow FORWARD path and e never touch INPUT, where your ufw rules dey live. sudo ufw deny 8080 reports success, ufw status shows say port deny, but service still dey answer the whole internet. Your firewall no spoil; Docker dey bypass am by design. Why Docker dey bypass ufw, and how to really filter container traffic explains the mechanism and the DOCKER-USER fix for ports wey must remain public.
The habit wey make the whole problem disappear be this: bind published ports to 127.0.0.1 unless you get specific reason not to, and put reverse proxy in front of anything wey suppose face the world. Na exactly this the Traefik reverse proxy guide build as the next step after this page: one container wey own ports 80 and 443 and route traffic to every other service by hostname, with TLS. (If you dey come from older Traefik v2 setup, the Traefik v2 to v3 migration guide cover the renames and rule changes.)
Verify the bind after you start the stack: sudo ss -tlnp | grep 8080 suppose show 127.0.0.1:8080, no be 0.0.0.0:8080 or *:8080.
Named volumes versus bind mounts
db-data:/var/lib/postgresql/data na named volume: Docker go create and manage directory under /var/lib/docker/volumes/ and mount am inside the container. The alternative na bind mount, ./data:/var/lib/postgresql/data, wey map path you choose for the host.
The split wey dey work well for practice be this: use named volumes for data wey only containers dey touch, especially databases, because Docker go initialise the volume with the ownership wey image expect and file permissions go work normally. 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 obvious. The common bind-mount failure na ownership: image run im process as fixed UID (Miniflux image run as UID 65534, postgres:16-alpine drop to UID 70), but your host directory belong to UID 1000. Then app die during startup with permission denied inside im logs. Named volumes make this type of bug mostly disappear, but the data go live for Docker-managed path, as explained below.
environment and .env; keep secrets out of git
Compose fills ${POSTGRES_PASSWORD} from file named .env wey dey beside compose.yml, but only when your shell no already define that variable. Value exported for your shell environment go override the .env file. So forgotten export POSTGRES_PASSWORD=... from earlier experiment fit silently override the file, and docker compose config (below) go show which value win. Create the file:
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 /, +, and = characters wey base64 produce go break URL parsing. The failure go appear as authentication error, no be syntax error, and e fit waste whole evening. Put .gitignore line before the first commit: you fit publish and version the compose file safely, but you must never publish .env file. Any secret wey don enter git history na secret wey you need rotate. If you start the stack while variable dey missing, Compose go warn loudly and continue with empty string. For Postgres password, this mean broken deployment:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config go print the fully-interpolated file. Na the fastest way to check wetin containers go actually receive. Remember say the output include your secrets.
depends_on no wait for anything unless you add healthcheck
Bare depends_on: [db] control start order only. Compose go launch Postgres first, then app shortly after, while Postgres still need some seconds before e fit accept connections. App go reach database, fail, then crash or retry depending on how well dem write am.
The reliable version na the one wey the file above use: the db service define healthcheck (Postgres ship pg_isready exactly for this), and app declare depends_on with condition: service_healthy. Compose go start database, poll the check every 10 seconds, and only start Miniflux after the check pass. If database never become 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 go point you to docker compose logs db. Na there the real error dey.
restart: unless-stopped
restart: unless-stopped for both services mean say containers go return 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. Most times, no be wetin you mean. Without restart policy, reboot after kernel update for 4 a.m. fit quietly take your services offline 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.
Wetin fit go wrong, and the strings wey you go see
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, you never dey inside docker group yet, or you dey inside am but the session start before the group change. id show the groups wey you effectively get; newgrp docker go fix the current shell, while logging out and logging back in go fix all groups.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, na different problem: the daemon itself no dey up. sudo systemctl status docker and sudo journalctl -u docker -n 50 talk why. For VPS, the common cause na disk wey don full, so run df -h /var/lib/docker first.
Bind for 127.0.0.1:8080 failed: port is already allocated, another container don already publish that host port. docker ps show which one; stale container from experimental docker run wey you run weeks ago na the usual culprit. If docker ps clean, process wey no be Docker process dey hold the port: sudo ss -tlnp | grep 8080 name am.
yaml: line 14: did not find expected key, indentation error dey for the line wey dem name or just above am. Compose files na YAML: use two-space indentation, spaces only, and tab character anywhere go cause fatal error. docker compose config validate the file without starting anything, and running 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, and port scan from outside still find your database. Read the ports section above again, check every ports: entry to see whether 127.0.0.1: prefix dey missing, 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 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 invent its own login form, self-hosted SSO server like Authentik go join dem back into one account behind that same proxy.
Game server like Minecraft server for VPS na friendly first Compose project to practise with. If you prefer learn with something wey you open every day, openGym, self-hosted workout tracker na small stack pinned to git tag instead of image tag, and e need TLS in front before you register the first passkey. Photos na usually the first thing wey people want bring back from another person cloud, and comparison of PhotoPrism and Immich settle the RAM floor and backup routine wey you dey sign up for before you commit volume to either one. When two services no feel enough again, setting up AFFiNE as Notion-style workspace use the same patterns for four containers, and na fair test of whether the pinned tags, healthchecks, and named volumes above don become habit.
FAQ
Wetin make I dey get "permission denied while trying to connect to the Docker daemon socket"?
Your user no dey inside docker group, or dem add am after your current session start; group membership only apply when you log in. Run sudo usermod -aG docker $USER, then newgrp docker, or log out and log back in. Confirm am with id. The group give root-equivalent access to the host, so only add users wey you for give sudo.
docker compose down dey delete my data?
Plain docker compose down no dey delete am. E remove 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 delete the named volumes, meaning 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 the old way to call Compose. For older servers, e usually be Compose v1, the original Python version wey Docker don declare end of life. Docker still dey publish standalone docker-compose binary wey current Go code build, but dem label that installation as not recommended and only support am for backward compatibility, so skip am for new servers. docker compose (space) na the current Compose, v2 onward and numbered v5 since December 2025. E be Go plugin for the 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 publish ports with DNAT rules inside iptables' PREROUTING chain. The rewritten packets follow the FORWARD path through Docker own chains, so dem no dey reach INPUT chain where ufw rules dey apply. Because of this, ufw deny 8080 no dey do anything to published container port. Fix am from the source: publish to 127.0.0.1: and expose services through reverse proxy instead.
I suppose use named volume or bind mount?
Use named volumes for data wey na only the container dey touch, especially databases, because Docker set the ownership wey the image expect and permissions just work. Use bind mounts for files wey you also handle from the host: configs wey you edit, media wey you upload, and anything wey you want make the path clear. If container fail during startup with permission denied on a bind mount, first check whether host UID and container UID no match.