SSD Nodes Learn
How to do am Matt ConnorBy Matt Connor · Updated 2026-07-24

how to install docker compose for VPS

Learn how to install Docker Engine and Compose v2 for Ubuntu 24.04. We go show you how to write compose.yml and avoid ufw port-publishing wahala for your VPS.

Wetin you dey build

Docker Compose na de foundation for almost everything for dis site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — every one of dem guides start with "write dis compose file", and dis page na him dey explain wetin dat file mean. You go install Docker Engine and de Compose v2 plugin from Docker's own apt repository for Ubuntu 24.04, den you go set up one real two-service stack — Miniflux, wey be small RSS reader, plus PostgreSQL — because dis pair dey use every pattern de bigger apps dey use: pinned images, database wey get healthcheck, named volume, secrets inside one .env file, and one port wey open only to localhost.

De install go take five minutes. De rest of dis guide go cover de parts wey dey cause wahala later: how de docker group na root with another name, how published ports dey bypass your ufw rules, and de one flag for docker compose down wey dey delete your database without asking you permission.

Prerequisites: one fresh Ubuntu 24.04 KVM VPS, one user wey get sudo, and one gigabyte of RAM or more. If you don already install Docker, no wahala — de first section go tell you wetin you need to remove.

Install from Docker's repo, no use Ubuntu's

You must avoid two mistakes before you run the first command. Ubuntu's own docker.io package dey work, but e dey slow for updates 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: e use Python, and e don reach end-of-life since 2023, na why old tutorials dey fail. Compose today na docker compose with space, e be CLI plugin, and you go install am from the same repository as the engine.

If any of dem dey already for your box, clear am first — including docker-compose-v2, wey be Ubuntu's 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 runc

Package 'docker.io' is not installed, so not removed na the normal output for 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 go print version strings — Docker Compose version v2.x.x confirm say you get the plugin, no be the dead v1 binary. The hello-world run must end with Hello from Docker!. The package go enable the service for boot; systemctl is-enabled docker go print enabled.

The docker group na root — make you decide with your eyes open

Right now, every docker command need sudo, because the daemon's socket for /var/run/docker.sock na root and the docker group own am. If you no dey inside that group, you go see the Docker error wey people dey search for pass for Google:

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 dey work only when you login again, so the error go still dey for your current shell. Run newgrp docker for this session, or log out and log back in; id suppose list docker for your groups.

Now, the truth wey we must talk plain: membership of the docker group na root for the host. No be "root-ish", no be "elevated" — na root. Anybody wey dey inside that group fit run docker run --rm -it -v /:/host alpine chroot /host and take control of the entire filesystem, without any password. The group dey exist for convenience, no be for containment.

Docker's rootless mode na the real alternative — the daemon itself dey run as your unprivileged user. But e go cost you: ports below 1024 need extra setup, networking dey run through a userspace shim wey go slow small, and some images fit misbehave if dem no get real root. For single-admin VPS where the only login already get sudo, the group no change anything for practice, and na wetin every guide for here assume — just no dey give am out like say e no heavy like sudo.

Anatomy of a compose file

Give every stack its own directory — the name of the directory na him go be the project name, wey go prefix containers, networks, and volumes:

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

Create compose.yml (na the modern name; docker-compose.yml still dey work). No use the old version: key — e don old and Compose go warn you 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 wey dey above na decision. Take dem one by one.

Pin image versions — :latest plus a pull na unattended upgrade

postgres:16-alpine, no be postgres:latest. Tag no dey freeze: :latest go always pick whatever the maintainer push last, every time you pull. Combine that with the upgrade habit wey you wan learn — docker compose pull && docker compose up -d — and :latest means major-version jumps go happen whenever upstream release dem, no be when you choose. For PostgreSQL, e no be theory: surprise jump from 16 to 17 fit make container dey crash-loop because data directory no compatible, because Postgres major upgrades need dump and restore, no be just restart.

Pin at least the major version (postgres:16-alpine dey 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 wetin dey current when you dey write the file. Upgrade den go become simple one-line edit wey you do on purpose, wey you fit see for 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 dey write "8080:8080", wey na shorthand for 0.0.0.0:8080:8080: e dey listen on every interface, including the public one.

Na here the trap dey, and e dey catch almost everybody once. Docker dey publish port by writing DNAT rule wey dey rewrite the packet's destination to the container's internal IP before e check filter, so the packet dey take FORWARD path and e no dey touch INPUT, wey your ufw rules dey stay. sudo ufw deny 8080 go say success, ufw status go show say port denied, but the service still dey answer 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 go explain how the mechanism dey work and the DOCKER-USER fix for ports wey must stay public.

The habit wey go make the problem vanish: bind published ports to 127.0.0.1 unless you get special reason, and put reverse proxy for front for anything wey suppose face the world. Na exactly wetin Traefik reverse proxy guide dey build as next step after this page — one container wey get ports 80 and 443 and dey route everything else by hostname, with TLS. (If you dey come from old Traefik v2 setup? The Traefik v2 to v3 migration guide dey 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 vs bind mounts

db-data:/var/lib/postgresql/data na named volume: Docker dey create and manage directory under /var/lib/docker/volumes/ and e dey mount am inside the container. The other one na bind mount, ./data:/var/lib/postgresql/data, wey dey map path wey you choose for host.

The way wey dey work well for real life: named volumes for data only containers touch — especially databases, because Docker dey initialize the volume with the ownership wey image expect and file permissions go dey work. Bind mounts for files wey you dey touch from host — config files wey you dey edit with text editor, media library wey you dey rsync into, anything wey you want make path clear. The classic bind-mount failure na ownership: the container dey run as UID 999, your host directory dey owned by UID 1000, and the app go die for startup with permission denied for its logs. Named volumes dey make that kind bug disappear, but the cost na say data dey live for Docker-managed path — we go see am below.

environment and .env — keep secrets out of git

${POSTGRES_PASSWORD} no dey read from your shell; Compose dey pull am from file wey dem dey call .env wey dey sit 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" >> .gitignore

Generate real values with openssl rand -hex 24. Hex, no be base64, on purpose: this password dey land inside the DATABASE_URL connection string, and the /, +, and = characters wey base64 dey produce go break URL parsing — e go show as authentication error, no be syntax error, and e go waste your evening. The .gitignore line dey go before the first commit: the compose file safe to publish and version, but the .env file no safe at all, and any secret wey don touch git history, you must rotate am. If you start the stack and one variable dey missing, Compose go warn you loud and continue with empty string — wey for Postgres password mean say deployment don spoil:

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

docker compose config dey print the fully-interpolated file — na the fastest way to check wetin the containers go actually receive; remember say the output include your secrets.

depends_on waits for nothing — unless you add a healthcheck

Just using depends_on: [db] dey control order only: Compose go launch Postgres first and the app a moment later, even when Postgres still dey seconds away from accepting connections. The app go hit the database, fail, and crash or retry depending on how well dem write am.

The reliable version na wetin the file above dey use: the db service dey define healthcheck (Postgres dey ship pg_isready for this exact thing), and the app dey declare depends_on with condition: service_healthy. Compose go start the database, dey check the health every 10 seconds, and only start Miniflux once the check pass. If the database no ever become healthy — bad password, corrupt volume — the app no go start and Compose go tell you which dependency fail:

dependency failed to start: container miniflux-db-1 is unhealthy

That message go point you to docker compose logs db, wey na where the real error dey.

restart: unless-stopped

restart: unless-stopped for both services mean say the containers go come back after crash and after VPS reboot, but dem go stay down if you deliberately run docker compose stop. The other one, always, dey bring containers back even after you stop dem manually — and that one rarely na wetin you want. Without restart policy, kernel-update reboot for 4 a.m. fit quietly take your services down until you notice am.

The daily verbs

Everyday work involve five commands, wey you must 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

You fit run up -d many times without wahala — e dey compare the file against reality and e go only touch services wey their config or image change. The upgrade pair go fetch whatever your pinned tags dey point to now: patch releases under postgres:16-alpine, but e no go do anything for exact pin until you edit am — na that one be the point. Old images dey pile up after upgrades; use docker image prune -f to clear the disk space.

Now for the one wey fit destroy everything, make you listen well: docker compose down safe — containers and the network na disposable thing, and your data dey inside the volume. docker compose down -v go delete the named volumes too. That one na your database, e go vanish instantly, without any confirmation prompt and without any way to undo am. The -v flag dey for when you wan tear down experiments; for stack wey hold real data, treat am like how you dey treat rm -rf. No trash can dey under /var/lib/docker/volumes/.

If you want one-off shell inside running container: docker compose exec db psql -U miniflux go drop you inside the database, and docker compose exec miniflux sh go give you shell inside the app.

Wey your data dey stay

Named volumes dey get project prefix. So db-data inside directory wey dem call miniflux go become miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

The inspect output go show the line wey important:

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

That directory na the database — root own am, e dey for host filesystem, and e go still dey even if you do down, upgrades, or container rebuilds. Na that exact directory your backups must carry.

Back up a named volume

Standard way to do am na to use one throwaway container wey go mount the volume as read-only next to one host directory, den tar am:

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 need to install anything, nothing go dey run, and the restore go be mirror image — tar xzf into one fresh empty volume wey get the same mounts wey dem reverse.

One thing to watch out for for databases: if you tar one running Postgres data directory, you fit catch mid-write state wey go make am no start well when you try run am again. Either use docker compose stop for the seconds wey the tar dey take, or — better way — take one logical dump, because dat one na consistent by construction:

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

The -T dey disable the pseudo-terminal wey Compose dey allocate by default — if you pipe dump output through TTY, e fit corrupt am. Put one of these inside cron and copy the result off the VPS; backup wey dey same disk as the data wey e dey protect na just copy, no be real backup. The Nextcloud guide build full scheduled routine wey use 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.

A game server such as a Minecraft server on a VPS is a friendly first Compose project to practise on.

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.