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
- Wat is environment drift precies?
- Kernprincipes: “zelfde image, andere configuratie”
- Aanbevolen Compose-structuur: base + overlays
- Voorbeeldproject: web + worker + db + redis
- Build-strategie: lokaal bouwen vs in CI bouwen
- Omgevingsvariabelen:
.env,env_file, en expliciete defaults - Profiles: services alleen lokaal of alleen prod
- Volumes en data: persistentie, permissies en migraties
- Networking: interne netwerken, poorten en reverse proxy
- Healthchecks en startvolgorde
- Secrets en gevoelige configuratie
- Resource-limieten en performanceverschillen
- Logging en observability
- Deployen naar productie zonder Swarm/Kubernetes
- CI/CD: images bouwen, taggen en uitrollen
- Checklist: zo voorkom je drift
- 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:
- Andere versies van dependencies (Node/Python/OS packages)
- Andere configuratie (feature flags, caching, timeouts)
- Andere infrastructuur (reverse proxy, TLS, DNS, firewalls)
- Andere resources (CPU/RAM), waardoor race conditions of timeouts zichtbaar worden
- Andere filesystem-semantiek (bind mounts lokaal vs named volumes in prod)
- Andere gebruikers/permissies (root vs non-root)
- Andere database-instellingen (extensies, collation, timezone)
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:
- Bouw één image per service (bij voorkeur in CI) en gebruik die image zowel lokaal als in productie.
- Gebruik Compose om configuratie te variëren, niet om andere codepaden te creëren.
- 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:
- Lokaal
build:en in prodimage:met andere context of Dockerfile. - Lokaal andere database (SQLite) en prod Postgres “omdat het sneller is”.
- Lokaal permissies root, prod non-root.
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:
- Lokaal:
docker compose -f compose.yml -f compose.local.yml up -d --build
- Productie:
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:
web: HTTP API (bijv. Node/Express of Python/FastAPI)worker: background jobsdb: Postgresredis: cache/queue- optioneel
nginx: reverse proxy (voor prod)
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:
- Zelfde service-topologie in local en prod (web/worker/db/redis).
- Healthchecks maken startproblemen reproduceerbaar.
- Defaults via
${VAR:-default}zorgen dat je lokaal snel draait, maar in prod expliciet kunt zetten.
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
- CI bouwt
webenworkerimages. - Je deployed met
APP_TAG=gitshaof een release tag.
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:
- Gebruik
.envalleen voor niet-gevoelige defaults. - Gebruik
.env.local(gitignored) voor jouw persoonlijke overrides. - Gebruik
.env.prodop de server (niet in git) met echte waarden.
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:
${APP_ENV:-local}${DATABASE_URL:-postgresql://...}
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
- Bind mount (
./:/app) is fijn voor live reload. - In productie wil je meestal immutable containers zonder code mount.
Dat verschil is oké, maar pas op:
- Bestandspermissies kunnen verschillen tussen host OS en container.
- Sommige frameworks detecteren file changes anders.
- Performance op macOS/Windows kan anders zijn.
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:
- CPU/memory limieten via Docker run opties (Compose ondersteunt deels via
mem_limitin sommige setups). - In ieder geval: monitor en load test.
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:
- Reverse proxy gebruiken
- Twee stacks naast elkaar draaien (blue/green) met andere projectnaam
- Of een load balancer buiten Compose
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:
- CI bouwt images en pusht naar registry (GHCR, ECR, GCR, Docker Hub).
- Tag met git SHA en eventueel semver.
- 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
- Gebruik base + overlay compose-bestanden (
compose.yml,compose.local.yml,compose.prod.yml) - Gebruik in prod images met tags, niet “latest” en liefst geen
build: - Houd lokale verschillen beperkt tot mounts, ports, debug tooling
- Gebruik
healthcheckvoor db/redis/web - Gebruik
--env-fileexpliciet (zeker in prod) - Fail fast met
${VAR:?message}voor verplichte prod-variabelen - Gebruik profiles voor tooling
- Maak migraties een expliciete stap
- Log naar stdout en houd log rotation in de gaten
- Documenteer commando’s in
READMEen scripts
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:
- Minder files watchen
- Gebruik
delegated/cached(Docker Desktop) - Gebruik een dev server in container met polling
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.