Best Practices

Container Image Hardening Checklist

A comprehensive checklist for hardening your container images, from base image selection to runtime protections, with practical Dockerfile examples.

Bob
Cloud Security Engineer
6 min read

Running containers in production without hardening them is like deploying a server with default passwords. It works until it does not. Container images ship with unnecessary packages, run as root by default, and often include debug tools that attackers love. This checklist covers the practical steps to lock them down.

I have organized this by impact. Start at the top and work your way down. Each item represents a real attack surface I have seen exploited or flagged in penetration tests.

Base Image Selection

Use minimal base images. Alpine, distroless, or scratch images have a fraction of the attack surface compared to full Ubuntu or Debian images.

# Instead of this
FROM ubuntu:22.04

# Use this
FROM alpine:3.18

# Or for maximum minimalism (Go, Rust, etc.)
FROM gcr.io/distroless/static-debian12

Alpine images are typically 5-10 MB compared to 70+ MB for Ubuntu. Fewer packages means fewer potential vulnerabilities.

Pin image versions with digests. Tags are mutable. Someone can push a compromised image to the latest tag. Digests are immutable.

# Mutable tag - risky
FROM node:18-alpine

# Immutable digest - safe
FROM node:18-alpine@sha256:a1f9d4202f...

Scan your base image before building on top of it. Run Trivy or Grype against your base image as the first step in your pipeline. If the foundation has critical CVEs, everything built on top inherits them.

Multi-Stage Builds

Always use multi-stage builds. Your build environment has compilers, package managers, and source code that should never reach production.

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

# Production stage
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/index.js"]

The production image only contains the compiled output and runtime dependencies. No TypeScript source, no devDependencies, no build tools.

User Configuration

Never run as root. This is the single most impactful change you can make.

FROM node:18-alpine

RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

WORKDIR /app
COPY --chown=appuser:appgroup . .

USER appuser
CMD ["node", "index.js"]

If an attacker breaks out of your application, they land as an unprivileged user instead of root. This blocks the majority of container escape techniques.

Set the filesystem to read-only where possible. At runtime, use the --read-only flag. For paths that need writes, use tmpfs mounts:

docker run --read-only --tmpfs /tmp:rw,noexec,nosuid myapp:latest

Package Management

Remove package managers after installation. If attackers gain shell access, they cannot install tools:

FROM alpine:3.18
RUN apk add --no-cache nodejs npm && \
    npm install --production && \
    apk del npm && \
    rm -rf /var/cache/apk/*

Do not install unnecessary packages. Every extra package is a potential vulnerability and a potential tool for attackers. Question every apt-get install or apk add in your Dockerfile.

Update packages during build. Pin to the latest patched versions:

RUN apk update && apk upgrade --no-cache

Secrets Management

Never bake secrets into images. Not in environment variables, not in files, not in build arguments that persist in layer history.

# NEVER do this
ENV DATABASE_PASSWORD=supersecret
COPY .env /app/.env

# Instead, pass secrets at runtime
# Or use Docker BuildKit secrets for build-time needs
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install

Scan for secrets in your images. Tools like TruffleHog and detect-secrets can scan image layers for accidentally committed credentials.

Network and Port Exposure

Only expose necessary ports.

# Expose only what your app needs
EXPOSE 8080

Drop all capabilities and add back only what you need:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp:latest

The default Docker capability set includes things like SYS_CHROOT, SETUID, and KILL that most applications never need.

File System Hardening

Use .dockerignore aggressively. Keep secrets, git history, and development files out of the build context:

.git
.env
*.md
docker-compose*.yml
.github
tests
__pycache__
node_modules
.npm

Set appropriate file permissions:

COPY --chmod=444 config.json /app/config.json
COPY --chmod=555 entrypoint.sh /app/entrypoint.sh

Files should be readable (444) and scripts should be executable (555) but not writable by the container process.

Health Checks and Metadata

Define health checks in the Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

Add labels for traceability:

LABEL maintainer="security-team@company.com"
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="1.2.3"

Scanning and Validation

Scan in CI before pushing to registry:

- name: Scan image
  run: |
    trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:${{ github.sha }}

Lint your Dockerfiles:

hadolint Dockerfile

Hadolint catches common mistakes like using apt-get without --no-install-recommends, missing version pins, and running as root.

Sign your images. Use cosign to create a verifiable chain of trust:

cosign sign --key cosign.key myregistry.io/myapp:latest

Then verify signatures before deployment to ensure images have not been tampered with.

Runtime Protections

Use seccomp profiles to restrict system calls:

docker run --security-opt seccomp=custom-profile.json myapp:latest

Enable AppArmor or SELinux for mandatory access control at the host level.

Set resource limits to prevent denial-of-service:

docker run --memory=512m --cpus=1.0 --pids-limit=100 myapp:latest

The Quick Reference Checklist

  • [ ] Minimal base image (Alpine, distroless, or scratch)
  • [ ] Image version pinned by digest
  • [ ] Multi-stage build separating build and runtime
  • [ ] Non-root user configured
  • [ ] Read-only filesystem where possible
  • [ ] Package managers removed
  • [ ] No secrets in image layers
  • [ ] Only necessary ports exposed
  • [ ] Capabilities dropped
  • [ ] .dockerignore configured
  • [ ] Health check defined
  • [ ] Image scanned in CI
  • [ ] Dockerfile linted
  • [ ] Image signed
  • [ ] Resource limits set at runtime

How Safeguard.sh Helps

Safeguard.sh scans your container images as part of your build pipeline, identifying vulnerabilities in both OS packages and application dependencies. It tracks your container inventory, flags images running with known CVEs, and enforces policies like "no critical vulnerabilities in production images." If a new vulnerability is published that affects a base image used across dozens of services, Safeguard.sh tells you exactly which images need rebuilding and helps you prioritize by exposure and severity.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.