Docker voor Node.js: De Productie-Klare Setup Waar Niemand Over Praat
Multi-stage builds, non-root users, health checks, secrets management en image-optimalisatie. De Docker-patronen die ik gebruik voor elke Node.js-productiedeployment.
De meeste Node.js Dockerfiles in productie zijn slecht. Niet "een beetje suboptimaal" slecht. Ik bedoel draaien als root, 600MB images shippen met devDependencies erin gebakken, geen health checks, en secrets hardcoded in environment variables die iedereen met docker inspect kan lezen.
Ik weet het omdat ik die Dockerfiles schreef. Jarenlang. Ze werkten, dus ik stelde ze nooit ter discussie. Toen markeerde een security audit op een dag dat onze container als PID 1 root draaide met schrijftoegang tot het hele bestandssysteem, en besefte ik dat "werkt" en "productie-klaar" heel verschillende drempels zijn.
Dit is de Docker-setup die ik nu gebruik voor elk Node.js-project. Het is niet theoretisch. Het draait de services achter deze site en meerdere andere die ik onderhoud. Elk patroon hier bestaat omdat ik me ofwel gebrand heb aan het alternatief, of iemand anders heb zien branden.
Waarom Je Huidige Dockerfile Waarschijnlijk Fout Is#
Laat me raden hoe je Dockerfile eruitziet:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Dit is de "hello world" van Dockerfiles. Het werkt. Het heeft ook minstens vijf problemen die je pijn doen in productie.
Draaien als Root#
Standaard draait het node Docker-image als root. Dat betekent dat je applicatieproces root-rechten heeft binnen de container. Als iemand een kwetsbaarheid in je app exploiteert — een path traversal-bug, een SSRF, een dependency met een backdoor — hebben ze root-toegang tot het container-bestandssysteem, kunnen ze binaries aanpassen, packages installeren, en mogelijk verder escaleren afhankelijk van je container runtime-configuratie.
"Maar containers zijn geisoleerd!" Gedeeltelijk. Container-escapes zijn echt. CVE-2024-21626, CVE-2019-5736 — dit zijn echte container-uitbraken. Draaien als non-root is een defense-in-depth maatregel. Het kost niets en sluit een hele klasse aanvallen.
devDependencies Installeren in Productie#
npm install zonder flags installeert alles. Je test runners, linters, build tools, type checkers — allemaal in je productie-image. Dit blaast je image op met honderden megabytes en vergroot je aanvalsoppervlak. Elk extra pakket is weer een potentiele kwetsbaarheid die Trivy of Snyk zal markeren.
Alles Kopieren#
COPY . . kopieert je hele projectmap naar het image. Dat omvat .git (die enorm kan zijn), .env-bestanden (die secrets bevatten), node_modules (die je toch opnieuw gaat installeren), testbestanden, documentatie, CI-configs — alles.
Geen Health Checks#
Zonder een HEALTHCHECK-instructie heeft Docker geen idee of je applicatie daadwerkelijk verkeer verwerkt. Het proces kan draaien maar in een deadlock zitten, out of memory zijn, of vast in een oneindige lus. Docker meldt de container als "running" omdat het proces niet is gestopt. Je load balancer blijft verkeer sturen naar een zombie-container.
Geen Layer Caching-Strategie#
Alles kopieren voordat je dependencies installeert betekent dat het wijzigen van een enkele regel broncode de npm install-cache invalideert. Elke build installeert alle dependencies opnieuw. Op een project met zware dependencies is dat 2-3 minuten verspilde tijd per build.
Laten we dit allemaal oplossen.
Multi-Stage Builds: De Grootste Winst#
Multi-stage builds zijn de meest impactvolle verandering die je aan een Node.js Dockerfile kunt maken. Het concept is simpel: gebruik een stage om je applicatie te bouwen, kopieer vervolgens alleen de artefacten die je nodig hebt naar een schoon, minimaal eindimage.
Hier is het verschil in de praktijk:
# Single stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]De builder-stage heeft alles: volledige Node.js, npm, build tools, broncode, devDependencies. De runner-stage heeft alleen wat nodig is tijdens runtime. De builder-stage wordt volledig weggegooid — die eindigt niet in het uiteindelijke image.
Echte Groottevergelijkingen#
Ik heb deze gemeten op een echt Express.js API-project met ongeveer 40 dependencies:
| Aanpak | Image-grootte |
|---|---|
node:20 + npm install | 1,1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-stage + alpine + alleen productie-deps | 150 MB |
| Multi-stage + alpine + opgeschoonde deps | 95 MB |
Dat is een 10x-reductie ten opzichte van de naive aanpak. Kleinere images betekenen snellere pulls, snellere deployments en minder aanvalsoppervlak.
Waarom Alpine?#
Alpine Linux gebruikt musl libc in plaats van glibc, en bevat geen package manager-cache, documentatie of de meeste utilities die je in een standaard Linux-distributie zou vinden. Het basis node:20-alpine image is ongeveer 50MB vergeleken met 350MB voor node:20-slim en meer dan 1GB voor het volledige node:20.
De afweging is dat sommige npm-pakketten met native bindings (zoals bcrypt, sharp, canvas) gecompileerd moeten worden tegen musl. In de meeste gevallen werkt dit gewoon — npm downloadt de juiste prebuilt binary. Als je problemen tegenkomt, kun je build-dependencies installeren in de builder-stage:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... rest van de buildDeze build tools bestaan alleen in de builder-stage. Ze zitten niet in je eindimage.
De Complete Productie-Dockerfile#
Hier is de Dockerfile die ik als startpunt gebruik voor elk Node.js-project. Elke regel is doelbewust.
# ============================================
# Stage 1: Dependencies installeren
# ============================================
FROM node:20-alpine AS deps
# Beveiliging: maak een werkdirectory aan voordat je iets anders doet
WORKDIR /app
# Installeer dependencies op basis van het lockfile
# Kopieer ALLEEN package-bestanden eerst — dit is cruciaal voor layer caching
COPY package.json package-lock.json ./
# ci is beter dan install: het is sneller, strikter en reproduceerbaar
# --omit=dev sluit devDependencies uit van deze stage
RUN npm ci --omit=dev
# ============================================
# Stage 2: De applicatie bouwen
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Kopieer package-bestanden en installeer ALLE dependencies (inclusief dev)
COPY package.json package-lock.json ./
RUN npm ci
# NU broncode kopieren — wijzigingen hier invalideren de npm ci-cache niet
COPY . .
# Bouw de applicatie (TypeScript-compilatie, Next.js build, etc.)
RUN npm run build
# ============================================
# Stage 3: Productie-runner
# ============================================
FROM node:20-alpine AS runner
# Voeg labels toe voor image-metadata
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# Beveiliging: installeer dumb-init voor correcte PID 1-signaalafhandeling
RUN apk add --no-cache dumb-init
# Beveiliging: stel NODE_ENV in voordat je iets anders doet
ENV NODE_ENV=production
# Beveiliging: gebruik non-root user
# Het node-image bevat al een 'node'-gebruiker (uid 1000)
USER node
# Maak app-directory aan eigendom van node-gebruiker
WORKDIR /app
# Kopieer productie-dependencies vanuit de deps-stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Kopieer gebouwde applicatie vanuit de builder-stage
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
# Stel de poort bloot (alleen documentatie — publiceert het niet)
EXPOSE 3000
# Health check: curl is niet beschikbaar in alpine, gebruik node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
# Gebruik dumb-init als PID 1 om signalen correct af te handelen
ENTRYPOINT ["dumb-init", "--"]
# Start de applicatie
CMD ["node", "dist/server.js"]Laat me de delen uitleggen die niet vanzelfsprekend zijn.
Waarom Drie Stages in Plaats van Twee?#
De deps-stage installeert alleen productie-dependencies. De builder-stage installeert alles (inclusief devDependencies) en bouwt de app. De runner-stage kopieert productie-deps van deps en gebouwde code van builder.
Waarom niet productie-deps installeren in de builder-stage? Omdat de builder-stage devDependencies door elkaar heeft staan. Je zou npm prune --production moeten draaien na de build, wat langzamer en minder betrouwbaar is dan een schone productie-dependency-installatie.
Waarom dumb-init?#
Wanneer je node server.js in een container draait, wordt Node.js PID 1. PID 1 heeft speciaal gedrag in Linux: het ontvangt geen standaard signaalhandlers. Als je SIGTERM naar de container stuurt (wat docker stop doet), zal Node.js als PID 1 het standaard niet afhandelen. Docker wacht 10 seconden, stuurt dan SIGKILL, wat het proces onmiddellijk stopt zonder enige opruiming — geen graceful shutdown, geen sluiten van databaseverbindingen, geen afmaken van lopende verzoeken.
dumb-init fungeert als PID 1 en stuurt signalen correct door naar je applicatie. Je Node.js-proces ontvangt SIGTERM zoals verwacht en kan graceful afsluiten:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// Sluit databaseverbindingen, flush logs, etc.
process.exit(0);
});
});Een alternatief is de --init flag in docker run, maar het inbakken in het image betekent dat het werkt ongeacht hoe de container wordt gestart.
Het .dockerignore-Bestand#
Dit is net zo belangrijk als het Dockerfile zelf. Zonder dit stuurt COPY . . alles naar de Docker daemon:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
Elk bestand in .dockerignore is een bestand dat niet naar de build context wordt gestuurd, niet in je image terechtkomt, en je layer cache niet invalideert wanneer het verandert.
Layer Caching: Stop Met 3 Minuten Wachten Per Build#
Docker bouwt images in lagen. Elke instructie creert een laag. Als een laag niet is veranderd, gebruikt Docker de gecachte versie. Maar hier is het cruciale detail: als een laag verandert, worden alle volgende lagen geinvalideerd.
Daarom is de volgorde van instructies enorm belangrijk.
De Verkeerde Volgorde#
COPY . .
RUN npm ciElke keer dat je een bestand wijzigt — een enkele regel in een enkel bronbestand — ziet Docker dat de COPY . .-laag is veranderd. Het invalideert die laag en alles erna, inclusief npm ci. Je herinstalleert alle dependencies bij elke codewijziging.
De Juiste Volgorde#
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Nu draait npm ci alleen wanneer package.json of package-lock.json verandert. Als je alleen broncode hebt gewijzigd, hergebruikt Docker de gecachte npm ci-laag. Op een project met 500+ dependencies bespaart dit 60-120 seconden per build.
Cache Mount voor npm#
Docker BuildKit ondersteunt cache mounts die de npm-cache bewaren tussen builds:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devDit bewaart de npm download-cache tussen builds. Als een dependency al gedownload was in een vorige build, gebruikt npm de gecachte versie in plaats van opnieuw te downloaden. Dit is vooral nuttig in CI waar je vaak bouwt.
Om BuildKit te gebruiken, stel de environment variable in:
DOCKER_BUILDKIT=1 docker build -t myapp .Of voeg toe aan je Docker daemon-configuratie:
{
"features": {
"buildkit": true
}
}ARG Gebruiken voor Cache Busting#
Soms moet je een laag forceren om opnieuw te bouwen. Bijvoorbeeld als je een latest tag van een registry pullt en wilt verzekeren dat je de nieuwste versie krijgt:
ARG CACHE_BUST=1
RUN npm ciBouw met een unieke waarde om de cache te busten:
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .Gebruik dit spaarzaam. Het hele punt van caching is snelheid — bust de cache alleen wanneer je een reden hebt.
Secrets Management: Stop Met Secrets in Je Dockerfile Zetten#
Dit is een van de meest voorkomende en gevaarlijke fouten. Ik zie het constant:
# DOE DIT NOOIT
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Environment variables ingesteld met ENV in een Dockerfile worden in het image gebakken. Iedereen die het image pullt kan ze zien met docker inspect of docker history. Ze zijn ook zichtbaar in elke laag nadat ze zijn ingesteld. Zelfs als je ze later unset, bestaan ze in de laaggeschiedenis.
De Drie Niveaus van Secrets#
1. Build-time secrets (Docker BuildKit)
Als je secrets nodig hebt tijdens de build (zoals een private npm registry-token), gebruik BuildKit's --secret flag:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Mount het secret tijdens build — het wordt nooit opgeslagen in het image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY . .
RUN npm run buildBouw met:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .Het .npmrc-bestand is beschikbaar tijdens het RUN-commando maar wordt nooit gecommit naar een image-laag. Het verschijnt niet in docker history of docker inspect.
2. Runtime secrets via environment variables
Voor secrets die je applicatie nodig heeft tijdens runtime, geef ze mee bij het starten van de container:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myappOf met een env-bestand:
docker run -d --env-file .env.production myappDeze zijn zichtbaar via docker inspect op de draaiende container, maar ze zijn niet in het image gebakken. Iedereen die het image pullt krijgt de secrets niet.
3. Docker secrets (Swarm / Kubernetes)
Voor serieus secret management in georkestreerde omgevingen:
# docker-compose.yml (Swarm mode)
version: "3.8"
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueDocker mount secrets als bestanden op /run/secrets/<secret_name>. Je applicatie leest ze van het bestandssysteem:
import { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
// Fallback naar environment variable voor lokale ontwikkeling
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret("db_password");Dit is de veiligste aanpak omdat secrets nooit verschijnen in environment variables, proceslijsten of container-inspectie-output.
.env-Bestanden en Docker#
Neem nooit .env-bestanden op in je Docker-image. Je .dockerignore moet ze uitsluiten (daarom hebben we .env en .env.* eerder opgenomen). Voor lokale ontwikkeling met docker-compose, mount ze tijdens runtime:
services:
api:
env_file:
- .env.localHealth Checks: Laat Docker Weten Dat Je App Echt Werkt#
Een health check vertelt Docker of je applicatie correct functioneert. Zonder health check weet Docker alleen of het proces draait — niet of het daadwerkelijk verzoeken kan afhandelen.
De HEALTHCHECK-Instructie#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"Laat me de parameters uitleggen:
--interval=30s: Controleer elke 30 seconden--timeout=10s: Als de controle langer dan 10 seconden duurt, beschouw het als mislukt--start-period=40s: Geef de app 40 seconden om op te starten voordat fouten worden geteld--retries=3: Markeer als unhealthy na 3 opeenvolgende mislukkingen
Waarom Geen curl?#
Alpine bevat standaard geen curl. Je zou het kunnen installeren (apk add --no-cache curl), maar dat voegt weer een binary toe aan je minimale image. Node.js direct gebruiken betekent nul extra dependencies.
Voor nog lichtere health checks kun je een apart script gebruiken:
// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: 3000,
path: "/health",
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]Het Health Endpoint#
Je applicatie heeft een /health-endpoint nodig dat de check kan aanspreken. Geef niet zomaar 200 terug — controleer daadwerkelijk of je app gezond is:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
// Controleer databaseverbinding
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
// Controleer Redis-verbinding
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});Een "degraded" status met een 503 vertelt de orchestrator om verkeer niet meer naar deze instantie te routeren terwijl het herstelt, maar triggert niet per se een herstart.
Waarom Health Checks Belangrijk Zijn voor Orchestrators#
Docker Swarm, Kubernetes en zelfs gewone docker-compose met restart: always gebruiken health checks om beslissingen te nemen:
- Load balancers stoppen met verkeer sturen naar unhealthy containers
- Rolling updates wachten tot de nieuwe container gezond is voordat de oude wordt gestopt
- Orchestrators kunnen containers herstarten die unhealthy worden
- Deployment-pipelines kunnen verifieren of een deployment is geslaagd
Zonder health checks kan een rolling deployment de oude container stoppen voordat de nieuwe klaar is, wat downtime veroorzaakt.
docker-compose voor Ontwikkeling#
Je ontwikkelomgeving moet zo dicht mogelijk bij productie zijn, maar met het gemak van hot reload, debuggers en directe feedback. Hier is de docker-compose-setup die ik gebruik voor ontwikkeling:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_VERSION: "20"
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger
volumes:
# Mount broncode voor hot reload
- .:/app
# Anoniem volume om node_modules van het image te behouden
# Dit voorkomt dat de node_modules van de host die van de container overschrijft
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
# Named volume voor persistente data tussen container-herstarts
- pgdata:/var/lib/postgresql/data
# Initialisatiescripts
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
# Optioneel: database admin-UI
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
redisdata:Belangrijke Ontwikkelpatronen#
Volume mounts voor hot reload: De .:/app volume mount mapt je lokale broncode naar de container. Wanneer je een bestand opslaat, is de wijziging onmiddellijk zichtbaar in de container. Gecombineerd met een dev-server die op wijzigingen let (zoals nodemon of tsx --watch), krijg je directe feedback.
De node_modules-truc: Het anonieme volume - /app/node_modules zorgt ervoor dat de container zijn eigen node_modules gebruikt (geinstalleerd tijdens de image-build) in plaats van de node_modules van je host. Dit is cruciaal omdat native modules gecompileerd op macOS niet werken in een Linux-container.
Service-afhankelijkheden: depends_on met condition: service_healthy zorgt ervoor dat de database echt klaar is voordat je app probeert te verbinden. Zonder de health check-conditie wacht depends_on alleen tot de container start — niet tot de service erin klaar is.
Named volumes: pgdata en redisdata blijven bewaard tussen container-herstarts. Zonder named volumes zou je je database kwijtraken elke keer dat je docker-compose down draait.
De Ontwikkel-Dockerfile#
Je ontwikkel-Dockerfile is simpeler dan productie:
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
# Installeer alle dependencies (inclusief devDependencies)
COPY package*.json ./
RUN npm ci
# Broncode wordt via volume gemount, niet gekopieerd
# Maar we hebben het nog steeds nodig voor de eerste build
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]Geen multi-stage build, geen productie-optimalisatie. Het doel is snelle iteratie, niet kleine images.
Productie Docker Compose#
Productie docker-compose is een ander beest. Hier is wat ik gebruik:
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
pgdata:
driver: local
redisdata:
driver: local
networks:
internal:
driver: bridge
web:
external: trueWat Anders Is Dan Ontwikkeling#
Restart policy: unless-stopped herstart de container automatisch als hij crasht, tenzij je hem expliciet hebt gestopt. Dit handelt het "3 uur 's nachts crash"-scenario af. Het alternatief always zou ook containers herstarten die je bewust hebt gestopt, wat meestal niet is wat je wilt.
Resource limits: Zonder limieten zal een memory leak in je Node.js-app al het beschikbare RAM op de host verbruiken, wat mogelijk andere containers of de host zelf doodt. Stel limieten in op basis van het werkelijke gebruik van je applicatie plus wat extra ruimte:
# Monitor werkelijk gebruik om passende limieten in te stellen
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"Logconfiguratie: Zonder max-size en max-file groeien Docker-logs onbeperkt. Ik heb productieservers zien vollopen met schijfruimte door Docker-logs. json-file met rotatie is de simpelste oplossing. Voor gecentraliseerde logging, wissel naar de fluentd of gelf driver:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "myapp.{{.Name}}"Netwerkisolatie: Het internal netwerk is alleen toegankelijk voor services in deze compose-stack. De database en Redis zijn niet blootgesteld aan de host of andere containers. Alleen de app-service is verbonden met het web-netwerk, dat je reverse proxy (Nginx, Traefik) gebruikt om verkeer te routeren.
Geen port mapping voor databases: Merk op dat db en redis geen ports hebben in de productieconfiguratie. Ze zijn alleen bereikbaar via het interne Docker-netwerk. In ontwikkeling stellen we ze bloot zodat we lokale tools kunnen gebruiken (pgAdmin, Redis Insight). In productie is er geen reden om ze bereikbaar te maken van buiten het Docker-netwerk.
Next.js Specifiek: De Standalone Output#
Next.js heeft een ingebouwde Docker-optimalisatie die veel mensen niet kennen: standalone output mode. Het traceert de imports van je applicatie en kopieert alleen de bestanden die nodig zijn om te draaien — geen node_modules vereist (dependencies worden gebundeld).
Schakel het in in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;Dit verandert de build-output drastisch. In plaats van de hele node_modules-map nodig te hebben, produceert Next.js een zelfstandige server.js in .next/standalone/ die alleen de dependencies bevat die het daadwerkelijk gebruikt.
De Next.js Productie-Dockerfile#
Dit is de Dockerfile die ik gebruik voor Next.js-projecten, gebaseerd op het officiele Vercel-voorbeeld maar met security hardening:
# ============================================
# Stage 1: Dependencies installeren
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ============================================
# Stage 2: De applicatie bouwen
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Schakel Next.js-telemetrie uit tijdens build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ============================================
# Stage 3: Productie-runner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non-root gebruiker
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Kopieer publieke assets
COPY --from=builder /app/public ./public
# Stel de standalone output-directory in
# Maakt automatisch gebruik van output traces om de image-grootte te verkleinen
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Kopieer de standalone server en statische bestanden
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Groottevergelijking voor Next.js#
| Aanpak | Image-grootte |
|---|---|
node:20 + volledige node_modules + .next | 1,4 GB |
node:20-alpine + volledige node_modules + .next | 600 MB |
node:20-alpine + standalone output | 120 MB |
De standalone output is transformatief. Een 1,4 GB image wordt 120 MB. Deploys die 90 seconden kostten om te pullen duren nu 10 seconden.
Statische Bestandsafhandeling#
Next.js standalone mode bevat niet de public-map of de statische assets van .next/static. Je moet ze expliciet kopieren (wat we doen in de Dockerfile hierboven). In productie wil je hier meestal een CDN voor:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
assetPrefix: process.env.CDN_URL || undefined,
};Als je geen CDN gebruikt, serveert Next.js statische bestanden direct. De standalone server handelt dit prima af — je moet er alleen voor zorgen dat de bestanden op de juiste plek staan (wat onze Dockerfile garandeert).
Sharp voor Image-Optimalisatie#
Next.js gebruikt sharp voor image-optimalisatie. In het Alpine-gebaseerde productie-image moet je ervoor zorgen dat de juiste binary beschikbaar is:
# In de runner-stage, voordat je naar de non-root gebruiker wisselt
RUN apk add --no-cache --virtual .sharp-deps vips-devOf beter, installeer het als productie-dependency en laat npm de platform-specifieke binary afhandelen:
npm install sharpHet node:20-alpine image werkt met sharp's prebuilt linux-x64-musl binary. In de meeste gevallen is geen speciale configuratie nodig.
Image Scanning en Beveiliging#
Een klein image bouwen met een non-root gebruiker is een goed begin, maar het is niet genoeg voor serieuze productie-workloads. Hier is hoe je verder gaat.
Trivy: Scan Je Images#
Trivy is een uitgebreide kwetsbaarheidsscanner voor container-images. Draai het in je CI-pipeline:
# Installeer trivy
brew install aquasecurity/trivy/trivy # macOS
# of
apt-get install trivy # Debian/Ubuntu
# Scan je image
trivy image myapp:latestVoorbeeldoutput:
myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver │ CVE-2022-25883 │ HIGH │ fixed │ 7.5.4 │
│ word-wrap │ CVE-2023-26115 │ MEDIUM │ fixed │ 1.2.4 │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘
Integreer het in CI om builds te laten falen bij kritieke kwetsbaarheden:
# .github/workflows/docker.yml
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueAlleen-Lezen Bestandssysteem#
Je kunt containers draaien met een alleen-lezen root-bestandssysteem. Dit voorkomt dat een aanvaller binaries kan wijzigen, tools kan installeren of kwaadaardige scripts kan schrijven:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestDe --tmpfs mounts bieden beschrijfbare tijdelijke mappen waar je applicatie legitiem naar moet schrijven (tijdelijke bestanden, caches). Al het andere is alleen-lezen.
In docker-compose:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cacheAlle Capabilities Verwijderen#
Linux capabilities zijn fijnmazige rechten die het alles-of-niets root-model vervangen. Standaard krijgen Docker-containers een subset van capabilities. Je kunt ze allemaal verwijderen:
docker run --cap-drop=ALL myapp:latestAls je applicatie moet binden aan een poort onder 1024, heb je NET_BIND_SERVICE nodig. Maar aangezien we poort 3000 gebruiken met een non-root gebruiker, hebben we geen enkele capability nodig:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges voorkomt dat het proces extra rechten verkrijgt via setuid/setgid-binaries. Dit is een defense-in-depth maatregel die niets kost.
Pin Je Base Image Digest#
In plaats van node:20-alpine te gebruiken (wat een bewegend doel is), pin naar een specifieke digest:
FROM node:20-alpine@sha256:abcdef123456...Verkrijg de digest met:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpineDit zorgt ervoor dat je builds 100% reproduceerbaar zijn. De afweging is dat je niet automatisch security patches voor het base image krijgt. Gebruik Dependabot of Renovate om digest-updates te automatiseren:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyCI/CD-Integratie: Alles Samenbrengen#
Hier is een complete GitHub Actions-workflow die een Docker-image bouwt, scant en pusht:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifBuildKit Cache in CI#
De cache-from: type=gha en cache-to: type=gha,mode=max regels gebruiken de GitHub Actions-cache als Docker layer-cache. Dit betekent dat je CI-builds profiteren van layer caching tussen runs. Eerste build duurt 5 minuten; latere builds met alleen codewijzigingen duren 30 seconden.
Veelvoorkomende Valkuilen en Hoe Ze Te Vermijden#
Het node_modules Binnenin het Image vs Host-Conflict#
Als je je projectmap volume-mount naar een container (-v .:/app), overschrijft de node_modules van de host die van de container. Native modules gecompileerd op macOS werken niet in Linux. Gebruik altijd de anonieme volume-truc:
volumes:
- .:/app
- /app/node_modules # behoudt de node_modules van de containerSIGTERM-Afhandeling in TypeScript-Projecten#
Als je TypeScript draait met tsx of ts-node in ontwikkeling, werkt signaalafhandeling normaal. Maar in productie, als je de gecompileerde JavaScript gebruikt met node, zorg er dan voor dat je gecompileerde output de signaalhandlers bewaart. Sommige build tools optimaliseren "ongebruikte" code weg.
Geheugenlimieten en Node.js#
Node.js respecteert Docker-geheugenlimieten niet automatisch. Als je container een geheugenlimiet van 512MB heeft, zal Node.js nog steeds proberen zijn standaard heap-grootte te gebruiken (ongeveer 1,5 GB op 64-bit systemen). Stel de maximale old space-grootte in:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]Laat ongeveer 25% extra ruimte tussen de Node.js heap-limiet en de container-geheugenlimiet voor non-heap geheugen (buffers, native code, etc.).
Of gebruik de automatische detectie-flag:
ENV NODE_OPTIONS="--max-old-space-size=384"Tijdzoneproblemen#
Alpine gebruikt standaard UTC. Als je applicatie afhankelijk is van een specifieke tijdzone:
RUN apk add --no-cache tzdata
ENV TZ=America/New_YorkMaar beter: schrijf tijdzone-onafhankelijke code. Sla alles op in UTC. Converteer naar lokale tijd alleen in de presentatielaag.
Build Arguments vs Environment Variables#
ARGis alleen beschikbaar tijdens de build. Het blijft niet bestaan in het eindimage (tenzij je het kopieert naarENV).ENVblijft bestaan in het image en is beschikbaar tijdens runtime.
# Build-time configuratie
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# Runtime configuratie
ENV PORT=3000
# FOUT: Dit maakt het secret zichtbaar in het image
ARG API_KEY
ENV API_KEY=${API_KEY}
# GOED: Geef secrets mee tijdens runtime
# docker run -e API_KEY=secret myappMonitoring in Productie#
Je Docker-setup is niet compleet zonder observability. Hier is een minimale maar effectieve monitoring-stack:
# docker-compose.monitoring.yml
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- internal
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
networks:
- internal
volumes:
prometheus_data:
grafana_data:Stel metrics bloot vanuit je Node.js-app met prom-client:
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
// Middleware
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
});
next();
});
// Metrics endpoint
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});De Checklist#
Voordat je een gecontaineriseerde Node.js-app naar productie shipt, controleer:
- Non-root gebruiker — Container draait als non-root gebruiker
- Multi-stage build — devDependencies en build tools zitten niet in het eindimage
- Alpine base — Gebruik een minimaal base image
- .dockerignore —
.git,.env,node_modules, tests uitgesloten - Layer caching —
package.jsongekopieerd voor broncode - Health check — HEALTHCHECK-instructie in Dockerfile
- Signaalafhandeling —
dumb-initof--initvoor correcte SIGTERM-afhandeling - Geen secrets in image — Geen
ENVmet gevoelige waarden in Dockerfile - Resource limits — Geheugen- en CPU-limieten ingesteld in compose/orchestrator
- Logrotatie — Logging driver geconfigureerd met maximale grootte
- Image scanning — Trivy of equivalent in CI-pipeline
- Gepinde versies — Base image- en dependency-versies gepind
- Geheugenlimieten —
--max-old-space-sizeingesteld voor Node.js heap
De meeste hiervan zijn eenmalige setup. Doe het een keer, maak er een template van, en elk nieuw project start met een productie-klare container vanaf dag een.
Docker is niet ingewikkeld. Maar de kloof tussen een "werkend" Dockerfile en een productie-klaar Dockerfile is groter dan de meeste mensen denken. De patronen in deze gids dichten die kloof. Gebruik ze, pas ze aan, en stop met het deployen van root-containers met 1GB images en geen health checks. Je toekomstige zelf — degene die om 3 uur 's nachts gepaged wordt — zal je dankbaar zijn.