Every container in production started as a Dockerfile. If that Dockerfile pulls an unpatched base image, runs as root, or leaks secrets into image layers, you have a vulnerability that propagates to every deployment. Container security starts at build time, not at runtime.
This guide covers the practical steps to harden your Dockerfiles — from choosing base images to signing your supply chain. Every recommendation includes a before-and-after example you can apply to your own images today.
Containers feel isolated, but a misconfigured Dockerfile can undermine that isolation:
The good news: most of these risks are eliminated by following a handful of Dockerfile patterns.
The simplest way to reduce your attack surface is to start with less. Fewer packages mean fewer CVEs, smaller images, and faster deployments.
| Base Image | Size | Packages | Shell | Package Manager | Use Case |
|---|---|---|---|---|---|
ubuntu:24.04 | ~78 MB | ~400 | bash | apt | Development, debugging |
debian:bookworm-slim | ~52 MB | ~100 | bash | apt | General production |
alpine:3.21 | ~7 MB | ~15 | sh | apk | Minimal production |
gcr.io/distroless/static | ~2 MB | 0 | none | none | Static binaries (Go, Rust) |
scratch | 0 MB | 0 | none | none | Fully static binaries |
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 python3-pip
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
CMD ["python3", "app.py"]
Problems: ubuntu:latest is 78 MB with 400+ packages, :latest tag is mutable, installs full pip toolchain.
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
USER 1000
CMD ["python", "app.py"]
Result: Smaller image, no build tools in production, non-root user.
Tip: For Go and Rust applications that compile to static binaries, use
scratchordistroless/static— your final image contains nothing but the binary. Use the Dockerfile Generator to scaffold multi-stage builds for any language.
By default, Docker containers run as root (UID 0). This means if an attacker exploits your application, they have root privileges inside the container — and potentially on the host if a container escape vulnerability exists.
# Create a dedicated app user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
# Set ownership
COPY --chown=appuser:appuser . /app
# Switch to non-root
USER appuser
CMD ["./app"]
Always prefer numeric UIDs over usernames. Kubernetes security policies and some container runtimes validate numeric IDs:
# Preferred: numeric UID
USER 1000:1000
# Also valid but less portable
USER appuser
Alpine uses addgroup/adduser instead of groupadd/useradd:
FROM alpine:3.21
RUN addgroup -S appuser && adduser -S -G appuser -h /app appuser
COPY --chown=appuser:appuser . /app
USER appuser
A common mistake is switching to a non-root user but leaving files owned by root:
# Bad: files owned by root, app can't write logs
COPY . /app
USER 1000
# App fails with "Permission denied" writing to /app/logs/
# Good: set ownership before switching user
COPY --chown=1000:1000 . /app
RUN mkdir -p /app/logs && chown 1000:1000 /app/logs
USER 1000
Tip: The Dockerfile Linter flags Dockerfiles that never set a
USERdirective — catch this before it reaches production.
Multi-stage builds are not just about image size. They are a security boundary that prevents build-time dependencies and tools from reaching your production image.
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app
# Stage 2: Production (from scratch — nothing but the binary)
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app /app
USER 1000
ENTRYPOINT ["/app"]
The final image contains only the static binary and CA certificates. No shell, no package manager, no OS utilities. An attacker who compromises the application has almost nothing to work with.
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:22-alpine
RUN addgroup -S nodejs && adduser -S -G nodejs nextjs
WORKDIR /app
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
USER nextjs
EXPOSE 3000
CMD ["node", "dist/index.js"]
Key points: development dependencies (devDependencies) never enter the production image. Build tools like TypeScript, webpack, and test frameworks stay in the builder stage.
Using :latest means your build pulls whatever the current image happens to be. A new base image release could introduce breaking changes or — worse — a compromised image.
# Bad: mutable, unpredictable
FROM node:latest
# Better: major.minor pin
FROM node:22-alpine
# Best: digest pin (immutable, reproducible)
FROM node:22-alpine@sha256:6e80991f69cc7722c561e5d14d3e2e...
# Pull the image and show its digest
docker pull node:22-alpine
docker inspect --format='{{index .RepoDigests 0}}' node:22-alpine
# Or use crane (no Docker daemon needed)
crane digest node:22-alpine
Pin to digests for reproducibility, but automate updates to avoid falling behind on security patches:
# Renovate Bot config (renovate.json)
{
"packageRules": [
{
"matchDatasources": ["docker"],
"matchUpdateTypes": ["digest"],
"automerge": true,
"schedule": ["every weekday"]
}
]
}
Renovate and Dependabot can automatically open pull requests when base image digests change, keeping you patched without manual intervention.
Secrets in Dockerfiles are the most dangerous anti-pattern. Every COPY, ADD, and RUN command creates a new image layer, and layers are permanent — even if you delete the file in a subsequent layer.
# DANGEROUS: Secret is permanently in image layers
COPY .env /app/.env
RUN source /app/.env && ./setup.sh
RUN rm /app/.env # This does NOT remove it from earlier layers!
# DANGEROUS: Secret visible in image history
ARG DB_PASSWORD=supersecret
RUN echo "password=$DB_PASSWORD" > /app/config
Anyone with access to the image can extract secrets with docker history or by inspecting layers directly.
Docker BuildKit provides a purpose-built mechanism for build-time secrets. The secret is mounted into the build step but never written to a layer:
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
# Secret is mounted at build time, never persisted in layers
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci --only=production
COPY . .
USER 1000
CMD ["node", "index.js"]
Build with:
docker build --secret id=npmrc,src=.npmrc -t myapp .
If you cannot use BuildKit, multi-stage builds provide a secondary defense:
# Stage 1: Use secrets for authentication
FROM node:22-alpine AS builder
WORKDIR /app
COPY .npmrc package.json package-lock.json ./
RUN npm ci
RUN rm -f .npmrc
# Stage 2: No secrets present
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER 1000
CMD ["node", "index.js"]
The .npmrc exists in the builder stage layers, but the final image starts fresh from node:22-alpine and only copies the node_modules directory.
Prevent secrets from being sent to the build context in the first place:
# .dockerignore
.env
.env.*
*.pem
*.key
.git
.ssh
credentials.json
aws-credentials
Tip: Use the Secrets Scanner to audit your codebase and Dockerfiles for accidentally committed credentials — it detects 41 secret patterns including AWS keys, private keys, and API tokens.
Every package you install is a potential vulnerability. Production containers should contain only what the application needs to run — nothing more.
# Bad: installs curl, wget, vim for "debugging" — each is an attack tool
RUN apt-get update && apt-get install -y \
python3 curl wget vim net-tools \
&& rm -rf /var/lib/apt/lists/*
# Good: only the runtime dependency
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
&& rm -rf /var/lib/apt/lists/*
Key flags:
--no-install-recommends prevents apt from pulling in suggested packagesrm -rf /var/lib/apt/lists/* cleans the apt cache (saves 20-40 MB)If an attacker compromises your application, curl and wget let them download additional tools, exfiltrate data, or establish reverse shells. Removing them eliminates an entire class of post-exploitation techniques.
If you need to download files during the build, do it in a builder stage and leave the tools behind:
FROM debian:bookworm-slim AS builder
RUN apt-get update && apt-get install -y curl
RUN curl -fsSL https://example.com/binary -o /binary
FROM debian:bookworm-slim
COPY --from=builder /binary /usr/local/bin/binary
USER 1000
CMD ["/usr/local/bin/binary"]
Each RUN instruction creates a new layer. Combine related operations to reduce layers and avoid leaving intermediate state:
# Bad: 3 layers, apt cache persists in layer 1
RUN apt-get update
RUN apt-get install -y python3
RUN rm -rf /var/lib/apt/lists/*
# Good: 1 layer, clean state
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/*
Even if your Dockerfile follows every best practice, your base image and dependencies may contain known vulnerabilities. Image scanning detects CVEs in OS packages and application libraries.
Trivy is open-source, fast, and covers OS packages, language-specific dependencies, and IaC misconfigurations:
# Scan a local image
trivy image myapp:latest
# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
# Scan and fail CI if critical CVEs found
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Scan a Dockerfile (IaC mode)
trivy config Dockerfile
Grype from Anchore is another excellent scanner with SBOM integration:
# Scan an image
grype myapp:latest
# Output as JSON for CI processing
grype myapp:latest -o json > scan-results.json
# Only show fixable vulnerabilities
grype myapp:latest --only-fixed
Integrate scanning into your pipeline so vulnerable images never reach production:
# GitHub Actions example
- name: Build image
run: docker build -t myapp:ci .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:ci
exit-code: 1
severity: HIGH,CRITICAL
format: table
# GitLab CI example
scan:
stage: test
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:ci
Tip: Use the Dockerfile Linter to catch security anti-patterns before you even build the image — it flags running as root, unpinned versions, and exposed secrets.
A read-only root filesystem prevents an attacker from modifying application binaries, writing malicious scripts, or tampering with configuration files inside a running container.
# Read-only root filesystem with writable /tmp and /app/logs
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid \
--tmpfs /app/logs:rw,noexec,nosuid \
myapp:latest
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid
- /app/logs:rw,noexec,nosuid
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Common writable paths your app might need:
/tmp — temporary files, most frameworks expect this/app/logs — application logs (better: log to stdout)/var/cache — cached dataTest with --read-only locally before deploying. If the container crashes, check the error log for "read-only file system" messages and add targeted tmpfs mounts.
Knowing where your images come from — and verifying they have not been tampered with — is critical for production deployments.
DCT uses Notary to cryptographically sign images. When enabled, Docker only pulls images that have valid signatures:
# Enable DCT globally
export DOCKER_CONTENT_TRUST=1
# Pull only signed images
docker pull myregistry/myapp:v1.0
# Sign and push an image
docker push myregistry/myapp:v1.0 # Automatically signs when DCT is enabled
Cosign is the modern approach to container signing, part of the Sigstore project. It supports keyless signing with OIDC identity:
# Sign an image (keyless, uses OIDC)
cosign sign myregistry/myapp@sha256:abc123...
# Verify a signature
cosign verify myregistry/myapp@sha256:abc123... \
[email protected] \
--certificate-oidc-issuer=https://accounts.google.com
# Sign with a key pair (air-gapped environments)
cosign generate-key-pair
cosign sign --key cosign.key myregistry/myapp@sha256:abc123...
cosign verify --key cosign.pub myregistry/myapp@sha256:abc123...
An SBOM lists every component inside your container image — OS packages, language libraries, and their versions. When a new CVE drops, you can instantly check if any of your images are affected.
# Generate SBOM with Trivy
trivy image --format spdx-json -o sbom.json myapp:latest
# Generate SBOM with Syft
syft myapp:latest -o spdx-json > sbom.json
# Attach SBOM to image with Cosign
cosign attach sbom --sbom sbom.json myregistry/myapp@sha256:abc123...
Combine signing and scanning in your CI/CD pipeline for end-to-end supply chain security:
# Build → Scan → Sign → Push
docker build -t myregistry/myapp:v1.0 .
trivy image --exit-code 1 --severity CRITICAL myregistry/myapp:v1.0
docker push myregistry/myapp:v1.0
cosign sign myregistry/myapp@sha256:$(docker inspect --format='{{index .RepoDigests 0}}' myregistry/myapp:v1.0 | cut -d@ -f2)
Use this quick reference when writing or reviewing Dockerfiles:
:latestUSER directive (numeric UID preferred)--chownRUN commands to minimize layers--no-install-recommends for aptCOPY or ADD secret filesARG for secrets--mount=type=secret for build-time secrets.dockerignore--read-only filesystemtmpfs mounts for writable pathsDockerfile security is not a single practice — it is a layered defense across your entire build pipeline. Start with the highest-impact changes:
Each layer you add makes exploitation harder. An attacker who faces a distroless, non-root, read-only container with no shell and no network tools has very few options — even if they find an application-level vulnerability.
For hands-on practice with these techniques: