HackBlue

Tools · Containers and Kubernetes security · 12 / 20

Dockerfile security best practices: non-root users, multi-stage builds, and no secrets in layers

Rewrite a typical Dockerfile to pin its base, drop build tools with a multi-stage build, run as a numeric non-root user, and keep secrets out of image history.

10 min read · practice only with authorization

What goes wrong

Common issues: an unpinned latest base, compilers and package managers left in the final image, the app running as root, secrets passed with ARG or copied in and deleted in a later layer, and a broad COPY . that pulls in .git or .env files. Every layer is kept in the image, so a file deleted later is still recoverable from the earlier layer.

A better Dockerfile

Use a multi-stage build so the final image contains only the runtime and the built app. Pin the base by tag and ideally by digest. Create a user with a numeric UID and switch to it. Use BuildKit secret mounts for credentials needed during build, so they never land in a layer. Add a .dockerignore.

Snippet
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /src
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /src/dist ./dist
COPY --from=build /src/node_modules ./node_modules
USER 10001
CMD ["dist/server.js"]

Lab: find a secret in history

Build a deliberately bad image that copies a fake token file and deletes it in the next RUN. Run docker history and then save the image and list each layer’s files: the token is still there. Rebuild using a secret mount and confirm the file is absent from every layer. Use only fake values.

Snippet
docker history --no-trunc bad-image:lab
docker save bad-image:lab -o bad.tar && mkdir bad && tar -xf bad.tar -C bad
find bad -name '*.tar' -o -name 'layer.tar' | head
trivy image --scanners secret bad-image:lab

DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t good-image:lab .

Lint it

Hadolint checks Dockerfiles for these issues and many more, such as missing version pins for apt packages. trivy config and Docker Scout also report Dockerfile misconfigurations. Add one of them to CI next to the image scan.

Snippet
docker run --rm -i hadolint/hadolint < Dockerfile

Diagram

Typical Dockerfile versus hardened Dockerfile

Each change removes something an attacker could use.

Typical

Base. node:latest

Contents. Compilers, npm cache, source, .git

User. root

Secrets. ARG TOKEN or copied file

Hardened

Base. Pinned slim or distroless

Contents. Runtime and built app only

User. USER 10001

Secrets. BuildKit secret mount, never in a layer

Check yourself

1. A Dockerfile copies a key file and deletes it in the next instruction. Is the key gone from the image?

2. Which technique keeps compilers out of the final image?

3. How should a build-time credential be provided?