← Terug naar tutorials

Docker voor lokale ontwikkeling vs productie: architectuur- en workflow-afwegingen

dockerlocal developmentproductiedevopsci/cddocker composekubernetescontainer imagessecrets managementobservability

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

  1. Mentale modellen: dev vs prod
  2. Kernprincipes: “build once, run anywhere” (met nuance)
  3. Dockerfiles: één basis, meerdere targets
  4. Compose voor development: snelle feedback loops
  5. Productie: immutable images, minimal runtime, security
  6. Volumes, bind mounts en data: wat hoort waar?
  7. Networking en service discovery
  8. Configuratie en secrets: env vars vs secret stores
  9. Health checks, readiness, en lifecycle
  10. Logging, metrics en tracing
  11. CI/CD: bouwen, testen, scannen, releasen
  12. Praktisch voorbeeld: Node.js API + Postgres + Redis
  13. Veelgemaakte fouten en anti-patterns
  14. Checklist: dev- en prod-kwaliteit

Mentale modellen: dev vs prod

Lokale ontwikkeling (dev)

In development wil je vooral:

Typische kenmerken:

Productie (prod)

In productie wil je:

Typische kenmerken:


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:

Een goede vuistregel:


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?

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:

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

docker compose -f compose.dev.yml exec api bash
docker compose -f compose.dev.yml exec db psql -U app -d app
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.

Controleer hoe je image eruitziet:

docker image ls
docker history mijnapp:prod

Scan op kwetsbaarheden (voorbeeld met Trivy):

trivy image mijnapp:prod

Reproduceerbare builds


Volumes, bind mounts en data: wat hoort waar?

Bind mounts (dev)

Bind mounts zijn top voor development, maar riskant in productie:

Gebruik bind mounts vooral voor:

Named volumes (dev en soms prod)

Named volumes zijn handig voor stateful services lokaal:

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:

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:

Belangrijk: ontwerp je app alsof netwerk onbetrouwbaar is:


Configuratie en secrets: env vars vs secret stores

Dev: .env en lokale defaults

In development is .env vaak prima (niet committen):

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:

Wat je niet wilt:

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:

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:

Ontwerp je /health endpoints slim:


Logging, metrics en tracing

Logging: schrijf naar stdout/stderr

In containers is de norm:

Node voorbeeld (conceptueel):

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:

  1. Lint & unit tests (snel)
  2. Build image (prod target)
  3. Run integration tests met Compose (db/redis)
  4. Security scan (Trivy/Grype)
  5. Push naar registry
  6. Deploy (staging → prod)
  7. Run migrations (gecontroleerd)
  8. 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:

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

Productie checklist


Afsluiting: hoe je de juiste afweging maakt

De kern is dat je Docker niet als één uniforme oplossing behandelt, maar als twee gerichte producten:

De meest duurzame aanpak is meestal:

  1. Eén Dockerfile met meerdere targets (dev, prod).
  2. Compose voor development (met bind mounts).
  3. CI bouwt altijd het prod target, 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: