SSD Nodes Learn Hosting plans →
Gidsen Matt ConnorDoor Matt Connor

Docker Compose installeren en configureren op Ubuntu 24.04

Installeer Docker Engine op Ubuntu 24.04 en bouw een stack met twee services. Leer hoe u de ufw-valstrik bij poortpublicatie vermijdt en uw Docker-volumes veilig back-upt.

Wat u gaat bouwen

Docker Compose vormt de basis voor bijna alles op deze site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat; elke handleiding begint met "schrijf dit compose-bestand". Deze pagina legt uit wat dat bestand daadwerkelijk betekent. U installeert Docker Engine en de Compose-plugin vanuit de officiële apt-repository van Docker op Ubuntu 24.04. Vervolgens zet u een stack met twee services op: Miniflux, een compacte RSS-lezer, en PostgreSQL. Dit duo gebruikt alle patronen die ook bij grotere applicaties voorkomen: vastgezette images, een database met een healthcheck, een named volume, geheimen in een .env-bestand en een poort die alleen op localhost wordt gepubliceerd.

De installatie duurt vijf minuten. De rest van deze handleiding behandelt de onderdelen die later voor problemen kunnen zorgen: de docker-groep die in feite root-rechten verleent, gepubliceerde poorten die uw ufw-regels omzeilen, en de specifieke vlag in docker compose down die uw database verwijdert zonder om bevestiging te vragen.

Vereisten: een schone Ubuntu 24.04 KVM VPS, een gebruiker met sudo-rechten en minimaal een gigabyte aan RAM. Een bestaande Docker-installatie is ook prima; de eerste sectie beschrijft wat u moet verwijderen.

Installeren vanuit de repository van Docker, niet die van Ubuntu

Er zijn twee verkeerde paden die u moet vermijden voordat u het eerste commando uitvoert. Het docker.io-pakket van Ubuntu werkt wel, maar loopt achter op de releases van Docker en mist de plugin-structuur waar alle andere software van uitgaat. Het commando met het koppelteken, docker-compose, is de weg die oudere handleidingen u opsturen. Op een oudere server is dat commando meestal Compose v1, geschreven in Python, door Docker als end-of-life verklaard en de reden waarom die handleidingen niet meer werken. Docker publiceert nog steeds een losstaand docker-compose-binary dat is gebouwd met de huidige Go-code, maar de eigen installatiepagina noemt die methode niet aanbevolen en enkel ondersteund voor achterwaartse compatibiliteit. Compose is tegenwoordig docker compose met een spatie, een CLI-plugin die vanuit dezelfde repository als de engine wordt geïnstalleerd.

Als er al iets van deze software op de server staat, verwijder dit dan eerst, inclusief docker-compose-v2 en docker-buildx, de eigen pakketten van Ubuntu voor de plugins, zodat alles uit één repository komt:

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc

Package 'docker.io' is not installed, so not removed is de normale output op een nieuwe VPS. Voeg daarna de repository van Docker toe en installeer:

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 versiestrings; Docker Compose version v5.x.x bevestigt dat u de plugin heeft en niet het verouderde v1-binary. De plugin maakte in december 2025 een sprong van v2 direct naar v5, waarbij 3 en 4 werden overgeslagen om verwarring met de oude versienummers van het compose-bestandsformaat te voorkomen. Handleidingen die hier v2.x.x tonen, beschrijven dus hetzelfde hulpmiddel. De hello-world-run moet eindigen met Hello from Docker!. Het pakket schakelt de service in bij het opstarten; systemctl is-enabled docker toont enabled.

De docker-groep is root, beslis met open ogen

Op dit moment vereist elk docker-commando sudo, omdat de socket van de daemon op /var/run/docker.sock eigendom is van root en de docker-groep. Zonder lidmaatschap krijgt u de meest gegoogelde 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

Groepslidmaatschap wordt toegepast bij het inloggen, dus de fout blijft bestaan in uw huidige shell. Voer newgrp docker uit voor deze sessie, of log uit en weer in; id zou daarna docker in uw groepen moeten tonen.

Nu het eerlijke deel, duidelijk gesteld: lidmaatschap van de docker-groep is root op de host. Niet "bijna root", niet "verhoogde rechten", maar root. Iedereen in die groep kan docker run --rm -it -v /:/host alpine chroot /host uitvoeren en het volledige bestandssysteem overnemen, zonder dat er om een wachtwoord wordt gevraagd. De groep bestaat voor het gemak, niet voor isolatie.

