Container Image Hardening Best Practices
Hardening container images reduces attack surface, limits blast radius, and ensures that production containers run with the minimum permissions and packages needed. This guide covers minimal base images, multi-stage builds, non-root users, read-only filesystems, secret handling, and security scanning integration.
Prerequisites
- Docker Engine 24+ or Podman 4+
- Linux server (Ubuntu 22.04/Debian 12 or CentOS/Rocky 9)
- Basic understanding of Dockerfiles
- Trivy or another scanner for vulnerability testing
Choose Minimal Base Images
The fewer packages in your base image, the fewer CVEs you inherit:
# BAD: Full Debian/Ubuntu - hundreds of packages, many CVEs
FROM ubuntu:22.04
# BETTER: Debian Slim - reduced package set
FROM debian:12-slim
# BETTER: Alpine Linux - ~5MB, musl libc, minimal attack surface
FROM alpine:3.19
# BEST for Go/Rust: Distroless - no shell, no package manager
FROM gcr.io/distroless/static-debian12
# BEST for Java: Distroless Java
FROM gcr.io/distroless/java17-debian12
# BEST for compiled binaries: scratch (empty, zero-byte base)
FROM scratch
Image size and CVE count comparison for a simple web app:
| Base Image | Size | Typical CVE Count |
|---|---|---|
| ubuntu:22.04 | ~80MB | 50-200 |
| debian:12-slim | ~25MB | 10-50 |
| alpine:3.19 | ~7MB | 0-10 |
| distroless | ~3MB | 0-5 |
| scratch | 0MB | 0 |
Multi-Stage Builds
Multi-stage builds separate the build environment from the runtime image, preventing build tools and source code from ending up in the final image:
# Build stage - has all build tools
FROM golang:1.22-alpine AS builder
WORKDIR /build
# Copy dependency files first (better layer caching)
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /app/server ./cmd/server
# Final stage - minimal runtime image
FROM gcr.io/distroless/static-debian12
# Copy only the compiled binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Example for a Node.js application:
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy only production dependencies and build output
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# Remove yarn cache and unnecessary files
RUN rm -rf /tmp/* && \
find /app/node_modules -name "*.md" -delete && \
find /app/node_modules -name "*.ts" -delete
EXPOSE 3000
CMD ["node", "dist/index.js"]
Python example with distroless:
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Final stage
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
COPY src/ /app/src/
WORKDIR /app
CMD ["src/main.py"]
Run as Non-Root User
Containers running as root are a significant security risk. Always specify a non-root user:
FROM alpine:3.19
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy files with correct ownership
COPY --chown=appuser:appgroup . .
# Install dependencies as root, then switch
RUN apk add --no-cache ca-certificates && \
chown -R appuser:appgroup /app
# Switch to non-root user for the rest
USER appuser
EXPOSE 8080
CMD ["/app/server"]
For distroless images, use the nonroot variant:
FROM gcr.io/distroless/static-debian12:nonroot
COPY --chown=nonroot:nonroot ./server /server
USER nonroot
ENTRYPOINT ["/server"]
Verify at runtime:
# Check what user the container runs as
docker run --rm myapp:latest id
# Expected: uid=65532(nonroot) gid=65532(nonroot)
# Run-time override to force non-root
docker run --user 1000:1000 myapp:latest
Read-Only Filesystems and Dropped Capabilities
FROM alpine:3.19
RUN adduser -S -D -H -u 1001 appuser
COPY ./server /usr/local/bin/server
USER 1001
# Use /tmp for temporary files (writable even with read-only root FS)
VOLUME ["/tmp"]
EXPOSE 8080
CMD ["/usr/local/bin/server"]
Run with hardened Docker flags:
docker run \
--read-only \ # Read-only root filesystem
--tmpfs /tmp:rw,noexec,nosuid,size=64m \ # Writable /tmp in memory
--cap-drop ALL \ # Drop all Linux capabilities
--cap-add NET_BIND_SERVICE \ # Add only what's needed
--security-opt no-new-privileges=true \ # No privilege escalation
--security-opt seccomp=/etc/docker/seccomp/default.json \
--user 1001:1001 \
myapp:latest
In Kubernetes, use securityContext:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: myapp
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
volumeMounts:
- name: tmp-dir
mountPath: /tmp
volumes:
- name: tmp-dir
emptyDir: {}
Minimize Layers and Package Footprint
Combine RUN commands to reduce image layers and clean up in the same step:
# BAD: Creates multiple layers, apt cache remains in image
RUN apt-get update
RUN apt-get install -y curl wget
RUN rm -rf /var/lib/apt/lists/*
# GOOD: Single layer, cache cleaned in same RUN
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Alpine: Even smaller installs
RUN apk add --no-cache curl ca-certificates
# Remove tools after use in the same RUN
RUN apk add --no-cache --virtual .build-deps \
gcc \
musl-dev && \
pip install --no-cache-dir -r requirements.txt && \
apk del .build-deps # Remove build tools after compilation
Use .dockerignore to prevent unnecessary files from entering the build context:
cat > .dockerignore << 'EOF'
.git
.gitignore
.github
*.md
docs/
tests/
Makefile
.env
.env.local
*.log
node_modules/
__pycache__/
*.pyc
.coverage
.pytest_cache/
.DS_Store
EOF
Secret Handling in Container Images
Never bake secrets into container images. Use Docker BuildKit secrets instead:
# BAD: Secret stored in image layer
FROM alpine
RUN curl -H "Authorization: Bearer mysecret" https://api.example.com/setup
# GOOD: Use BuildKit secret (never stored in image)
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=api_token \
curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" \
https://api.example.com/setup
# Build with BuildKit secret
docker buildx build \
--secret id=api_token,src=./secrets/api_token.txt \
-t myapp:latest .
For runtime secrets, use environment variables or volume mounts (not build args):
# NEVER use --build-arg for secrets (stored in image history)
# docker build --build-arg DB_PASSWORD=secret . # BAD
# GOOD: Pass secrets at runtime only
docker run -e DB_PASSWORD="$(vault kv get -field=password secret/db)" myapp:latest
# GOOD: Mount secrets file at runtime
docker run -v /run/secrets/db-password:/run/secrets/db-password:ro myapp:latest
Scan for accidentally included secrets:
# Trivy secret scan
trivy image --scanners secret myapp:latest
# Check git history for secrets
git log --all --full-history -- '*.env'
Integrate Security Scanning
Add vulnerability scanning to your container build pipeline:
# Makefile targets for secure builds
cat > Makefile << 'EOF'
IMAGE_NAME = myapp
IMAGE_TAG = $(shell git rev-parse --short HEAD)
.PHONY: build scan push
build:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(IMAGE_NAME):$(IMAGE_TAG) \
-t $(IMAGE_NAME):latest \
--load .
scan: build
# Fail if any HIGH or CRITICAL CVEs found
trivy image --exit-code 1 --severity HIGH,CRITICAL \
--ignore-unfixed $(IMAGE_NAME):$(IMAGE_TAG)
push: scan
docker push $(IMAGE_NAME):$(IMAGE_TAG)
docker push $(IMAGE_NAME):latest
EOF
Common Issues
Distroless image won't start - missing shared libraries:
# Check what libraries your binary needs
ldd ./server
# For Alpine builds, ensure the binary is statically compiled
# For Go:
CGO_ENABLED=0 go build -o server .
# Use the debug variant for troubleshooting (includes busybox shell)
FROM gcr.io/distroless/static-debian12:debug
# Then: docker run --rm --entrypoint=sh myapp:latest
Container can't write to filesystem:
# Add specific writable directories as tmpfs or emptyDir volumes
# Don't disable read-only just to fix permission issues
# Find what directories the app writes to:
strace -e trace=openat,write -p $(pgrep myapp) 2>&1 | grep O_WRONLY
Build fails when running as non-root:
# Check file permissions in the Dockerfile
RUN chown -R appuser:appgroup /app && \
chmod -R 755 /app
# Or use --chown with COPY
COPY --chown=appuser:appgroup ./app /app
Conclusion
Container image hardening is a layered process: start with a minimal base image, use multi-stage builds to separate build and runtime environments, run as a non-root user, and enforce read-only filesystems in production. These practices, combined with automated vulnerability scanning in the CI/CD pipeline, dramatically reduce the attack surface of your containerized applications. The most impactful single change is switching from ubuntu:22.04 to alpine or distroless as your base image, which typically eliminates 80-90% of detected CVEs.


