← Terug naar tutorials

Docker Compose 101: Multi-container apps lokaal draaien (voor beginners)

docker composedockercontainerslokale developmentbeginnercompose.ymlmulti-containerdev workflow

Docker Compose 101: Multi-container apps lokaal draaien (voor beginners)

Docker Compose is een hulpmiddel waarmee je meerdere containers samen als één applicatie kunt definiëren en starten. In plaats van losse docker run-commando’s te onthouden voor een database, een backend en eventueel een reverse proxy, beschrijf je alles in één Compose-bestand en beheer je de hele “stack” met een paar commando’s.

In deze tutorial leer je wat Docker Compose is, hoe je het installeert en gebruikt, en je bouwt stap voor stap een multi-container applicatie (een webapp + database) die je lokaal kunt draaien. Je krijgt ook uitleg over netwerken, volumes, omgevingsvariabelen, logs, debugging, herstartbeleid en veelgemaakte fouten.


Inhoud


1. Wat is Docker Compose?

Docker Compose is een tool die:

Zonder Compose zou je bijvoorbeeld dit moeten doen:

Met Compose beschrijf je dat declaratief en herhaalbaar.


2. Begrippen: container, image, service, netwerk, volume

Image
Een image is een “template” (een pakket) met een bestandssysteem en metadata om een container te starten. Voorbeelden: postgres:16, node:20-alpine, nginx:alpine.

Container
Een container is een draaiende instantie van een image. Containers zijn in principe tijdelijk: als je ze verwijdert, ben je data kwijt tenzij je volumes gebruikt.

Service (Compose)
In Compose is een service een beschrijving van “hoe een container moet draaien”: welk image, welke poorten, welke volumes, welke environment variables, enz. Compose kan meerdere replicas draaien, maar voor beginners is één container per service het meest gebruikelijk.

Netwerk
Compose maakt standaard een eigen netwerk aan per project. Services kunnen elkaar bereiken via de servicenaam als hostname. Dus een service db is bereikbaar als db op de interne poort (bijvoorbeeld 5432 voor Postgres).

Volume
Een volume is persistente opslag beheerd door Docker. Hiermee blijft data bestaan als je containers opnieuw maakt. Voor databases is dit essentieel.


3. Vereisten en installatie checken

Je hebt nodig:

Controleer je installatie:

docker version
docker compose version

Let op: het moderne commando is docker compose (met spatie). Soms bestaat ook docker-compose (met streepje), maar deze tutorial gebruikt de moderne variant.


4. Projectstructuur opzetten

We maken een simpele Node.js webapp die een teller in een Postgres database bijhoudt. Dit is bewust klein, maar realistisch: je ziet meteen hoe services samenwerken.

Maak een map:

mkdir compose-101
cd compose-101

Maak deze structuur:

compose-101/
  app/
    package.json
    server.js
  docker-compose.yml
  .env

Maak de map app:

mkdir app

5. Eerste Compose-bestand: web + database

5.1 De Node.js app maken

Maak app/package.json:

{
  "name": "compose-101-app",
  "version": "1.0.0",
  "main": "server.js",
  "type": "commonjs",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "pg": "^8.11.3"
  }
}

Maak app/server.js:

const http = require("http");
const { Client } = require("pg");

const PORT = process.env.PORT || 3000;

const dbConfig = {
  host: process.env.DB_HOST || "db",
  port: Number(process.env.DB_PORT || 5432),
  user: process.env.DB_USER || "postgres",
  password: process.env.DB_PASSWORD || "postgres",
  database: process.env.DB_NAME || "postgres",
};

async function ensureSchema() {
  const client = new Client(dbConfig);
  await client.connect();
  await client.query(`
    CREATE TABLE IF NOT EXISTS counter (
      id INTEGER PRIMARY KEY,
      value INTEGER NOT NULL
    );
  `);
  await client.query(`
    INSERT INTO counter (id, value)
    VALUES (1, 0)
    ON CONFLICT (id) DO NOTHING;
  `);
  await client.end();
}

async function incrementAndGet() {
  const client = new Client(dbConfig);
  await client.connect();
  const res = await client.query(`
    UPDATE counter
    SET value = value + 1
    WHERE id = 1
    RETURNING value;
  `);
  await client.end();
  return res.rows[0].value;
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.url === "/") {
      const value = await incrementAndGet();
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(`Hallo! Teller staat nu op: ${value}\n`);
      return;
    }

    if (req.url === "/health") {
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end("ok\n");
      return;
    }

    res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    res.end("Niet gevonden\n");
  } catch (err) {
    res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
    res.end(`Fout: ${err.message}\n`);
  }
});

(async () => {
  await ensureSchema();
  server.listen(PORT, () => {
    console.log(`Web luistert op poort ${PORT}`);
    console.log(`DB host: ${dbConfig.host}:${dbConfig.port}`);
  });
})();

Belangrijk om te begrijpen:

5.2 Compose-bestand maken

Maak docker-compose.yml in de root:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  web:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    command: sh -c "npm install && npm start"
    environment:
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}
    ports:
      - "3000:3000"
    depends_on:
      - db

volumes:
  pgdata:

Wat gebeurt hier precies?

5.3 .env maken

Maak .env:

DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=compose101

Compose leest standaard een .env in dezelfde map als docker-compose.yml. Variabelen zoals ${DB_PASSWORD} worden dan ingevuld.


6. De applicatie starten, stoppen en opruimen

6.1 Starten

Start alles:

docker compose up

Je ziet logs van beide services. Wil je het in de achtergrond draaien:

docker compose up -d

Controleer draaiende containers:

docker compose ps

6.2 Testen in de browser of met curl

In een andere terminal:

curl http://localhost:3000/
curl http://localhost:3000/
curl http://localhost:3000/

Je zou zien dat de teller oploopt.

6.3 Stoppen

Stop services (containers blijven bestaan):

docker compose stop

Stop en verwijder containers, maar behoud volumes:

docker compose down

Stop en verwijder containers én volumes (data weg):

docker compose down -v

7. Data bewaren met volumes (persistente opslag)

Waarom is het volume belangrijk?

Een database schrijft data in /var/lib/postgresql/data. Als je geen volume gebruikt, staat die data in de containerlaag. Verwijder je de container, dan is je database leeg.

In het Compose-bestand:

volumes:
  - pgdata:/var/lib/postgresql/data

En onderaan:

volumes:
  pgdata:

Dit maakt een named volume. Je kunt volumes bekijken:

docker volume ls
docker volume inspect compose-101_pgdata

Let op: Compose geeft volumes vaak een projectprefix. Als je map compose-101 heet, kan het volume compose-101_pgdata heten.

Wil je testen dat data echt blijft?

  1. Start stack en doe een paar requests.
  2. Doe docker compose down (zonder -v).
  3. Start opnieuw met docker compose up -d.
  4. Doe weer curl http://localhost:3000/ en kijk of de teller doorgaat.

8. Omgevingsvariabelen en .env

Je hebt twee soorten variabelen om uit elkaar te houden:

  1. Compose-variabelen (interpolatie): ${DB_PASSWORD} in docker-compose.yml wordt ingevuld vanuit .env of je shell.
  2. Container-variabelen (runtime): environment: bepaalt wat er in de container beschikbaar is.

In ons bestand gebeurt beide tegelijk: ${DB_PASSWORD} wordt door Compose vervangen en als waarde doorgegeven aan de container.

Handig commando om te zien wat Compose ervan maakt:

docker compose config

Dit toont de “uitgewerkte” configuratie met ingevulde variabelen.

Tip: zet geen echte productie-wachtwoorden in .env als je dit project deelt. Voor lokaal leren is het prima.


9. Netwerken en service discovery

Compose maakt standaard een netwerk aan, bijvoorbeeld compose-101_default. Daardoor:

Belangrijk: binnen het Compose-netwerk gebruik je de containerpoort, niet de hostpoort. Dus:

Bekijk netwerken:

docker network ls
docker network inspect compose-101_default

10. depends_on en “wachten tot de database klaar is”

depends_on regelt de startvolgorde, niet de gereedheid. Postgres kan een paar seconden nodig hebben om te initialiseren. Onze app probeert direct tabellen aan te maken; soms gaat dat te snel en krijg je een verbindingsfout.

10.1 Simpele oplossing: retry-logica in de app

In echte projecten bouw je vaak retry’s in (bijvoorbeeld 10 pogingen met wachttijd). Dat is robuust, want ook in productie kan een database tijdelijk niet bereikbaar zijn.

10.2 Compose-oplossing: healthcheck en conditionele afhankelijkheid

Je kunt een healthcheck toevoegen aan db en dan web laten wachten tot db “healthy” is. Dit werkt in veel omgevingen, maar gedrag kan verschillen per Compose-versie. Toch is het nuttig om te kennen.

Pas docker-compose.yml aan:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    command: sh -c "npm install && npm start"
    environment:
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

Als jouw Compose-variant condition: service_healthy niet accepteert, dan is de beste aanpak: retry’s in de app of een klein “wait-for” script.


11. Logs, shells en debugging

11.1 Logs bekijken

Alle logs volgen:

docker compose logs -f

Alleen web:

docker compose logs -f web

Alleen db:

docker compose logs -f db

11.2 Een shell openen in een container

In de webcontainer:

docker compose exec web sh

In de db-container:

docker compose exec db bash

Let op: sommige images (zoals alpine) hebben sh maar geen bash.

11.3 Verbinden met Postgres via psql

In de db-container:

docker compose exec db psql -U postgres -d compose101

Dan kun je queries doen:

SELECT * FROM counter;

Stop psql met:

\q

11.4 Containers inspecteren

Bekijk details:

docker compose ps
docker inspect compose-101-web-1

(De exacte containernaam kan verschillen.)


12. Herstartbeleid en gezondheidchecks

12.1 Restart policy

Voor lokale ontwikkeling wil je vaak dat containers automatisch herstarten bij een crash. Voeg toe:

services:
  web:
    restart: unless-stopped
  db:
    restart: unless-stopped

Uitleg:

12.2 Healthchecks

Een healthcheck is een periodieke test. Voor web kun je bijvoorbeeld:

services:
  web:
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health >/dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 5

Waarom nuttig?


13. Profielen en extra services (optioneel)

Soms wil je extra tools alleen af en toe starten, zoals een database-admin UI. Met profielen kun je dat netjes scheiden.

Voorbeeld: voeg Adminer toe (web UI voor databases):

services:
  adminer:
    image: adminer:4
    ports:
      - "8080:8080"
    profiles:
      - tools

Start normaal (zonder tools):

docker compose up -d

Start met tools-profiel:

docker compose --profile tools up -d

Ga dan naar:

Login:


14. Veelgemaakte fouten en oplossingen

14.1 “Port is already allocated”

Fout: poort 5432 of 3000 is al in gebruik.

Oplossingen:

ports:
  - "5433:5432"

Dan verbind je lokaal met localhost:5433, maar intern blijft het db:5432.

14.2 Database blijft leeg na herstart

Waarschijnlijk heb je docker compose down -v gebruikt, waardoor het volume is verwijderd. Gebruik docker compose down zonder -v als je data wilt behouden.

14.3 “Web kan db niet vinden”

Controleer:

Check:

docker compose ps
docker compose logs db
docker compose exec web sh -c "cat /etc/resolv.conf && nslookup db || true"

14.4 Permissieproblemen met bind mounts

Op sommige systemen kunnen permissies lastig zijn. Als npm install faalt door rechten, kun je:

Eenvoudige aanpak: voeg een volume toe voor node_modules:

services:
  web:
    volumes:
      - ./app:/app
      - node_modules:/app/node_modules

volumes:
  pgdata:
  node_modules:

15. Handige Compose-commando’s (spiekbriefje)

Starten (foreground):

docker compose up

Starten (background):

docker compose up -d

Herbouwen (als je een eigen Dockerfile gebruikt):

docker compose up --build

Stoppen:

docker compose stop

Down (containers weg, netwerk weg):

docker compose down

Down inclusief volumes:

docker compose down -v

Status:

docker compose ps

Logs:

docker compose logs -f
docker compose logs -f web

Commando in container:

docker compose exec web sh
docker compose exec db psql -U postgres -d compose101

Configuratie uitgeschreven:

docker compose config

Afronding: wat je nu kunt

Na deze tutorial kun je:

Als je hierna een stap verder wilt, zijn logische vervolgstappen:

Wil je dat ik een variant toevoeg met een eigen Dockerfile en een productie-achtige setup (zonder npm install bij elke start), dan kan dat met dezelfde basisstructuur.