← Terug naar tutorials

Local vs Production Docker Compose: voorkom environment drift en verrassingen

docker composedevopsenvironment driftci/cdcontainers

Local vs Production Docker Compose: voorkom environment drift en verrassingen

Environment drift is de sluipmoordenaar van betrouwbare deployments: je applicatie draait lokaal “prima”, maar in productie krijg je timeouts, andere dependencies, rare permissieproblemen of data die “opeens” weg is. Docker Compose kan dit probleem kleiner maken, maar alleen als je Compose-bestanden en je workflow bewust ontwerpt.

In deze tutorial leer je hoe je Docker Compose lokaal en in productie inzet zonder verrassingen. We behandelen een robuuste bestandsstructuur, overlays, profielen, secrets, healthchecks, logging, resource-limieten, reverse proxies, CI/CD en troubleshooting. Alles met echte commando’s en concrete voorbeelden.


Inhoud

  1. Wat is environment drift precies?
  2. Kernprincipes: “zelfde image, andere configuratie”
  3. Aanbevolen Compose-structuur: base + overlays
  4. Voorbeeldproject: web + worker + db + redis
  5. Build-strategie: lokaal bouwen vs in CI bouwen
  6. Omgevingsvariabelen: .env, env_file, en expliciete defaults
  7. Profiles: services alleen lokaal of alleen prod
  8. Volumes en data: persistentie, permissies en migraties
  9. Networking: interne netwerken, poorten en reverse proxy
  10. Healthchecks en startvolgorde
  11. Secrets en gevoelige configuratie
  12. Resource-limieten en performanceverschillen
  13. Logging en observability
  14. Deployen naar productie zonder Swarm/Kubernetes
  15. CI/CD: images bouwen, taggen en uitrollen
  16. Checklist: zo voorkom je drift
  17. Troubleshooting: typische “works on my machine” issues

Wat is environment drift precies?

Environment drift betekent dat je lokale omgeving en je productieomgeving steeds verder uit elkaar groeien. Dat kan subtiel zijn:

Docker helpt door te containeriseren, maar Compose kan drift óók veroorzaken als je lokaal “even snel” andere mounts, ports of build args gebruikt dan in productie.

Doel: één bron van waarheid voor services, met kleine, expliciete verschillen per omgeving.


Kernprincipes: “zelfde image, andere configuratie”

De meest robuuste aanpak:

  1. Bouw één image per service (bij voorkeur in CI) en gebruik die image zowel lokaal als in productie.
  2. Gebruik Compose om configuratie te variëren, niet om andere codepaden te creëren.
  3. Houd verschillen tussen local en prod beperkt tot:
    • ports (wel/niet exposen)
    • volumes (bind mount vs read-only)
    • debug tooling
    • logging/monitoring
    • resource limits
    • reverse proxy/TLS

Vermijd:


Aanbevolen Compose-structuur: base + overlays

Gebruik een base compose met gedeelde definitie en overlays voor local/prod.

Voorbeeldstructuur:

.
├─ compose.yml
├─ compose.local.yml
├─ compose.prod.yml
├─ .env                # lokale defaults (niet voor prod secrets)
├─ .env.local          # optioneel, gitignored
├─ .env.prod           # op server, niet in git
├─ docker/
  ├─ web/Dockerfile
  ├─ worker/Dockerfile
  └─ nginx/
     ├─ nginx.conf
     └─ conf.d/
└─ scripts/
   ├─ deploy.sh
   └─ wait-for-it.sh

Je draait dan:

docker compose -f compose.yml -f compose.local.yml up -d --build
docker compose -f compose.yml -f compose.prod.yml up -d

Belangrijk: Compose merge’t bestanden. De laatste -f wint bij conflicten.


Voorbeeldproject: web + worker + db + redis

We maken een voorbeeld met:

compose.yml (base)

Let op: dit is Markdown tutorialtekst; je kunt dit direct kopiëren naar compose.yml.

services:
  web:
    image: ghcr.io/jouw-org/jouw-app-web:${APP_TAG:-dev}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      APP_ENV: ${APP_ENV:-local}
      DATABASE_URL: ${DATABASE_URL:-postgresql://app:app@db:5432/app}
      REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
      PORT: "8080"
    healthcheck:
      test: ["CMD", "sh", "-c", "wget -qO- http://localhost:8080/health || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 10
    networks:
      - appnet

  worker:
    image: ghcr.io/jouw-org/jouw-app-worker:${APP_TAG:-dev}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      APP_ENV: ${APP_ENV:-local}
      DATABASE_URL: ${DATABASE_URL:-postgresql://app:app@db:5432/app}
      REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
    networks:
      - appnet

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-app}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
      POSTGRES_DB: ${POSTGRES_DB:-app}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 30
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - appnet

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 30
    volumes:
      - redis_data:/data
    networks:
      - appnet

networks:
  appnet:

volumes:
  db_data:
  redis_data:

Waarom dit base-bestand drift vermindert:


Build-strategie: lokaal bouwen vs in CI bouwen

De grootste bron van drift is: lokaal bouw je een image met jouw filesystem, in prod bouw je iets anders (of andersom).

Aanbevolen: images bouwen in CI en pinnen met tags

Lokaal kun je óók met die images werken, of optioneel lokaal builden met een overlay.

compose.local.yml met build:

services:
  web:
    build:
      context: .
      dockerfile: docker/web/Dockerfile
    image: jouw-app-web:local
    ports:
      - "8080:8080"
    volumes:
      - ./:/app:delegated
    environment:
      APP_ENV: local
      LOG_LEVEL: debug

  worker:
    build:
      context: .
      dockerfile: docker/worker/Dockerfile
    image: jouw-app-worker:local
    volumes:
      - ./:/app:delegated
    environment:
      APP_ENV: local
      LOG_LEVEL: debug

In productie wil je juist géén bind mounts en meestal geen build::

compose.prod.yml (geen build, wel immutability)

services:
  web:
    restart: unless-stopped
    ports: []   # expose niet direct; gebruik reverse proxy
    read_only: true
    tmpfs:
      - /tmp
    environment:
      APP_ENV: production
      LOG_LEVEL: info

  worker:
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp

  db:
    restart: unless-stopped

  redis:
    restart: unless-stopped

Tip: Zet in prod APP_TAG expliciet:

export APP_TAG=1.4.2
docker compose -f compose.yml -f compose.prod.yml pull
docker compose -f compose.yml -f compose.prod.yml up -d

Omgevingsvariabelen: .env, env_file, en expliciete defaults

.env is handig, maar kan drift veroorzaken

Compose leest standaard een .env in dezelfde directory. Als jouw .env lokale waarden bevat (bijv. DATABASE_URL=...localhost...), dan kan dat per ongeluk in prod gebruikt worden.

Aanpak:

Run lokaal met:

docker compose --env-file .env --env-file .env.local -f compose.yml -f compose.local.yml up -d --build

Run in prod met:

docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml up -d

Wees expliciet met defaults

In compose.yml deden we:

Dit voorkomt dat “lege” variabelen tot kapotte config leiden, maar let op: in productie wil je vaak juist falen als iets ontbreekt. Dan kun je ${VAR:?error message} gebruiken:

environment:
  DATABASE_URL: ${DATABASE_URL:?zet DATABASE_URL in .env.prod}

Profiles: services alleen lokaal of alleen prod

Soms wil je tooling alleen lokaal (bijv. Mailhog, Adminer, pgAdmin).

Voorbeeld: compose.local.yml:

services:
  adminer:
    image: adminer:4
    profiles: ["local-tools"]
    ports:
      - "8081:8080"
    networks:
      - appnet

Start met profile:

docker compose -f compose.yml -f compose.local.yml --profile local-tools up -d

Zonder profile draait adminer niet, dus minder drift en minder attack surface.


Volumes en data: persistentie, permissies en migraties

Lokaal: bind mounts vs named volumes

Dat verschil is oké, maar pas op:

Database persistentie

Gebruik in zowel local als prod named volumes voor DB-data:

docker volume ls
docker volume inspect jouwproject_db_data

Back-up maken (voorbeeld Postgres):

docker compose exec -T db pg_dump -U app app > backup.sql

Restore:

cat backup.sql | docker compose exec -T db psql -U app -d app

Migraties consistent uitvoeren

Voer migraties uit als een expliciete stap, niet “magisch bij start”.

Bijv. (stel web image heeft een migratiecommand):

docker compose run --rm web sh -c "python manage.py migrate"
docker compose up -d

In prod:

docker compose -f compose.yml -f compose.prod.yml run --rm web sh -c "python manage.py migrate"
docker compose -f compose.yml -f compose.prod.yml up -d

Networking: interne netwerken, poorten en reverse proxy

Expose niet alles in productie

Lokaal wil je ports voor web, admin tools, etc. In prod liever alleen via een reverse proxy.

In compose.local.yml deden we:

ports:
  - "8080:8080"

In prod zetten we ports: [] of we overschrijven het weg.

Reverse proxy in productie (Nginx)

Je kunt Nginx als service toevoegen in compose.prod.yml:

services:
  nginx:
    image: nginx:1.27-alpine
    depends_on:
      web:
        condition: service_healthy
    ports:
      - "80:80"
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - appnet
    restart: unless-stopped

Voorbeeld docker/nginx/conf.d/app.conf:

server {
  listen 80;
  server_name _;

  location / {
    proxy_pass http://web:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Test lokaal (ook nuttig):

curl -i http://localhost/

Healthchecks en startvolgorde

depends_on met condition: service_healthy voorkomt dat web start voordat db ready is. Zonder dit krijg je drift: lokaal is je laptop snel, prod heeft cold start of trage disks.

Controleer health:

docker compose ps
docker inspect --format='{{json .State.Health}}' $(docker compose ps -q db) | jq

Als je geen jq hebt:

docker inspect $(docker compose ps -q db) | grep -n "Health" -n

Secrets en gevoelige configuratie

Vermijd secrets in environment waar mogelijk

Environment variables lekken makkelijker via logs, process listings of crash dumps. Compose ondersteunt secrets, maar zonder Swarm is het in de praktijk vaak een file mount. Toch is het beter dan “hardcoded in git”.

Voorbeeld in compose.prod.yml:

services:
  web:
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

In je app lees je dan DB_PASSWORD_FILE en laad je de inhoud.

Zorg dat ./secrets niet in git staat:

echo "secrets/" >> .gitignore

Permissies op server:

chmod 600 secrets/db_password.txt

Resource-limieten en performanceverschillen

Drift ontstaat vaak door resourceverschillen: prod heeft minder CPU dan jouw laptop, of juist veel meer waardoor race conditions verdwijnen.

Compose (niet-Swarm) ondersteunt deploy.resources niet altijd zoals je verwacht. Een praktisch alternatief is:

Je kunt wel deploy opnemen voor compatibiliteit, maar verwacht niet dat het overal enforced wordt zonder Swarm:

services:
  web:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

Test gedrag onder load (voorbeeld met hey):

hey -n 2000 -c 50 http://localhost:8080/

Logging en observability

Consistente logs naar stdout/stderr

Zorg dat je app logt naar stdout. Dan:

docker compose logs -f --tail=200 web

In prod wil je vaak log rotation. Docker heeft logging drivers, maar een simpele start:

Controleer huidige logging driver:

docker info | grep -i "Logging Driver"

Je kunt per service opties zetten (werkt afhankelijk van engine/driver):

services:
  web:
    logging:
      options:
        max-size: "10m"
        max-file: "3"

Metrics/Tracing (optioneel)

Voeg tooling toe via profiles zodat het niet “per ongeluk” in prod draait, of juist alleen in prod.


Deployen naar productie zonder Swarm/Kubernetes

Compose in productie kan prima, mits je discipline hebt.

Op de server: directory en user

Zet je project in bijv. /opt/jouw-app:

sudo mkdir -p /opt/jouw-app
sudo chown -R $USER:$USER /opt/jouw-app
cd /opt/jouw-app

Zet .env.prod neer en eventueel secrets/.

Pull en up

export APP_TAG=1.4.2
docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml pull
docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml up -d
docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml ps

Zero-downtime?

Compose heeft geen ingebouwde rolling updates zoals Kubernetes. Je kunt wel:

Blue/green met projectnaam:

export APP_TAG=1.4.2
docker compose -p app_green --env-file .env.prod -f compose.yml -f compose.prod.yml up -d
# test green
curl -f http://localhost/health
# switch proxy upstream naar app_green netwerk/service (vergt ontwerp)

Voor veel teams is “korte restart” acceptabel; minimaliseer downtime door healthchecks en snelle start.


CI/CD: images bouwen, taggen en uitrollen

Een typische flow:

  1. CI bouwt images en pusht naar registry (GHCR, ECR, GCR, Docker Hub).
  2. Tag met git SHA en eventueel semver.
  3. Server pullt en herstart.

Lokaal: build en push (handmatig voorbeeld)

export TAG=$(git rev-parse --short HEAD)

docker build -f docker/web/Dockerfile -t ghcr.io/jouw-org/jouw-app-web:$TAG .
docker build -f docker/worker/Dockerfile -t ghcr.io/jouw-org/jouw-app-worker:$TAG .

docker push ghcr.io/jouw-org/jouw-app-web:$TAG
docker push ghcr.io/jouw-org/jouw-app-worker:$TAG

Server: deploy script

scripts/deploy.sh (voorbeeld):

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/opt/jouw-app"
cd "$APP_DIR"

TAG="${1:?gebruik: deploy.sh <tag>}"

export APP_TAG="$TAG"

docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml pull
docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml up -d
docker compose --env-file .env.prod -f compose.yml -f compose.prod.yml ps

Run:

bash scripts/deploy.sh 1.4.2

Checklist: zo voorkom je drift


Troubleshooting: typische “works on my machine” issues

1) “In prod kan web db niet vinden”

Check netwerk en env:

docker compose exec web sh -c 'echo $DATABASE_URL'
docker compose exec web sh -c 'getent hosts db || nslookup db || ping -c1 db'

Check DB health:

docker compose ps
docker compose logs --tail=200 db

2) “Permissieproblemen in prod maar niet lokaal”

In prod heb je read_only: true of draai je als non-root. Check:

docker compose exec web id
docker compose exec web sh -c 'touch /tmp/test && echo ok'

Als je app naar /app of /var wil schrijven, maak dat expliciet met een volume of tmpfs.

3) “Lokaal werkt hot reload niet”

Bind mounts op macOS/Windows kunnen traag zijn. Probeer:

Check of mount echt actief is:

docker compose exec web sh -c 'ls -la /app | head'

4) “Andere dependency versies”

Pin versies in je Dockerfile en lockfiles. Controleer image:

docker compose exec web sh -c 'node -v || python --version'
docker compose images

5) “Compose merge doet iets onverwachts”

Bekijk de uiteindelijke config:

docker compose -f compose.yml -f compose.prod.yml config

Dit is één van de meest waardevolle commando’s om drift te begrijpen.


Afsluiting

Docker Compose is een krachtige manier om local en production dichter bij elkaar te brengen, maar alleen als je het inzet met een base definitie en expliciete overlays, een eenduidige build/publish flow, en strakke controle over environment variables, volumes en startcondities.

Als je wilt, kan ik op basis van jouw stack (taal/framework + database + reverse proxy) een concrete set compose.yml/compose.local.yml/compose.prod.yml plus Dockerfiles voorstellen die precies passen bij jouw situatie.