SSD Nodes Learn
Gidsen Matt ConnorDoor Matt Connor · Bijgewerkt 2026-07-24

Docker Compose installeren op Ubuntu 24.04

Leer Docker Engine en Compose v2 installeren op Ubuntu 24.04. Voorkom fouten met ufw poorten en leer volumes veilig backuppen voor uw VPS applicaties.

Wat u gaat bouwen

Docker Compose is de basis voor bijna alles op deze website. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — elke handleiding begint met de instructie "schrijf dit compose-bestand". Deze pagina legt uit wat dat bestand precies inhoudt. U installeert Docker Engine en de Compose v2-plugin via de officiële apt-repository van Docker op Ubuntu 24.04. Daarna zet u een stack met twee services op: Miniflux, een kleine RSS-reader, en PostgreSQL. Deze combinatie gebruikt alle patronen die grotere applicaties ook gebruiken: vaste image-versies, een database met een healthcheck, een named volume, secrets in een .env-bestand, en een poort die alleen naar localhost is gepubliceerd.

De installatie duurt vijf minuten. De rest van deze handleiding behandelt de onderdelen die later voor problemen zorgen: de docker-groep die in feite root is, gepubliceerde poorten die de ufw-regels omzeilen, en de flag op docker compose down die uw database verwijdert zonder bevestiging.

Vereisten: een schone Ubuntu 24.04 KVM VPS, een gebruiker met sudo-rechten, en minimaal 1 GB RAM. Een bestaande Docker-installatie is toegestaan — de eerste sectie legt uit wat u moet verwijderen.

Installeer vanuit de Docker-repo, niet vanuit Ubuntu

Voorkom twee fouten voordat u de eerste opdracht uitvoert. Het standaard docker.io-pakket van Ubuntu werkt, maar loopt achter op de releases van Docker. Bovendien mist dit pakket de plugin-structuur die andere componenten vereisen. De standalone docker-compose-binary — de versie met het koppelteken — is Compose v1. Deze is geschreven in Python, is sinds 2023 end-of-life en is de reden waarom oudere handleidingen niet werken. Compose is tegenwoordig docker compose (met een spatie). Dit is een CLI-plugin die wordt geïnstalleerd vanuit hetzelfde repository als de engine.

Als een van deze componenten al op het systeem staat, verwijder deze dan eerst. Vergeet hierbij niet docker-compose-v2 te verwijderen (de Ubuntu-versie van de plugin), zodat alle onderdelen uit hetzelfde repository komen:

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 de normale output op een nieuwe VPS. Voeg daarna het Docker-repository toe en installeer de software:

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

Controleer alle drie de lagen:

docker --version
docker compose version
sudo docker run --rm hello-world

De eerste twee commando's tonen de versienummers. Docker Compose version v2.x.x bevestigt dat u de plugin heeft gebruikt en niet de verouderde v1-binary. De hello-world-opdracht moet eindigen met Hello from Docker!. Het pakket zorgt ervoor dat de service automatisch start bij het opstarten; systemctl is-enabled docker toont enabled.

De docker group is root — wees u bewust van de gevolgen

Momenteel vereist elke docker command sudo, omdat de daemon socket op /var/run/docker.sock eigendom is van root en de docker group. Zonder lidmaatschap krijgt u de meest gezochte Docker-foutmelding:

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

De standaardoplossing:

sudo usermod -aG docker $USER

Lidmaatschap van een groep wordt pas actief bij het inloggen. De foutmelding blijft daarom aanwezig in uw huidige shell. Voer newgrp docker uit voor deze sessie, of log uit en opnieuw in; id moet vervolgens docker in uw groepen vermelden.

Nu het eerlijke deel, direct geformuleerd: lidmaatschap van de docker group is root op de host. Niet "root-achtig", niet "verhoogd" — maar root. Iedereen in die groep kan docker run --rm -it -v /:/host alpine chroot /host uitvoeren en het volledige filesystem beheren, zonder wachtwoord. De groep is bedoeld voor gebruiksgemak, niet voor isolatie.

De rootless mode van Docker is het echte alternatief — de daemon zelf draait dan als uw onbevoegde user. Dit brengt nadelen met zich mee: poorten onder de 1024 vereisen extra configuratie, netwerkverkeer loopt via een userspace shim met meetbare overhead, en sommige images werken niet correct zonder echte root-rechten. Op een VPS met slechts één administrator die al sudo gebruikt, verandert de groep in de praktijk niets. Alle handleidingen hier gaan van dit scenario uit — maar deel deze rechten nooit uit alsof het minder is dan sudo.

Anatomie van een compose file

Geef elke stack een eigen directory — de naam van de directory wordt de projectnaam, die containers, networks en volumes prefixeert:

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

Maak compose.yml aan (de moderne naam; docker-compose.yml werkt nog steeds). Sla de oude version: key over — deze is verouderd en Compose geeft een waarschuwing als deze wordt gevonden.

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:

Elke regel hierboven is een beslissing. Neem ze één voor één.

Pin image versions — :latest plus een pull is een ongecontroleerde upgrade

postgres:16-alpine, niet postgres:latest. Een tag is niet statisch: :latest wordt bij elke pull opnieuw opgelost naar wat de maintainer onlangs heeft gepusht. Combineer dit met de routine upgrade-gewoonte die u gaat leren — docker compose pull && docker compose up -d — en :latest betekent dat major-version sprongen plaatsvinden zodra de upstream ze publiceert, niet wanneer u dat kiest. Bij PostgreSQL is dit geen hypothetisch scenario: een onverwachte sprong van 16 naar 17 zorgt ervoor dat de container in een crash-loop terechtkomt door een incompatibele data directory, omdat Postgres major upgrades een dump en restore vereisen, geen restart.

Pin ten minste de major version (postgres:16-alpine volgt 16.x patch releases), en pin applicaties aan een exacte release zoals miniflux/miniflux:2.2.9 — controleer de releases-pagina van het project en gebruik de huidige versie wanneer u het bestand schrijft. Een upgrade wordt dan een bewuste wijziging van één regel, zichtbaar in git diff.

Publish to 127.0.0.1, omdat Docker ufw omzeilt

"127.0.0.1:8080:8080" — host address, host port, container port. De meeste tutorials schrijven "8080:8080", wat een afkorting is voor 0.0.0.0:8080:8080: luisteren op elke interface, inclusief de publieke interface.

Hier zit de valstrik die bijna iedereen één keer raakt. Docker publiceert een port door een DNAT-regel te schrijven die het doel van het pakket herschrijft naar de interne IP van de container voordat er gefilterd wordt. Het pakket volgt het FORWARD pad en raakt INPUT, waar uw ufw-rules staan, nooit. sudo ufw deny 8080 meldt succes, ufw status laat zien dat de port geweigerd is, en de service reageert nog steeds op het hele internet. Uw firewall is niet kapot; deze wordt ontworpen omzeild. Waarom Docker ufw omzeilt en hoe u containerverkeer echt filtert legt het mechanisme uit en biedt de DOCKER-USER oplossing voor ports die publiek moeten blijven.

De gewoonte die het probleem oplost: bind gepubliceerde ports aan 127.0.0.1 tenzij u een specifieke reden heeft om dit niet te doen, en plaats een reverse proxy voor alles wat de wereld moet kunnen bereiken. Dat is precies wat de Traefik reverse proxy guide bouwt als de volgende stap na deze pagina — één container die ports 80 en 443 bezit en via hostname naar de rest routeert, met TLS. (Komt u van een oudere Traefik v2 setup? De Traefik v2 naar v3 migratie guide behandelt de hernoemingen en regelwijzigingen.)

Verifieer de bind na het starten van de stack: sudo ss -tlnp | grep 8080 moet 127.0.0.1:8080 tonen, niet 0.0.0.0:8080 of *:8080.

Named volumes vs bind mounts

db-data:/var/lib/postgresql/data is een named volume: Docker maakt een directory aan onder /var/lib/docker/volumes/ en beheert deze, en mount deze in de container. Het alternatief is een bind mount, ./data:/var/lib/postgresql/data, wat een pad koppelt dat u op de host heeft gekozen.

