Containers & Orchestration

Dockerfile Security Hardening: Shrinking the Attack Surface

A landing checklist for image maintainers and service containerizers: non-root user, read-only root FS, dropping capabilities, layer hygiene, and image scanning.

By LaoHand Team·8 min read·Updated 2026-09-06

Why the Container User Matters: Root Escape Becomes Host Risk

Container processes default to root, and uid 0 maps to host root. Via a Docker bug or a wrong mount, an escape hands the attacker full host privileges. Moving the process off root is the best value-per-effort hardening step.

Non-root is not just a RUN useradd; it must pair with ownership, port binding, and start/sbin directory permissions, or the process refuses to launch and you gain a new problem.

Start this section by getting the process to run as a real non-root user; read-only and capability-dropping will complete the defenses.

FROM node:20-alpine

# Create a non-root user with a home directory
RUN addgroup -S appgrp && adduser -S appuser -G appgrp

# Fix ownership on copy so the app can write its cache dir at startup
COPY --chown=appuser:appgrp . /app

# Switch to the non-root user
USER appuser

CMD ["node", "server.js"]

Less Is More: Flattening Dependencies and Layers of Attack

An image magnifies binary area. Slim the base to the running need: Alpine/distroless, combine RUN layers that also purge packages, and never bake in source, secrets, or debug symbols.

Order the build so volatile layers sit late and dependency-install layers (npm ci, pip) sit early, reusing cache while keeping the final code layer tiny.

.dockerignore keeps node_modules, .git, and .env out of the build context; multi-stage keeps only runtime artifacts in the final image, with binary copies and a resolved dynamic-dependency path.

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN npm ci --omit=dev

# .dockerignore should contain at least: node_modules, .git, .env

Mistake: Secrets in the Image or a Privileged Runtime

Putting DATABASE_PASSWORD into a Dockerfile ENV or a config file means anyone who can pull the image (collaborators, public registries) reads it — a bare secret.

Right approach: pass env at runtime via -e or compose environment, or mount a secret (Docker Swarm secret / K8s secret / cloud KMS). Build-time secrets flow through BuildKit --secret, never into a layer.

Also, do not reach for --privileged out of convenience; grant the least capability and let the default SECCOMP profile do its job.

# Bad example: bake the secret into the image
# ENV DATABASE_PASSWORD=supersecret123

# Fix: inject at runtime
# docker run -e DATABASE_PASSWORD=$DB_PASS -e DATABASE_USER=$DB_USER image:tag

# Or in compose
#   api:
#     env_file:
#       - .env.production   # not committed

# Use BuildKit for build-time secrets
# RUN --mount=type=secret,id=npm_token \
#     TOKEN=$(cat /run/secrets/npm_token) npm ci

Read-Only Root FS and Downscoped Caps: Reining In Runtime Changes

A read-only root filesystem (--read-only, or securityContext readOnlyRootFilesystem in K8s) makes it impossible for an attacker running in your container to write, tamper, or install; scratch paths park on tmpfs/emptyDir.

Capability-drop is the second lock: normal work never needs CAP_SYS_ADMIN or CAP_NET_ADMIN. Drop them in K8s via securityContext capabilities, or declare them on docker run.

Together they turn a vulnerable container from "writable and raisable" into "immutable and escape-limited", so even a hole in the code causes far less damage.

# docker run with read-only FS + dropped privileges
# docker run --read-only \
#   --tmpfs /tmp --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
#   -p 8080:3000 image:tag

# K8s style
# securityContext:
#   readOnlyRootFilesystem: true
#   runAsNonRoot: true
#   capabilities:
#     drop: ["ALL"]
#     add: ["NET_BIND_SERVICE"]

Verifying Hardening: Scanning, Probing, and the Fixed List

Hardening is only worth what it stops in a real attack. Run at least one image scan (trivy, grype, docker scan) to confirm no known high-severity CVE rides the newly built layer.

Probe read-only and dropped caps actively: launch a throwaway container and try to install a package or chmod a system path — failing to write proves read-only is on.

Finish with a "hardened items" checklist: non-root, readable-root, least capabilities, no plaintext secrets, cached build layers, scan clean. Tick each and leave a trace for audit.

# Scan the image
trivy image --severity HIGH,CRITICAL myapp:1.2.3

# Verify read-only works: expect the write to fail
# docker run --rm --read-only --tmpfs /tmp image:tag \
#   sh -c "touch /etc/foo && echo wrote"  # should report read-only file system

# Verify non-root works
# docker run --rm image:tag id
# -> uid=1000(appuser) gid=1000(appgrp)

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.