Skip to main content
RunBook Academy

Docker & ContainersXXXV Β· Production HardeningHardening

Image and secret hardening β€” proving nothing leaked

Advanced⏱ ~28 min

What you'll learn

  • Audit an image for embedded credentials across all of its layers
  • Verify the identity of what is running by digest rather than by tag
  • Prove that secrets exist only in the container runtime, not in the image or the environment

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

An image is a filesystem plus a config, both of which are read by everyone who can pull it. The hardening question is not β€œdid we mean to put a credential in there” β€” nobody means to β€” but β€œcan we demonstrate that we did not”.

This lesson is the demonstration. Four properties, each with a command that returns a verdict: the image runs as a non-root user, the running container’s identity is pinned to a digest, no layer contains a credential, and every secret the application uses arrived at runtime.

Property 1 β€” the image does not default to root

Read-only / Safeimage default user
for i in $(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
U=$(docker image inspect "$i" --format '{{.Config.User}}')
printf '%-44s USER=%s\n' "$i" "${U:-ROOT}"
done
registry.example.com/api:1.4.0               USER=10001
registry.example.com/web:1.4.0               USER=10001
postgres:16                                  USER=ROOT
redis:7-alpine                               USER=ROOT

Illustrative output

Third-party images defaulting to root is normal β€” postgres drops privileges itself after start-up, and redis expects you to set the user. What matters is that the runtime overrides it, which the previous lesson’s audit already checks. What this check catches is your own image shipping without a USER line, so that the safety depends entirely on every deployment remembering --user.

Use a numeric UID in the USER instruction, not a name:

RUN useradd --system --uid 10001 --no-create-home app
USER 10001:10001

A named user requires /etc/passwd to resolve it, which distroless and scratch images may not have, and which means the runtime cannot verify the UID without starting the container.

Property 2 β€” you know exactly what is running

A tag is a mutable pointer. api:1.4.0 today and api:1.4.0 next month can be different images, which makes every statement about β€œthe image we audited” unverifiable.

Read-only / Saferunning digests
docker ps -q | while read -r c; do
  docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}}' "$c"
done
/api registry.example.com/api:1.4.0 sha256:4f2a...c19d
/web registry.example.com/web:1.4.0 sha256:9b71...02af

Illustrative output

Read-only / Safedrift between tag and running image
for c in $(docker ps -q); do
  RUNNING=$(docker inspect --format '{{.Image}}' "$c")
  TAG=$(docker inspect --format '{{.Config.Image}}' "$c")
  CURRENT=$(docker image inspect "$TAG" --format '{{.Id}}' 2>/dev/null || echo missing)
  [ "$RUNNING" = "$CURRENT" ] || echo "DRIFT: $TAG running $RUNNING, tag now $CURRENT"
done
true
DRIFT: registry.example.com/api:1.4.0 running sha256:4f2a...c19d, tag now sha256:81e0...77b3

Illustrative output

That output means somebody rebuilt and re-pushed 1.4.0. Whatever was scanned and approved is not what is running, or will not be after the next restart.

Pin deployments by digest so the question cannot arise:

services:
  api:
    image: registry.example.com/api@sha256:4f2ac19d0000000000000000000000000000000000000000000000000000c19d

Property 3 β€” no layer contains a credential

This is the property people get wrong, because they check the final filesystem rather than the layers.

Read-only / Safescan the build history
IMG=registry.example.com/api:1.4.0
docker history --no-trunc --format '{{.CreatedBy}}' "$IMG" \
  | grep -inE 'password|passwd|secret|token|api[_-]?key|BEGIN [A-Z ]*PRIVATE KEY' \
  || echo 'OK: no credential-shaped strings in history'
OK: no credential-shaped strings in history
Read-only / Safescan the image config environment
IMG=registry.example.com/api:1.4.0
docker image inspect "$IMG" --format '{{range .Config.Env}}{{println .}}{{end}}' \
  | grep -iE 'password|secret|token|key=' \
  || echo 'OK: no credential-shaped environment variables'
OK: no credential-shaped environment variables

For the layer contents themselves, export and search. This is slow and worth doing once per release rather than per build:

IMG=registry.example.com/api:1.4.0
WORK=$(mktemp -d)
docker save "$IMG" -o "$WORK/image.tar"
tar -xf "$WORK/image.tar" -C "$WORK"
grep -rlE 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|AKIA[0-9A-Z]{16}' "$WORK" || echo 'OK: no key material in layers'
rm -rf "$WORK"

A dedicated secret scanner does this better and with far fewer false negatives. Run one in CI β€” the point of showing the manual version is that you can reproduce a finding on a host with nothing installed.

Property 4 β€” secrets arrive at runtime, and only at runtime

Read-only / Safesecrets in the container environment
docker ps -q | while read -r c; do
  N=$(docker inspect --format '{{.Name}}' "$c")
  docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$c" \
    | grep -iE '(password|secret|token|apikey)=..' \
    | sed "s|^|$N |"
done
true
/legacy-batch DB_PASSWORD=REDACTED
/adminer ADMINER_PASSWORD=REDACTED

Illustrative output

Two findings. docker inspect is readable by every member of the docker group, environment variables are inherited by every child process, and /proc/<pid>/environ exposes them to anything with the same UID in the container.

The correct shape is a file the runtime provides:

services:
  api:
    image: registry.example.com/api@sha256:4f2ac19d00000000000000000000000000000000000000000000000000000000
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password
Read-only / Safeverify the secret is a file and nothing more
docker exec api ls -l /run/secrets/
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
sudo grep 'run/secrets' "/proc/$PID/mounts"
total 4
-r--r--r-- 1 10001 10001 33 Aug 11 09:14 db_password
tmpfs /run/secrets tmpfs ro,relatime,size=65536k,inode64 0 0

Illustrative output

ro on the mount and mode 0444 owned by the container UID is the target state. A secret on a writable mount can be modified by the application, which defeats rotation.

Build-time secrets

A credential needed during a build β€” a private package registry token, an SSH key for a private module β€” must never be an ARG or a COPY. BuildKit mounts it for the duration of one RUN and leaves nothing in the layer:

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
RUN --mount=type=secret,id=npmtoken,target=/run/secrets/npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) && \
    npm ci --registry https://npm.example.com
docker build --secret id=npmtoken,src=./npm_token.txt -t app:1.0.0 .

The mount exists only while that RUN executes. Prove it afterwards with the same docker history grep β€” the secret’s id appears, its value does not.

  1. **Assert a non-root numeric USER** in every image you build, and check it in CI rather than in review.
  2. Fail the build on a secret-scanner finding, over history, config and layer contents.
  3. Publish by digest and deploy by digest; keep the tag as a label for humans.
  4. Verify signatures at deploy time against the digest, not the tag.
  5. Audit running containers for credential-shaped environment variables on the same schedule as the container hardening audit.
  6. **Confirm /run/secrets is a read-only tmpfs** holding exactly the secrets the service needs and no more.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. A Dockerfile copies a private key, uses it, then removes it in the same RUN chain of a later instruction. What does the published image contain?

  2. Q2. Why can a statement like "we audited api:1.4.0" not be verified later?

  3. Q3. Which places must a secret audit look, beyond the running filesystem? Select all that apply.

  4. Q4. On discovering a credential inside a published image, the first action is to delete the affected tags from the registry.

Passing score: 75%. Answers are checked in this browser.