De rootless-modus van Docker is het echte alternatief; de daemon zelf draait dan als uw gebruiker zonder privileges. Dit heeft nadelen: poorten onder 1024 vereisen extra configuratie, netwerkverkeer verloopt via een userspace-shim met meetbare overhead, en sommige images werken niet correct zonder echte rootrechten. Op een VPS met één beheerder die al over sudo-rechten beschikt, verandert het groepslidmaatschap in de praktijk niets, en dit is wat elke handleiding hier aanneemt. Verstrek deze rechten echter nooit alsof het minder is dan sudo.

Anatomie van een compose-bestand

Geef elke stack een eigen map. De mapnaam wordt de projectnaam, die als voorvoegsel dient voor containers, netwerken en volumes:

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

Maak gebruik van compose.yml (de moderne naam; docker-compose.yml werkt ook nog). Sla de oude version:-sleutel over; deze is verouderd en Compose geeft een waarschuwing als het deze tegenkomt.

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 bewuste keuze. Neem ze één voor één door.

Pin image-versies, :latest plus een pull is een onbeheerde upgrade

Gebruik postgres:16-alpine, niet postgres:latest. Een tag is niet bevroren: :latest wordt telkens opnieuw opgelost naar de meest recente versie die de beheerder heeft gepusht wanneer u een pull uitvoert. Combineer dat met de routine voor upgrades die u zometeen leert, docker compose pull && docker compose up -d, en :latest betekent dat grote versiesprongen worden doorgevoerd zodra de upstream-bron ze uitbrengt, niet wanneer u dat wilt. Bij PostgreSQL is dit geen hypothetisch scenario: een verrassende sprong van 16 naar 17 zorgt ervoor dat de container in een crash-loop belandt vanwege een incompatibele datamap, omdat grote Postgres-upgrades een dump en restore vereisen, geen herstart.

Pin ten minste de hoofdversie (postgres:16-alpine volgt 16.x patch-releases) en pin applicaties op een exacte release zoals miniflux/miniflux:2.2.9. Controleer de releases-pagina van het project en gebruik de versie die actueel is op het moment dat u het bestand schrijft. Een upgrade wordt dan een wijziging van één regel die u bewust doorvoert, zichtbaar in git diff.

Publiceer naar 127.0.0.1, omdat Docker om ufw heen loopt

"127.0.0.1:8080:8080", host-adres, host-poort, container-poort. De meeste handleidingen schrijven "8080:8080", wat een afkorting is voor 0.0.0.0:8080:8080: luisteren op elke interface, inclusief de publieke.

Dit is de valkuil waar bijna iedereen wel eens in trapt. Docker publiceert een poort door een DNAT-regel te schrijven die de bestemmings-IP van het pakket herschrijft naar het interne IP van de container voordat de filtering plaatsvindt. Het pakket volgt daardoor het FORWARD-pad en raakt nooit INPUT, waar uw ufw-regels staan. sudo ufw deny 8080 rapporteert succes, ufw status toont dat de poort is geweigerd, en de service antwoordt nog steeds aan het hele internet. Uw firewall is niet defect; hij wordt bewust omzeild. Waarom Docker ufw omzeilt en hoe u containerverkeer echt filtert doorloopt het mechanisme en de DOCKER-USER-oplossing voor poorten die publiek moeten blijven.

De gewoonte die dit probleem volledig wegneemt: bind gepubliceerde poorten aan 127.0.0.1 tenzij u een specifieke reden heeft om dat niet te doen, en plaats een reverse proxy voor alles wat naar de buitenwereld moet kijken. Dat is precies wat de Traefik reverse proxy handleiding opbouwt als volgende stap na deze pagina: één container die poorten 80 en 443 beheert en alles doorstuurt op basis van hostnaam, met TLS. (Komt u van een oudere Traefik v2-opstelling? De Traefik v2 naar v3 migratiehandleiding behandelt de naamswijzigingen en regelwijzigingen.)

Controleer de binding na het starten van de stack: sudo ss -tlnp | grep 8080 hoort 127.0.0.1:8080 te tonen, niet 0.0.0.0:8080 of *:8080.

Named volumes versus bind mounts

db-data:/var/lib/postgresql/data is een named volume: Docker maakt en beheert een map onder /var/lib/docker/volumes/ en mount deze in de container. Het alternatief is een bind mount, ./data:/var/lib/postgresql/data, die een pad op de host koppelt dat u zelf kiest.

Het onderscheid dat in de praktijk standhoudt: named volumes voor data waar alleen containers bij kunnen, vooral databases, omdat Docker het volume initialiseert met de eigendomsrechten die de image verwacht en bestandsrechten simpelweg werken. Bind mounts voor bestanden die u vanaf de host beheert, configuratiebestanden die u met een teksteditor bewerkt, een mediabibliotheek die u via rsync synchroniseert, of alles waarvan u wilt dat het pad duidelijk is. De klassieke bind-mount fout is eigendom: de image voert het proces uit als een vaste UID (de Miniflux-image draait als UID 65534, postgres:16-alpine valt terug naar UID 70), uw host-map is eigendom van UID 1000, en de app crasht bij het opstarten met permission denied in de logs. Named volumes laten dat type bug grotendeels verdwijnen, ten koste van het feit dat de data op een door Docker beheerd pad staat, zoals hieronder beschreven.

environment en .env, houd geheimen uit git

Compose vult ${POSTGRES_PASSWORD} in vanuit een bestand genaamd .env dat naast compose.yml staat, maar alleen als uw shell die variabele nog niet heeft gedefinieerd: een waarde die in uw shell-omgeving is geëxporteerd, wint het van het .env-bestand. Een vergeten export POSTGRES_PASSWORD=... van een eerder experiment overschrijft daarom stilletjes het bestand, en docker compose config (hieronder) laat zien welke waarde heeft gewonnen. Maak het bestand 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. Hex, niet base64, en wel om een reden: dit wachtwoord komt in de DATABASE_URL-verbindingsreeks terecht, en de /, + en =-tekens die base64 produceert, breken URL-parsing. Dit is een fout die naar voren komt als een authenticatiefout, niet als een syntaxfout, en die u een avond kost. De .gitignore-regel moet worden toegevoegd voordat de eerste commit plaatsvindt: het compose-bestand is veilig om te publiceren en te versioneren, het .env-bestand is dat nooit, en een geheim dat in de git-geschiedenis is beland, is een geheim dat u moet roteren. Als u de stack start terwijl een variabele ontbreekt, waarschuwt Compose luidruchtig en gaat door met een lege string, wat voor een Postgres-wachtwoord een defecte implementatie betekent:

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

docker compose config print het volledig geïnterpoleerde bestand; dit is de snelste manier om te controleren wat de containers daadwerkelijk zullen ontvangen. Onthoud dat de uitvoer uw geheimen bevat.

depends_on wacht op niets, tenzij u een healthcheck toevoegt

Een kale depends_on: [db] regelt alleen de opstartvolgorde: Compose start Postgres eerst en de app een moment later, terwijl Postgres nog seconden verwijderd is van het accepteren van verbindingen. De app benadert de database, faalt, en crasht of probeert het opnieuw, afhankelijk van hoe goed deze is geschreven.

De betrouwbare versie is wat het bestand hierboven gebruikt: de db-service definieert een healthcheck (Postgres levert pg_isready precies hiervoor), en de app declareert depends_on met condition: service_healthy. Compose start de database, pollt de check elke 10 seconden, en start Miniflux pas zodra de check slaagt. Als de database nooit gezond wordt (verkeerd wachtwoord, corrupt volume), start de app nooit en vertelt Compose u welke afhankelijkheid faalde:

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

Dat bericht wijst u naar docker compose logs db, waar de werkelijke fout zich bevindt.

restart: unless-stopped

restart: unless-stopped op beide services betekent dat de containers terugkeren na een crash en na een VPS-herstart, maar uitgeschakeld blijven als u bewust docker compose stop heeft uitgevoerd. Het alternatief always wekt containers zelfs na een handmatige stop weer tot leven, wat zelden de bedoeling is. Zonder restart-beleid zorgt een kernel-update herstart om 04:00 uur ervoor dat uw services stilletjes offline gaan totdat u het opmerkt.

De dagelijkse commando's

Alles wat dagelijks nodig is, bestaat uit vijf commando's die u uitvoert 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 is veilig om herhaaldelijk uit te voeren; het vergelijkt het bestand met de huidige status en past alleen services aan waarvan de configuratie of image is gewijzigd. Het upgrade-paar haalt de versies op waar uw vastgezette tags naar verwijzen: patch-releases onder postgres:16-alpine, en niets voor een exacte pin totdat u deze zelf aanpast, wat precies de bedoeling is. Oude images hopen zich op na upgrades; maak schijfruimte vrij met docker image prune -f.

Nu het destructieve commando, met nadruk: 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 verwijderd, zonder bevestigingsvraag en zonder mogelijkheid tot herstel. De -v-vlag bestaat voor het afbreken van experimenten; behandel deze bij een stack met echte data op dezelfde manier als u rm -rf behandelt. Er is geen prullenbak onder /var/lib/docker/volumes/.

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

Waar uw data daadwerkelijk staat

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

docker volume ls
docker volume inspect miniflux_db-data

De inspect-output bevat de regel die ertoe doet:

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

Die map is de database, eigendom van root, op het bestandssysteem van de host, en deze blijft behouden na down, upgrades en het opnieuw opbouwen van containers. Dit is tevens precies wat uw back-ups moeten bevatten.

Back-up maken van een named volume

Het standaardpatroon is een tijdelijke container die het volume read-only koppelt naast een host-directory, en vervolgens alles archiveert met tar:

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 .

Geen installatie vereist, niets blijft draaien, en het herstel is het spiegelbeeld: tar xzf naar een nieuw, leeg volume met dezelfde mounts in omgekeerde volgorde.

Eén waarschuwing voor databases: het archiveren van een actieve Postgres data-directory kan een status tijdens het schrijven vastleggen die niet correct opstart. Stop de container docker compose stop voor de seconden die de tar-actie in beslag neemt, of, beter nog, maak een logische dump, die per definitie 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 deze beschadigen. Plaats een van deze commando's in cron en kopieer het resultaat van de VPS; een back-up op dezelfde schijf als de data die het beschermt is een kopie, geen back-up. De Nextcloud-handleiding bouwt een volledig geplande routine rondom precies deze twee patronen.

Foutmodi en de bijbehorende meldingen

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, u zit nog niet in de docker-groep, of u zit er wel in maar de sessie is van vóór de wijziging. id toont uw effectieve groepen; newgrp docker herstelt de huidige shell, uitloggen en opnieuw inloggen herstelt ze allemaal.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, een ander probleem: de daemon zelf is niet actief. sudo systemctl status docker en sudo journalctl -u docker -n 50 geven de reden aan. Op een VPS is de klassieke oorzaak een volle schijf, controleer dit met df -h /var/lib/docker.

Bind for 127.0.0.1:8080 failed: port is already allocated, een andere container heeft die hostpoort al gepubliceerd. docker ps laat zien welke; een verouderde container van een experiment docker run weken geleden is meestal de boosdoener. Als docker ps geen conflicterende containers toont, houdt een proces buiten Docker de poort bezet: sudo ss -tlnp | grep 8080 benoemt dit proces.

yaml: line 14: did not find expected key, een inspringfout op of net boven de genoemde regel. Compose-bestanden zijn YAML: twee spaties inspringing, uitsluitend spaties, en een tab-karakter op welke plek dan ook is fataal. docker compose config valideert het bestand zonder iets op te starten, en dit na elke wijziging uitvoeren is een goede gewoonte.

De ufw-verrassing geeft helemaal geen foutmelding, wat het gevaarlijk maakt: de deploy werkt, ufw status ziet er goed uit, en een poortscan van buitenaf vindt uw database alsnog. Lees de sectie over poorten hierboven opnieuw, controleer elk ports:-item op een ontbrekend 127.0.0.1:-voorvoegsel, en bevestig vanaf een andere machine met curl http://your-vps-ip:8080 dat 'connection refused' het antwoord is dat u wilt zien.

Vanaf hier verandert de Traefik-handleiding deze enkele stack in vele applicaties achter één HTTPS-toegangspunt, en wat de moeite waard is om in 2026 zelf te hosten is de boodschappenlijst om doorheen te lopen. Zodra er meerdere van die stacks draaien en elk zijn eigen inlogformulier heeft uitgevonden, brengt een zelfgehoste SSO-server zoals Authentik ze terug naar één account achter diezelfde proxy.

Een gameserver zoals een Minecraft-server op een VPS is een vriendelijk eerste Compose-project om mee te oefenen. Als u liever leert met iets dat u dagelijks opent, is openGym, een zelfgehoste workout-tracker een kleine stack die vastgepind is aan een git-tag in plaats van een image-tag, en deze vereist TLS aan de voorzijde voordat u de eerste passkey registreert. Foto's zijn meestal het eerste dat mensen terug willen halen van de cloud van een ander, en het vergelijken van PhotoPrism en Immich bepaalt de minimale hoeveelheid RAM en de back-uproutine waar u zich aan verbindt voordat u een volume aan een van beide toewijst. Wanneer twee services niet meer als genoeg voelen, is het opzetten van AFFiNE als een Notion-achtige werkruimte hetzelfde patroon met vier containers, en een goede test of de vastgepinde tags, healthchecks en benoemde volumes hierboven een gewoonte zijn geworden.

FAQ

Waarom krijg ik de melding "permission denied while trying to connect to the Docker daemon socket"?

Uw gebruiker zit niet in de docker-groep, of is pas toegevoegd nadat de huidige sessie was gestart; groepslidmaatschap wordt pas bij het inloggen actief. Voer sudo usermod -aG docker $USER uit, gevolgd door newgrp docker, of log uit en weer in, en controleer het resultaat met id. De groep verleent root-equivalente toegang tot de host, voeg dus alleen gebruikers toe aan wie u ook sudo-rechten zou verlenen.

Verwijdert docker compose down mijn data?

Een standaard docker compose down doet dit niet; het verwijdert containers en het projectnetwerk. Benoemde volumes blijven behouden en bij de volgende up -d worden deze opnieuw gekoppeld. docker compose down -v is de destructieve variant: deze verwijdert de benoemde volumes, inclusief uw database, zonder bevestigingsvraag en zonder mogelijkheid tot herstel. Voer nooit -v uit op een stack met actieve data, tenzij u beschikt over een geverifieerde back-up.

Wat is het verschil tussen docker-compose en docker compose?

docker-compose (met koppelteken) is de oude manier om Compose aan te roepen. Op oudere servers betreft dit meestal Compose v1, de oorspronkelijke Python-versie die door Docker als end-of-life is gemarkeerd. Docker publiceert nog steeds een zelfstandig docker-compose-binary gebouwd op basis van de huidige Go-code, maar raadt deze installatiemethode af en ondersteunt deze enkel voor achterwaartse compatibiliteit; sla deze op nieuwe servers dus over. docker compose (met spatie) is de huidige Compose, versie v2 en hoger (genummerd v5 sinds december 2025). Dit is een Go-plugin voor de Docker CLI, geïnstalleerd als docker-compose-plugin vanuit de apt-repository van Docker. Commando's en YAML-bestanden zijn vrijwel volledig compatibel; als een oude handleiding docker-compose up vermeldt, kunt u gewoon docker compose up typen.

Waarom is mijn Docker-container bereikbaar vanaf internet, terwijl ufw de poort blokkeert?

Omdat Docker poorten publiceert met DNAT-regels in de PREROUTING-chain van iptables, passeren de herschreven pakketten de FORWARD-route via Docker's eigen chains. Hierdoor bereiken ze nooit de INPUT-chain waar de regels van ufw worden toegepast. ufw deny 8080 heeft daarom geen effect op een gepubliceerde containerpoort. Los dit bij de bron op: publiceer naar 127.0.0.1: en stel services beschikbaar via een reverse proxy.

Moet ik een benoemd volume of een bind mount gebruiken?

Gebruik benoemde volumes voor data waar alleen de container mee werkt, in het bijzonder databases, omdat Docker de eigendomsrechten instelt zoals de image verwacht en de permissies direct correct zijn. Gebruik bind mounts voor bestanden die u ook vanaf de host beheert: configuraties die u bewerkt, media die u uploadt, of alles waarvan u het pad duidelijk wilt houden. Als een container bij het opstarten faalt met permission denied op een bind mount, is een mismatch tussen de UID van de host en de container het eerste wat u moet controleren.