Het onderscheid dat in de praktijk werkt: named volumes voor containers die alleen data aanraken — databases in het bijzonder, aangezien Docker het volume initialiseert met de eigendom die de image verwacht en de bestandsrechten direct werken. Bind mounts voor bestanden die u vanaf de host aanraakt — configuratiebestanden die u met een tekstverwerker bewerkt, een media library die u via rsync binnenhaalt, alles waarvan u wilt dat het pad duidelijk is. De klassieke bind-mount fout is eigendom: de container draait als UID 999, uw host directory is eigendom van UID 1000, en de app crasht bij het opstarten met permission denied in de logs. Named volumes laten dit type bug grotendeels verdwijnen, ten koste van het feit dat de data op een door Docker beheerd pad staat — hieronder beschreven.

environment en .env — houd secrets buiten git

${POSTGRES_PASSWORD} wordt niet gelezen uit uw shell; Compose interpreteert dit vanuit een bestand genaamd .env dat naast compose.yml staat. Maak dit aan:

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

Genereer echte waarden met openssl rand -hex 24. Gebruik hex in plaats van base64: dit wachtwoord komt in de DATABASE_URL connection string, en de /, + en = tekens die base64 produceert, breken de URL-parsing — een fout die verschijnt als een authenticatiefout, niet als een syntaxfout, en u een avond kost. De .gitignore regel moet vóór de eerste commit worden toegevoegd: het compose file is veilig om te publiceren en te versioneren, het .env bestand nooit, en een geheim dat de git-historie heeft geraakt, is een geheim dat u moet roteren. Als u de stack start met een ontbrekende variabele, waarschuwt Compose luidruchtig en gaat het verder met een lege string — wat voor een Postgres wachtwoord een mislukte deployment betekent:

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

docker compose config print het volledig geïnterpoleerde bestand — de snelste manier om te controleren wat de containers daadwerkelijk zullen ontvangen; onthoud dat de output uw geheimen bevat.

depends_on wacht op niets — tenzij u een healthcheck toevoegt

Een enkele depends_on: [db] regelt alleen de startvolgorde: Compose start eerst Postgres en een moment later de app, terwijl Postgres nog seconden verwijderd is van het accepteren van verbindingen. De app raakt de database, faalt, en crasht of probeert het opnieuw, afhankelijk van de kwaliteit van de code.

De betrouwbare versie is wat het bovenstaande bestand gebruikt: de db service definieert een healthcheck (Postgres levert pg_isready voor dit doel), en de app declareert depends_on met condition: service_healthy. Compose start de database, controleert de check elke 10 seconden, en start Miniflux pas als de check slaagt. Als de database nooit gezond wordt — fout wachtwoord, beschadigd volume — start de app nooit en vertelt Compose u welke dependency is mislukt:

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

Deze melding wijst u naar docker compose logs db, waar de werkelijke fout ligt.

restart: unless-stopped

restart: unless-stopped op beide services betekent dat de containers terugkeren na een crash en na een VPS reboot, maar uitgeschakeld blijven als u bewust docker compose stop heeft uitgevoerd. Het alternatief always herstelt containers zelfs na een handmatige stop — zelden wat u bedoelt. Zonder restart policy zorgt een kernel-update reboot om 4 uur 's nachts ervoor dat uw services stilzwijgend uitvallen totdat u het merkt.

De dagelijkse commando's

Alle dagelijkse taken bestaan uit vijf commando's, uit te voeren vanuit de projectmap.

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 kan veilig herhaaldelijk worden uitgevoerd — het vergelijkt het bestand met de huidige status en wijzigt alleen services waarvan de configuratie of image is gewijzigd. Het upgrade-paar haalt op waar uw pinned tags momenteel naar verwijzen: patch-releases onder postgres:16-alpine, maar niets voor een exacte pin totdat u deze bewerkt — dat is juist het doel. Oude images hopen zich op na upgrades; maak schijfruimte vrij met docker image prune -f.

