← Terug naar tutorials

Dockerfiles Hardenen: Kleinere Images en Minder Securityrisico’s Zonder Buildbreuken

dockerdockerfilesecurityhardeningimage sizemulti-stage buildsdevopssupply chain securityci/cdbest practices

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

  1. Waarom hardenen? (en wat gaat er vaak mis)
  2. Basisprincipes: determinisme, minimalisme, least privilege
  3. Pin je base image (tags vs digests)
  4. Multi-stage builds: build tools niet meenemen naar productie
  5. Kleinere layers: RUN combineren, caches slim gebruiken, rommel opruimen
  6. Package management hardenen (apt/apk): pinnen, no-install-recommends, cleanup
  7. Niet als root draaien: users, permissions, capabilities
  8. Secrets en gevoelige data: nooit in images bakken
  9. Supply chain: SBOM, signatures, scanning, provenance
  10. Runtime hardening: read-only filesystem, drop capabilities, seccomp
  11. Praktijkvoorbeelden: Node.js, Python, Go
  12. 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:

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:

2) Minimalisme (minder attack surface)

Alles wat je niet nodig hebt, is extra:

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.

  1. Zoek de digest:
docker pull ubuntu:22.04
docker inspect --format='{{index .RepoDigests 0}}' ubuntu:22.04

Output lijkt op:

ubuntu@sha256:xxxxxxxx...
  1. 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


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:

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?

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:

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:

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

Correcte aanpak

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

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

# 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:

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

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:

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:

Mitigaties:

2) Node builds breken door native modules

Oorzaken:

Mitigaties:

3) Python wheels verschillen per platform

Mitigaties:


Debugbaarheid vs hardening: maak een bewuste keuze

Distroless images zijn top voor security, maar debugging is lastiger (geen shell). Twee patronen:

  1. 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 .
  1. Ephemeral debug container
    In Kubernetes kun je ephemeral containers gebruiken; in Docker kun je docker exec gebruiken als er een shell is. Bij distroless niet.

Checklist: “hardened” Dockerfile zonder buildbreuken

Gebruik deze checklist als standaard review:

Reproduceerbaarheid

Minimalisme

Least privilege

Secrets en supply chain

Runtime hardening (deployment)


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:

Als je wilt, kan ik ook: