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 ImageSizeTypical CVE Count
ubuntu:22.04~80MB50-200
debian:12-slim~25MB10-50
alpine:3.19~7MB0-10
distroless~3MB0-5
scratch0MB0

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.