Nu het destructieve commando: docker compose down is veilig — containers en het netwerk zijn vervangbaar en uw data staat in de volume. docker compose down -v verwijdert ook de benoemde volumes. Dat is uw database, direct weg, zonder bevestiging en zonder mogelijkheid tot herstel. De -v flag is bedoeld voor het afbreken van experimenten; behandel deze op een stack met echte data zoals u rm -rf behandelt. Er is geen prullenbak bij /var/lib/docker/volumes/.

Voor een eenmalige shell in een draaiende container: docker compose exec db psql -U miniflux geeft u toegang tot de database, en docker compose exec miniflux sh geeft u een shell in de app.

Waar uw gegevens zich daadwerkelijk bevindt

Named volumes krijgen het projectprefix, dus db-data in een directory genaamd miniflux wordt miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

De inspect output bevat de relevante regel:

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

Die directory is de database — deze is eigendom van root, staat op het host-filesystem en blijft behouden na down, upgrades en het opnieuw opbouwen van containers. Dit is exact wat uw backups moeten bevatten.

Een benoemde volume backuppen

Het standaardpatroon is een tijdelijke container die het volume read-only mount naast een host-directory, en vervolgens tars:

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 .

Er is geen installatie nodig, er blijft niets draaien, en de restore is het spiegelbeeld — tar xzf naar een nieuw leeg volume met de omgekeerde mounts.

Eén waarschuwing voor databases: het taren van een draaiende Postgres data-directory kan een status tijdens het schrijven vastleggen, waardoor de database niet correct start. Gebruik ofwel docker compose stop gedurende de seconden dat de tar loopt, of — beter — maak een logical dump, wat inherent consistent is:

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

De -T schakelt de pseudo-terminal uit die Compose standaard toewijst — het doorsturen van dump-output via een TTY kan de data beschadigen. Plaats een van deze commando's in cron en kopieer het resultaat naar een andere VPS; een backup op dezelfde schijf als de data die wordt beschermd, is een kopie en geen backup. De Nextcloud gids stelt een volledige geplande routine samen op basis van precies deze twee patronen.

Foutmodi, met de strings die u zult zien

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — u bent nog niet lid van de docker-groep, of u bent dat wel maar de sessie is ouder dan de wijziging. id toont uw actieve groepen; newgrp docker herstelt de huidige shell. Uitloggen en opnieuw inloggen herstelt alle groepen.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — een ander probleem: de daemon is gestopt. sudo systemctl status docker en sudo journalctl -u docker -n 50 geven de reden aan. Op een VPS is de meest voorkomende oorzaak een volle schijf — controleer eerst df -h /var/lib/docker.

Bind for 127.0.0.1:8080 failed: port is already allocated — een andere container gebruikt deze host-poort al. docker ps toont welke container dit is; een oude container van een experimentele docker run van weken geleden is vaak de oorzaak. Als docker ps schoon is, houdt een proces buiten Docker de poort vast: sudo ss -tlnp | grep 8080 identificeert dit proces.

yaml: line 14: did not find expected key — een fout in de inspringing op of vlak boven de genoemde regel. Compose-bestanden zijn YAML: gebruik twee spaties voor inspringing, uitsluitend spaties, en een tab-teken veroorzaakt een fout. docker compose config valideert het bestand zonder processen te starten. Het is aanbevolen om dit na elke wijziging uit te voeren.

De ufw verrassing geeft geen foutmelding, wat het gevaarlijk maakt: de deployment werkt, ufw status ziet er correct uit, maar een poortscan van buitenaf vindt de database alsnog. Lees de sectie over poorten hierboven opnieuw, controleer elke ports:-vermelding op een ontbrekende 127.0.0.1:-prefix, en controleer de status vanaf een ander systeem met curl http://your-vps-ip:8080 — "connection refused" is het gewenste resultaat.

Vanaf hier zet de Traefik gids deze enkele stack om in meerdere applicaties achter één HTTPS-entrypoint, en wat het waard is om zelf te hosten in 2026 is de lijst met software om doorheen te draaien.

Een gameserver zoals een Minecraft server op een VPS is een geschikt eerste Compose-project om te oefenen.

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.