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

# 创建非 root 用户并给到主目录
RUN addgroup -S appgrp && adduser -S appuser -G appgrp

# 拷贝后修正属主,避免启动时无权写缓存目录
COPY --chown=appuser:appgrp . /app

# 切换到非 root
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 至少包含: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.

# 错误示范:密钥打包进镜像
# ENV DATABASE_PASSWORD=supersecret123

# 修复:运行时注入
# docker run -e DATABASE_PASSWORD=$DB_PASS -e DATABASE_USER=$DB_USER image:tag

# 或在 compose 中
#   api:
#     env_file:
#       - .env.production   # 不入库

# 构建期 secret 用 BuildKit
# 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 只读 + 降权
# docker run --read-only \
#   --tmpfs /tmp --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
#   -p 8080:3000 image:tag

# K8s 风格
# 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.

# 镜像扫描
trivy image --severity HIGH,CRITICAL myapp:1.2.3

# 验证只读生效:期望写入失败
# docker run --rm --read-only --tmpfs /tmp image:tag \
#   sh -c "touch /etc/foo && echo wrote"  # 应报 read-only file system

# 验证非 root 生效
# 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.