Docker voor lokale ontwikkeling vs productie: architectuur- en workflow-afwegingen
Docker is in veel teams de standaard geworden om applicaties te bouwen, te draaien en te distribueren. Toch is “Docker gebruiken” niet één ding: de keuzes die je maakt voor lokale ontwikkeling verschillen vaak fundamenteel van wat je in productie wilt. In deze tutorial leer je hoe je die verschillen bewust ontwerpt: van image-opbouw en dependency management tot networking, secrets, observability en CI/CD.
Doel: na het lezen kun je een setup maken die ontwikkelaars snel laat itereren (hot reload, debug, testdata) én die productie robuust, veilig en reproduceerbaar houdt (immutable images, least privilege, health checks, logging).
Inhoud
- Mentale modellen: dev vs prod
- Kernprincipes: “build once, run anywhere” (met nuance)
- Dockerfiles: één basis, meerdere targets
- Compose voor development: snelle feedback loops
- Productie: immutable images, minimal runtime, security
- Volumes, bind mounts en data: wat hoort waar?
- Networking en service discovery
- Configuratie en secrets: env vars vs secret stores
- Health checks, readiness, en lifecycle
- Logging, metrics en tracing
- CI/CD: bouwen, testen, scannen, releasen
- Praktisch voorbeeld: Node.js API + Postgres + Redis
- Veelgemaakte fouten en anti-patterns
- Checklist: dev- en prod-kwaliteit
Mentale modellen: dev vs prod
Lokale ontwikkeling (dev)
In development wil je vooral:
- Snel itereren: code aanpassen en direct effect zien (hot reload).
- Debugbaarheid: stacktraces, extra logging, debug ports.
- Flexibele dependencies: makkelijk een database resetten, testdata laden.
- Gemak boven perfectie: iets meer permissies of tooling in de container is acceptabel.
Typische kenmerken:
- Bind mounts (je broncode wordt “live” gemount in de container).
- Dev dependencies aanwezig (linters, test runners).
- Containers starten met
npm run dev,python -m flask run, etc. - Meer open poorten, soms root-user, extra tools (curl, bash).
Productie (prod)
In productie wil je:
- Reproduceerbaarheid: exact dezelfde bits draaien als getest.
- Security: minimale attack surface, non-root, geen secrets in images.
- Stabiliteit: health checks, retries, resource limits, graceful shutdown.
- Observability: logs, metrics, tracing met consistente correlatie.
Typische kenmerken:
- Immutable images (geen bind mounts).
- Multi-stage builds: alleen runtime artifacts in het eindimage.
- Strakke configuratie: secrets via secret store, env vars, config maps.
- Least privilege: non-root, read-only filesystem waar mogelijk.
Kernprincipes: “build once, run anywhere” (met nuance)
Het ideaal is: één image bouwen en dat image door de pipeline schuiven (test → staging → prod). In de praktijk is er nuance:
- Je kunt één Dockerfile hebben met meerdere targets (
--target deven--target prod). - Of je bouwt twee images: een dev-image met tooling en een prod-image minimalistisch.
- Belangrijk: het prod-image moet overeenkomen met wat je in staging test. Dev mag afwijken.
Een goede vuistregel:
- Dev optimaliseer je voor developer experience.
- Prod optimaliseer je voor operational excellence.
Dockerfiles: één basis, meerdere targets
Multi-stage builds zijn de sleutel om dev en prod netjes te scheiden zonder duplicatie.
Voorbeeld: Node.js multi-stage Dockerfile
Maak een Dockerfile:
# syntax=docker/dockerfile:1.6
FROM node:20-bookworm-slim AS base
WORKDIR /app
ENV NODE_ENV=production
# Alleen package manifests eerst kopiëren voor betere cache
COPY package.json package-lock.json ./
FROM base AS deps
# Installeer productie dependencies (later hergebruiken)
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM base AS dev
ENV NODE_ENV=development
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
FROM base AS build
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
# Bouw stap (bijv. TypeScript/Next/Vite)
RUN npm run build
FROM node:20-bookworm-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
# Security: maak een non-root user
RUN useradd -r -u 10001 appuser
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Wat gebeurt hier?
devtarget: bevat dev tooling, installeert dev dependencies, kopieert broncode, draaitnpm run dev.prodtarget: bevat enkel runtime essentials (node + node_modules + build output), draait als non-root.
Bouwen:
# Dev image
docker build -t mijnapp:dev --target dev .
# Prod image
docker build -t mijnapp:prod --target prod .
Compose voor development: snelle feedback loops
Docker Compose is ideaal voor lokale stacks: API + database + cache + message broker.
Compose met bind mounts en hot reload
compose.dev.yml:
services:
api:
build:
context: .
target: dev
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://redis:6379
LOG_LEVEL: debug
volumes:
- ./:/app
- /app/node_modules
depends_on:
- db
- redis
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7
ports:
- "6379:6379"
volumes:
pgdata:
Starten:
docker compose -f compose.dev.yml up --build
Belangrijke keuzes:
volumes: - ./:/appbind mount: code wijzigt direct in container.- /app/node_modules“anonymous volume” voorkomt dat je lokalenode_modules(host) de container overschrijft.depends_onis handig, maar geen readiness-garantie. Daarover later meer.
Stoppen en opruimen:
docker compose -f compose.dev.yml down
docker compose -f compose.dev.yml down -v # ook volumes weg (reset db)
Dev workflow tips
- Gebruik
docker compose execom in containers te werken:
docker compose -f compose.dev.yml exec api bash
docker compose -f compose.dev.yml exec db psql -U app -d app
- Log volgen:
docker compose -f compose.dev.yml logs -f api
Productie: immutable images, minimal runtime, security
In productie wil je meestal geen Compose op een enkele machine (tenzij heel klein) maar een orchestrator (Kubernetes, ECS, Nomad). Toch blijven de principes gelijk.
Image hardening
Doel: zo klein en veilig mogelijk.
- Multi-stage build: geen build tools in runtime image.
- Non-root user (
USER appuser). - Minimal base image (slim varianten).
- Geen secrets in image layers.
- Optioneel: read-only filesystem en drop capabilities (in orchestrator).
Controleer hoe je image eruitziet:
docker image ls
docker history mijnapp:prod
Scan op kwetsbaarheden (voorbeeld met Trivy):
trivy image mijnapp:prod
Reproduceerbare builds
- Gebruik
npm cii.p.v.npm install. - Pin base images (bijv.
node:20.11.1-bookworm-slimi.p.v.node:20), afhankelijk van je updatebeleid. - Zet build args en labels consistent.
Volumes, bind mounts en data: wat hoort waar?
Bind mounts (dev)
Bind mounts zijn top voor development, maar riskant in productie:
- Ze maken je runtime afhankelijk van de host filesystem state.
- Ze kunnen permissieproblemen geven.
- Ze doorbreken “immutable infrastructure”.
Gebruik bind mounts vooral voor:
- Broncode
- Lokale config (niet secrets)
- Snelle iteratie
Named volumes (dev en soms prod)
Named volumes zijn handig voor stateful services lokaal:
- Postgres data (
pgdata) - Redis persistence (optioneel)
In productie wordt state vaak beheerd via managed services (RDS, Cloud SQL) of via StatefulSets met persistent volumes.
Data migraties
In dev kun je migrations automatisch draaien bij start. In prod wil je vaak een expliciete stap (job) in je deployment pipeline.
Voorbeeld (Node + Prisma/Knex/TypeORM-achtig):
# Dev: bij opstarten
npm run migrate:dev
# Prod: apart in CI/CD of als job
npm run migrate:deploy
Networking en service discovery
Compose networking
Compose maakt standaard een netwerk aan; services zijn bereikbaar via servicenaam:
db:5432redis:6379
Daarom werkt postgres://app:app@db:5432/app in Compose.
Test connectivity:
docker compose -f compose.dev.yml exec api curl -sS http://api:3000/health || true
docker compose -f compose.dev.yml exec api bash -lc "nc -zv db 5432"
Productie networking
In productie heb je meestal:
- Een interne service mesh / cluster DNS (bijv.
api.default.svc.cluster.local) - Ingress / load balancer voor extern verkeer
- NetworkPolicies / security groups
Belangrijk: ontwerp je app alsof netwerk onbetrouwbaar is:
- Timeouts
- Retries met backoff
- Circuit breakers waar nodig
Configuratie en secrets: env vars vs secret stores
Dev: .env en lokale defaults
In development is .env vaak prima (niet committen):
.envin.gitignoredocker compose --env-file .env ...
Voorbeeld .env:
DATABASE_URL=postgres://app:app@db:5432/app
REDIS_URL=redis://redis:6379
JWT_SECRET=dev-secret
Prod: secrets horen niet in images of git
Opties:
- Docker secrets (Swarm)
- Kubernetes Secrets (liefst met encryptie en RBAC)
- Cloud secret managers (AWS Secrets Manager, GCP Secret Manager, Vault)
Wat je niet wilt:
ENV JWT_SECRET=...in Dockerfile- Secrets in
compose.ymldie in git staan - Secrets in build args (komen vaak in image history terecht)
Controleer of je per ongeluk secrets in layers hebt:
docker history --no-trunc mijnapp:prod
Health checks, readiness, en lifecycle
Waarom “container draait” niet genoeg is
Een container kan “up” zijn, maar je app kan:
- nog migrations draaien
- nog geen DB connectie hebben
- in een crash loop zitten met snelle restarts
Docker HEALTHCHECK
Je kunt in je Dockerfile een healthcheck zetten (vooral nuttig op single-host setups):
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
Of in Compose:
services:
api:
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 5
Readiness vs liveness (prod)
In Kubernetes onderscheid je vaak:
- Liveness: moet de container herstarten?
- Readiness: mag verkeer naar deze pod?
Ontwerp je /health endpoints slim:
/live: proces leeft/ready: dependencies OK (DB, cache)
Logging, metrics en tracing
Logging: schrijf naar stdout/stderr
In containers is de norm:
- Geen logfiles in de container (tenzij sidecar/agent)
- JSON logs in productie (makkelijk te parsen)
- Correlation IDs (request-id) voor tracing
Node voorbeeld (conceptueel):
- Dev: pretty logs
- Prod: JSON logs
Bekijk logs:
docker logs -f <container_id>
docker compose -f compose.dev.yml logs -f api
Metrics
In prod wil je metrics (Prometheus, OpenTelemetry). In dev kan dit optioneel zijn, maar het helpt om performance issues vroeg te zien.
Distributed tracing
OpenTelemetry is de standaard. Belangrijk is dat je contextpropagatie consistent is (traceparent headers).
CI/CD: bouwen, testen, scannen, releasen
Een typische pipeline:
- Lint & unit tests (snel)
- Build image (prod target)
- Run integration tests met Compose (db/redis)
- Security scan (Trivy/Grype)
- Push naar registry
- Deploy (staging → prod)
- Run migrations (gecontroleerd)
- Smoke tests
Praktische commando’s
Build met BuildKit:
DOCKER_BUILDKIT=1 docker build -t registry.example.com/mijnapp:$(git rev-parse --short HEAD) --target prod .
Push:
docker push registry.example.com/mijnapp:$(git rev-parse --short HEAD)
Integration tests met Compose (CI):
docker compose -f compose.dev.yml up -d --build db redis
docker compose -f compose.dev.yml run --rm api npm test
docker compose -f compose.dev.yml down -v
Let op: in CI wil je vaak een aparte compose.ci.yml zonder bind mounts.
Praktisch voorbeeld: Node.js API + Postgres + Redis
Hier bouwen we een mini-architectuur met duidelijke dev/prod scheiding.
1) Projectstructuur
.
├─ Dockerfile
├─ package.json
├─ package-lock.json
├─ src/
│ ├─ server.js
│ └─ health.js
├─ compose.dev.yml
└─ .dockerignore
2) .dockerignore
Zorg dat je build context klein blijft:
node_modules
npm-debug.log
Dockerfile*
compose*.yml
.git
.gitignore
.env
dist
(Als je dist in build output gebruikt, pas dit aan: in ons Dockerfile bouwen we dist in de container, dus lokaal dist negeren is prima.)
3) Simpele server (voorbeeld)
src/server.js (schets):
import http from "node:http";
import { healthHandler } from "./health.js";
const port = process.env.PORT || 3000;
const server = http.createServer(async (req, res) => {
if (req.url === "/health") return healthHandler(req, res);
res.writeHead(200, { "content-type": "text/plain" });
res.end("OK\n");
});
server.listen(port, () => console.log(`Listening on :${port}`));
src/health.js:
export async function healthHandler(_req, res) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, ts: Date.now() }));
}
4) Dev draaien met Compose
docker compose -f compose.dev.yml up --build
Test:
curl -fsS http://localhost:3000/health && echo
5) Prod image bouwen en runnen (zonder Compose)
docker build -t mijnapp:prod --target prod .
docker run --rm -p 3000:3000 --name mijnapp mijnapp:prod
Test:
curl -fsS http://localhost:3000/health && echo
Veelgemaakte fouten en anti-patterns
1) “Dev container” deployen naar productie
Dev images bevatten vaak:
- extra packages (curl, bash, build tools)
- dev dependencies
- debug flags
- permissieve defaults
Oplossing: gebruik --target prod in CI en deploy alleen dat artifact.
2) Secrets in Dockerfile of git
Alles wat in een image layer komt, is vaak terug te halen.
Oplossing: injecteer secrets pas bij runtime via secret store.
3) latest overal gebruiken
latest maakt deployments onvoorspelbaar.
Oplossing: tag met commit SHA en (optioneel) semver:
docker tag mijnapp:prod registry.example.com/mijnapp:1.4.2
docker tag mijnapp:prod registry.example.com/mijnapp:git-abc1234
4) Geen resource limits
Zonder limits kan één container een node slopen.
Oplossing: stel CPU/memory limits in je orchestrator. Voor lokale Compose kun je (beperkt) ook limieten zetten, maar prod is belangrijker.
5) Geen health/readiness checks
Je load balancer stuurt verkeer naar een app die nog niet klaar is.
Oplossing: implementeer /ready en configureer checks.
Checklist: dev- en prod-kwaliteit
Development checklist
- Bind mount voor source code werkt (hot reload)
- Dev dependencies aanwezig (test/lint)
-
docker compose down -vreset state makkelijk - Debugging mogelijk (bijv. Node inspect port indien nodig)
- Snelle rebuild door caching (
COPY package*.jsoneerst)
Productie checklist
- Multi-stage build, runtime image minimal
- Non-root user
- Geen secrets in image of repo
- Health/readiness endpoints
- Logs naar stdout/stderr (liefst gestructureerd)
- Image scanning en SBOM (aanbevolen)
- Reproduceerbare builds (
npm ci, pinned dependencies) - Tagging strategie (SHA/semver), geen blind
latest - Resource limits en security policies in orchestrator
Afsluiting: hoe je de juiste afweging maakt
De kern is dat je Docker niet als één uniforme oplossing behandelt, maar als twee gerichte producten:
- Dev-omgeving: maximaliseer snelheid, feedback en gemak.
- Prod-omgeving: maximaliseer veiligheid, stabiliteit en reproduceerbaarheid.
De meest duurzame aanpak is meestal:
- Eén Dockerfile met meerdere targets (
dev,prod). - Compose voor development (met bind mounts).
- CI bouwt altijd het
prodtarget, test dat, scant dat, en deployt dat.
Als je wilt, kan ik op basis van jouw stack (bijv. Python/FastAPI, Java/Spring, Go, PHP/Laravel) een complete set leveren met:
- een dev/prod Dockerfile,
compose.dev.yml+compose.ci.yml,- health/readiness endpoints,
- en een CI pipeline (GitHub Actions/GitLab CI) met caching en scanning.