Harden Docker Images with Minimal Bases, Pinned Digests, and Non-Root
For developers shipping containers to production, this shows how to rebuild an image on a minimal base, pin every base image by digest, and run the final container as a non-root user. You’ll finish with a Dockerfile you can audit, a smaller attack surface, and verification commands that catch the common breakages before deploy.
TL;DR — Rebuild your image as a multi-stage Dockerfile: compile in a full builder image, copy only the runtime artifacts into a minimal final image, pin every
FROMline to a digest, and setUSERto a numeric non-root UID/GID. The single most common fix is replacing tag-only bases likenode:20-alpinewithnode:20-alpine@sha256:...and explicitlychowning copied files so the non-root process can read and write what it needs. Reading time: ~5 min
Goal
When you are done, your application image builds from pinned base-image digests, contains only the runtime files it needs, starts successfully as a non-root user, and passes basic checks showing it is smaller, reproducible, and not running as UID 0.
Prerequisites
- Docker Engine with BuildKit enabled, Docker >= 26 — check with:
docker version
- Access to the application source and its current Dockerfile
- A shell with
grep,awk, andid - Optional but useful:
syftortrivyfor image inspection — check with:
syft version
trivy --version
- The exact base image tag you currently use, for example
node:22-bookwormorpython:3.12-slim - If your app writes files at runtime, the exact writable paths it needs, for example
/tmp,/app/uploads, or/var/cache/app
Steps
Step 1: Record the current image behavior and size
Run your current build once so you have a before/after comparison.
docker build -t myapp:before .
docker image ls myapp:before
docker run --rm myapp:before id
You should see one image row and, on many existing images, output shaped like uid=0(root) gid=0(root) groups=0(root).
Step 2: Resolve and pin the base image digest
Get the exact digest for the base image tag you intend to use. Replace the image name below with yours.
docker buildx imagetools inspect node:22-bookworm
Look for output shaped like:
Name: docker.io/library/node:22-bookworm
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:3d7d8b7d6d2d5d7f0d0d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a291817161514
Use that Digest: value in your Dockerfile. Success means you have a full reference like node:22-bookworm@sha256:....
Step 3: Replace the Dockerfile with a multi-stage build on a minimal runtime base
Use a builder image for compilation and a smaller runtime image for execution. Example for a Node.js app that produces dist/.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm@sha256:3d7d8b7d6d2d5d7f0d0d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a291817161514 AS build
WORKDIR /src
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim@sha256:8a6c5d4e3f2a1908171615143d7d8b7d6d2d5d7f0d0d8e7b6c5a4f3e2d1c0b9 AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN groupadd --gid 10001 app && useradd --uid 10001 --gid 10001 --home-dir /app --create-home --shell /usr/sbin/nologin app
COPY --from=build --chown=10001:10001 /src/package*.json ./
COPY --from=build --chown=10001:10001 /src/node_modules ./node_modules
COPY --from=build --chown=10001:10001 /src/dist ./dist
USER 10001:10001
EXPOSE 3000
CMD ["node", "dist/server.js"]
If your app is static, compiled, or single-binary, use an even smaller final base such as debian:bookworm-slim@sha256:... or a distroless runtime if you do not need a shell. Success means the file contains only digest-pinned FROM lines and a final USER that is not root.
Step 4: Add a .dockerignore so secrets and junk do not enter the build context
Create or replace .dockerignore with literal entries like these:
.git
.gitignore
Dockerfile
node_modules
npm-debug.log
.env
.env.*
coverage
dist
*.pem
*.key
Build context transfer should shrink on the next build. Success looks like a smaller transferring context: line during docker build.
Step 5: Build the hardened image and inspect the result
Build the new image and check the user and size.
docker build -t myapp:hard .
docker image ls myapp:before myapp:hard
docker run --rm myapp:hard id
You should see output shaped like uid=10001(app) gid=10001(app) groups=10001(app) and the hardened image should usually be smaller than myapp:before.
Step 6: Verify the app still starts as non-root
Run the container with the app port published.
docker run --rm -p 3000:3000 --name myapp-test myapp:hard
In another terminal, test it:
curl -i http://127.0.0.1:3000/
You should see an HTTP status line such as:
HTTP/1.1 200 OK
If your app writes files, test the writable path explicitly:
docker run --rm myapp:hard sh -lc 'test -w /tmp && echo ok'
On slim images with a shell, success prints ok. On distroless images, skip the shell test and validate through application behavior instead.
Step 7: Fail the build if the final image runs as root or uses unpinned bases
Add these checks to CI or run them locally.
awk '/^FROM / && $0 !~ /@sha256:/ { print "UNPINNED: " $0; bad=1 } END { exit bad }' Dockerfile
docker inspect myapp:hard --format '{{.Config.User}}'
Expected output is no UNPINNED: lines, and the inspect command should print 10001:10001 or another non-zero UID:GID.
Verify it works
Run all of these checks end to end:
docker build -t myapp:hard .
docker run --rm myapp:hard id
docker inspect myapp:hard --format '{{.Config.User}}'
docker history --no-trunc myapp:hard
curl -i http://127.0.0.1:3000/
Expected results:
| Check | Expected result |
|---|---|
docker run --rm myapp:hard id | uid is not 0 |
docker inspect ... '{{.Config.User}}' | prints 10001:10001 or another non-root numeric user |
docker history --no-trunc myapp:hard | final image does not include build tools like gcc, make, package manager caches, or source tree junk |
curl -i http://127.0.0.1:3000/ | HTTP/1.1 200 OK or your app’s expected healthy status |
Common pitfalls
USER app exists in one stage, but not in the final stage
Mistake: creating the user in the builder stage and assuming it carries into the runtime stage.
Symptom: container start fails with messages shaped like:
docker: Error response from daemon: unable to find user app: no matching entries in passwd file.
Fix: create the user again in the final stage, or use USER 10001:10001 after creating that UID/GID there.
Files copied into the final image are owned by root
Mistake: COPY --from=build /src/dist ./dist without ownership change.
Symptom: app starts, then fails on reads or writes with errors like:
Error: EACCES: permission denied, open '/app/uploads/file.txt'
Fix: use COPY --chown=10001:10001 ... and create writable directories with:
RUN mkdir -p /app/uploads && chown -R 10001:10001 /app
Pinning only tags, not digests
Mistake: using FROM python:3.12-slim and assuming builds are reproducible.
Symptom: the same commit builds differently on different days; vulnerability scans drift unexpectedly.
Fix: replace every FROM image:tag with FROM image:tag@sha256:... using docker buildx imagetools inspect to fetch the digest.
Using alpine breaks native modules or glibc-linked binaries
Mistake: switching to Alpine to shrink the image without checking libc compatibility.
Symptom: runtime failures such as:
Error loading shared library libc.so.6: No such file or directory
or Node/Python native module load errors.
Fix: use a glibc-based minimal image like debian:bookworm-slim or the language runtime *-slim variant instead of Alpine.
Distroless final images remove your debugging tools
Mistake: moving straight to distroless and then trying to debug with sh, ps, or curl inside the container.
Symptom: commands fail like:
exec: "sh": executable file not found in $PATH
Fix: keep a *-slim runtime while stabilizing the build, or publish a separate debug image; do not add shells to the production image just for convenience.
Binding to a privileged port as non-root
Mistake: app still listens on port 80 or 443 inside the container.
Symptom: startup error such as:
listen EACCES: permission denied 0.0.0.0:80
Fix: change the app to listen on an unprivileged port like 3000 or 8080, then map host port 80/443 at the orchestrator or reverse proxy layer.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI