Dockerfiles Hardenen: Kleinere Images en Minder Securityrisico’s Zonder Buildbreuken
Deze tutorial laat je stap voor stap zien hoe je Dockerfiles kunt hardenen: je maakt images kleiner, reproduceerbaarder en veiliger, zonder dat builds “random” breken door upstream changes. We focussen op praktische patronen die je direct kunt toepassen, met echte commando’s en voorbeelden.
Doel: minder attack surface, minder CVE’s, snellere builds, stabielere deployments.
Inhoud
- Waarom hardenen? (en wat gaat er vaak mis)
- Basisprincipes: determinisme, minimalisme, least privilege
- Pin je base image (tags vs digests)
- Multi-stage builds: build tools niet meenemen naar productie
- Kleinere layers: RUN combineren, caches slim gebruiken, rommel opruimen
- Package management hardenen (apt/apk): pinnen, no-install-recommends, cleanup
- Niet als root draaien: users, permissions, capabilities
- Secrets en gevoelige data: nooit in images bakken
- Supply chain: SBOM, signatures, scanning, provenance
- Runtime hardening: read-only filesystem, drop capabilities, seccomp
- Praktijkvoorbeelden: Node.js, Python, Go
- Checklist: “hardened” Dockerfile zonder buildbreuken
Waarom hardenen? (en wat gaat er vaak mis)
Een Dockerfile is niet alleen “hoe bouw ik mijn app”, maar ook een security- en supply-chain contract. Veel voorkomende problemen:
- Builds breken plots: je gebruikt
ubuntu:latestofnode:18en upstream wijzigt iets (nieuwe OS release, andere packageversies, verwijderde repo’s). - Onnodig grote images: build tools, caches en tijdelijke bestanden blijven achter.
- Te veel privileges: container draait als root, kan te veel in het systeem, of heeft schrijfrechten waar dat niet nodig is.
- Secrets lekken: tokens in
ENV,ARG, of per ongeluk in de image-layer viaRUN echo. - Onzichtbare supply-chain risico’s: je weet niet welke dependencies precies in je image zitten, of je base image is vervangen.
Hardening betekent hier: bewust beperken, vastzetten (pinning), en controleerbaar maken.
Basisprincipes: determinisme, minimalisme, least privilege
1) Determinisme (reproduceerbaarheid)
Je wilt dat dezelfde broncode + dezelfde Dockerfile = dezelfde output (of zo dicht mogelijk). Dat bereik je door:
- base images te pinnen (liefst op digest)
- dependencies te pinnen (lockfiles, versies)
- build tooling te versioneren
- netwerkafhankelijkheid te minimaliseren (caches, mirrors, vendoring)
2) Minimalisme (minder attack surface)
Alles wat je niet nodig hebt, is extra:
- extra CVE’s
- extra bytes
- extra complexiteit
Voor productie wil je meestal: alleen runtime + je app + strikt noodzakelijke libs.
3) Least privilege
De container hoeft zelden root te zijn. En zelfs als root nodig is voor install tijdens build, hoeft dat niet in runtime.
Pin je base image (tags vs digests)
Waarom :latest (en ook “losse” tags) riskant zijn
FROM ubuntu:latest betekent: “geef me wat Canonical vandaag ‘latest’ noemt”. Dat kan morgen iets anders zijn. Zelfs ubuntu:22.04 kan in de tijd veranderen (security updates), wat goed is, maar ook tot subtiele verschillen kan leiden.
Digest pinning: exact dezelfde base
Een digest is een content-hash van de image manifest. Daarmee krijg je exact dezelfde base.
- Zoek de digest:
docker pull ubuntu:22.04
docker inspect --format='{{index .RepoDigests 0}}' ubuntu:22.04
Output lijkt op:
ubuntu@sha256:xxxxxxxx...
- Gebruik die in je Dockerfile:
FROM ubuntu@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Trade-off: je krijgt niet automatisch security updates. Oplossing: update digests gecontroleerd (bijv. via Dependabot/Renovate) en scan in CI.
Praktisch advies
- Voor productie: pin op digest.
- Voor snelle dev: tag pinnen kan, maar wees bewust van drift.
- Documenteer je update-proces: “we bumpen base image digests wekelijks + CI scan”.
Multi-stage builds: build tools niet meenemen naar productie
Multi-stage builds zijn een van de grootste winsten voor zowel size als security.
Probleem: build chain in productie
Als je in één stage compileert en runt, neem je vaak mee:
- compilers (gcc, make)
- headers en dev packages
- package managers
- tijdelijke build artifacts
Dat vergroot je image en attack surface.
Oplossing: scheid build en runtime
Voorbeeld (Go):
# syntax=docker/dockerfile:1.7
FROM golang:1.22.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Wat gebeurt hier?
- Stage
build: bevat Go toolchain. - Final stage: distroless runtime, geen shell, geen package manager.
-trimpathen-ldflags "-s -w"maken binary kleiner en minder “leaky” qua paden/symbols.USER nonrootbeperkt privileges.
Build:
docker build -t mijnapp:go .
docker run --rm -p 8080:8080 mijnapp:go
Kleinere layers: RUN combineren, caches slim gebruiken, rommel opruimen
Elke RUN, COPY, ADD creëert een layer. Veel layers is niet per se slecht, maar onnodige data in layers blijft bestaan.
RUN combineren + cleanup in dezelfde layer
Slecht (cache blijft in eerdere layer):
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Goed (cleanup in dezelfde layer):
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Gebruik .dockerignore
Zonder .dockerignore stuur je mogelijk je hele repo naar de Docker daemon (incl. node_modules, .git, build output).
Voorbeeld .dockerignore:
.git
node_modules
dist
build
*.log
.env
Test hoeveel context je verstuurt:
docker build --no-cache -t test .
Let op build output: “Sending build context to Docker daemon …”.
BuildKit cache mounts (sneller, schoner)
Met BuildKit kun je caches gebruiken zonder ze in de image te bewaren.
Voorbeeld (apt cache):
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
BuildKit aan:
DOCKER_BUILDKIT=1 docker build -t hardened:cache .
Package management hardenen (apt/apk): pinnen, no-install-recommends, cleanup
Debian/Ubuntu (apt)
Best practices:
--no-install-recommendsom bloat te vermijdenrm -rf /var/lib/apt/lists/*om apt index te verwijderen- installeer alleen wat je nodig hebt
- overweeg versies te pinnen (maar doe dit bewust)
Voorbeeld:
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Versies pinnen?
Pinnen kan buildbreuken verminderen, maar kan ook security updates blokkeren. Een middenweg:
- pin je base image digest
- pin je app dependencies (npm lockfile, poetry.lock, go.sum)
- laat OS security patches binnenkomen via gecontroleerde digest updates
Als je toch apt versies wilt pinnen:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl=7.81.0-1ubuntu1.18 \
&& rm -rf /var/lib/apt/lists/*
Maar: deze exacte versie kan verdwijnen uit repo’s, wat juist buildbreuken kan veroorzaken. Overweeg dan een eigen mirror of snapshot repo.
Alpine (apk)
Alpine is klein, maar let op musl vs glibc compatibiliteit.
RUN apk add --no-cache ca-certificates curl
--no-cache voorkomt dat /var/cache/apk/* blijft liggen.
Niet als root draaien: users, permissions, capabilities
Waarom dit belangrijk is
Als een aanvaller je app compromitteert, is het verschil tussen root en non-root gigantisch. Root in een container is vaak nog steeds “root-ish” op de host (afhankelijk van config), en kan misbruikt worden voor privilege escalation.
Maak een user aan (Debian/Ubuntu)
RUN useradd --create-home --shell /usr/sbin/nologin appuser
USER appuser
Maak directories writable waar nodig
Als je app schrijft naar /tmp is dat meestal oké. Maar als je schrijft naar /app/data, maak dat expliciet:
WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app
USER appuser
Distroless en nonroot
Veel distroless images hebben al een nonroot user. Gebruik die.
Capabilities beperken (runtime)
Zelfs als je non-root draait, kun je extra hardenen door capabilities te droppen:
docker run --rm \
--cap-drop=ALL \
--security-opt=no-new-privileges \
mijnapp:latest
Soms heb je specifieke capabilities nodig (bijv. NET_BIND_SERVICE om op poort 80 te luisteren). Voeg alleen die toe:
docker run --rm \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
mijnapp:latest
Secrets en gevoelige data: nooit in images bakken
Veelgemaakte fouten
ENV API_KEY=...in DockerfileARG NPM_TOKEN=...en danRUN npm config set ...(komt in layer history).envmee kopiëren door ontbrekende.dockerignore
Correcte aanpak
- Gebruik runtime secrets (Docker/Kubernetes secrets)
- Gebruik BuildKit secrets voor build-time auth (bijv. private registries)
Voorbeeld: npm token als BuildKit secret:
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npm_token \
sh -c 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > /root/.npmrc' \
&& npm ci \
&& rm -f /root/.npmrc
Build met secret:
DOCKER_BUILDKIT=1 docker build \
--secret id=npm_token,src=$HOME/.secrets/npm_token \
-t mijnnodeapp:secure .
Belangrijk: secrets via --secret worden niet in layers opgeslagen.
Supply chain: SBOM, signatures, scanning, provenance
Hardening stopt niet bij de Dockerfile. Je wilt weten wat je shipped.
Image scannen op kwetsbaarheden (Trivy)
Installatie verschilt per OS, maar scan zo:
trivy image --severity HIGH,CRITICAL --no-progress mijnapp:latest
SBOM genereren (Syft)
syft mijnapp:latest -o spdx-json > sbom.spdx.json
Signeren (Cosign)
Als je een registry gebruikt (bijv. GHCR), kun je signeren:
cosign sign --key cosign.key ghcr.io/mijnorg/mijnapp:1.2.3
cosign verify --key cosign.pub ghcr.io/mijnorg/mijnapp:1.2.3
Pin ook je dependencies
- Node:
package-lock.jsonofpnpm-lock.yaml - Python:
poetry.lockofrequirements.txtmet hashes - Go:
go.sum
Dit vermindert “works on my machine” en supply-chain drift.
Runtime hardening: read-only filesystem, drop capabilities, seccomp
Je Dockerfile kan veel doen, maar runtime flags zijn net zo belangrijk.
Read-only root filesystem
Als je app niet hoeft te schrijven naar root filesystem:
docker run --rm \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
mijnapp:latest
Je geeft een tijdelijke schrijfplek in /tmp.
Seccomp en AppArmor
Docker gebruikt standaard seccomp-profielen, maar je kunt strikter zijn. Custom profielen zijn context-afhankelijk; begin met:
docker run --rm --security-opt=no-new-privileges mijnapp:latest
In Kubernetes kun je dit vertalen naar securityContext (maar deze tutorial blijft bij echte commands en Dockerfile focus).
Praktijkvoorbeelden: Node.js, Python, Go
Hieronder drie “hardened” voorbeelden met uitleg.
Voorbeeld 1: Node.js (multi-stage, npm ci, non-root)
Doelen
- dev dependencies niet in productie
- reproducible install via lockfile
- non-root runtime
- kleine image
# syntax=docker/dockerfile:1.7
FROM node:20.15.1-bookworm AS build
WORKDIR /app
# Alleen dependency manifests eerst voor betere caching
COPY package.json package-lock.json ./
# npm ci is deterministischer dan npm install (gebruikt lockfile strikt)
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Kopieer broncode en build
COPY . .
RUN npm run build
# Productie dependencies apart (optioneel, afhankelijk van je setup)
RUN npm prune --omit=dev
FROM node:20.15.1-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Maak non-root user
RUN useradd --create-home --shell /usr/sbin/nologin nodeapp \
&& mkdir -p /app \
&& chown -R nodeapp:nodeapp /app
# Kopieer alleen wat nodig is
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nodeapp
EXPOSE 3000
CMD ["node", "dist/server.js"]
Build en run:
DOCKER_BUILDKIT=1 docker build -t mijnnodeapp:1.0 .
docker run --rm -p 3000:3000 mijnnodeapp:1.0
Hardening highlights:
npm ci+ lockfile = minder verrassingen.npm prune --omit=dev= kleinere runtime.bookworm-slimruntime = kleiner dan full.- non-root user.
Nog strakker: overweeg distroless Node runtime, maar dat vereist vaak extra aandacht voor CA certs en debugging.
Voorbeeld 2: Python (wheels bouwen, slim runtime, geen build tools)
Doelen
- build deps (gcc, headers) niet in runtime
- pinned dependencies
- kleinere image
We gebruiken pip wheel om wheels te bouwen in build stage.
# syntax=docker/dockerfile:1.7
FROM python:3.12.4-bookworm AS build
WORKDIR /w
# System deps alleen voor build (voorbeeld: psycopg)
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Bouw wheels (offline install in runtime)
RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --no-deps -r requirements.txt -w /wheels
FROM python:3.12.4-slim-bookworm AS runtime
WORKDIR /app
# Alleen runtime libs (zo min mogelijk)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
RUN useradd --create-home --shell /usr/sbin/nologin appuser
USER appuser
COPY --from=build /wheels /wheels
COPY requirements.txt .
RUN --mount=type=cache,target=/home/appuser/.cache/pip \
pip install --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
CMD ["python", "-m", "mijnpakket"]
Build:
DOCKER_BUILDKIT=1 docker build -t mijnpythonapp:1.0 .
Hardening highlights:
- Build tools alleen in build stage.
- Runtime is
slim. pip install --no-indexinstalleert alleen van lokale wheels: minder supply-chain verrassingen tijdens runtime stage.- non-root.
Extra hardening: gebruik requirements.txt met hashes:
pip-compile --generate-hashes
En dan in Dockerfile:
RUN pip install --require-hashes -r requirements.txt
Voorbeeld 3: Go (distroless, static, nonroot)
Deze zagen we eerder, maar hier nog wat extra hardening.
# syntax=docker/dockerfile:1.7
FROM golang:1.22.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Run met extra runtime hardening:
docker run --rm \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--tmpfs /tmp:rw,nosuid,noexec,size=64m \
mijnapp:go
Veelvoorkomende buildbreuken (en hoe je ze voorkomt)
1) “Package not found” bij apt/apk
Oorzaken:
- repo veranderd
- versie gepind die niet meer beschikbaar is
- base image geüpdatet naar andere release
Mitigaties:
- pin base image digest
- gebruik snapshot repositories (enterprise)
- vermijd te strikte apt version pinning tenzij je repo controleert
2) Node builds breken door native modules
Oorzaken:
node-gypvereist build tools- glibc/musl mismatch (Alpine)
Mitigaties:
- build stage met build-essential
- runtime op Debian slim i.p.v. Alpine als je native deps hebt
- pin Node versie exact
3) Python wheels verschillen per platform
Mitigaties:
- build wheels in dezelfde distro familie als runtime (bookworm/buildworm slim)
- pin Python patch version
- gebruik manylinux wheels waar mogelijk
Debugbaarheid vs hardening: maak een bewuste keuze
Distroless images zijn top voor security, maar debugging is lastiger (geen shell). Twee patronen:
- Debug image apart
Maak een extra target in je Dockerfile:
FROM runtime AS debug
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl procps && rm -rf /var/lib/apt/lists/*
USER nodeapp
Build debug target:
docker build --target debug -t mijnapp:debug .
- Ephemeral debug container
In Kubernetes kun je ephemeral containers gebruiken; in Docker kun jedocker execgebruiken als er een shell is. Bij distroless niet.
Checklist: “hardened” Dockerfile zonder buildbreuken
Gebruik deze checklist als standaard review:
Reproduceerbaarheid
- Base image gepind (idealiter
@sha256:digest) - App dependencies gepind (lockfile / go.sum / hashes)
- Geen
curl | bashzonder verificatie - BuildKit cache mounts gebruikt waar nuttig (sneller, schoner)
Minimalisme
- Multi-stage build: build tools niet in runtime
-
--no-install-recommends(Debian) of--no-cache(Alpine) - Cleanup in dezelfde
RUNlayer (rm -rf /var/lib/apt/lists/*) -
.dockerignoreaanwezig en correct
Least privilege
-
USERis non-root in runtime stage - Alleen noodzakelijke files gekopieerd naar runtime
- File permissions expliciet (geen “777” workarounds)
Secrets en supply chain
- Geen secrets in
ENV,ARG, of gekopieerde.env - Build-time secrets via
--secret(BuildKit) - Image scanning in CI (
trivy image ...) - SBOM generatie (
syft ...) en eventueel signing (cosign ...)
Runtime hardening (deployment)
-
--read-onlywaar mogelijk -
--cap-drop=ALL+ alleen noodzakelijke adds -
--security-opt=no-new-privileges
Conclusie
Dockerfiles hardenen is geen “extra werk voor security”, maar een manier om betrouwbaarder te bouwen en te deployen. Door te pinnen (digest + lockfiles), multi-stage builds te gebruiken, privileges te minimaliseren en supply-chain tooling toe te voegen, krijg je:
- kleinere images
- minder CVE’s en minder aanvalsvectoren
- minder buildbreuken door upstream wijzigingen
- beter te auditen artefacts
Als je wilt, kan ik ook:
- jouw bestaande Dockerfile reviewen en herschrijven (plak hem hier),
- een CI-pipeline voorstel geven (build + scan + SBOM + sign),
- of een variant maken voor jouw stack (Java/.NET/Rust, etc.).