Container Security: Hardening Docker Images for Production
A default Docker container runs as root, includes a full OS with hundreds of packages, and has no resource limits — every one of these defaults is a security vulnerability. Container security hardening reduces your attack surface from thousands of potential exploits to a handful. Therefore, this guide covers practical techniques for building minimal, rootless, scanned, and signed container images that meet production security requirements.
Multi-Stage Builds: Ship Only What You Need
A typical Node.js development image includes npm, build tools, dev dependencies, and source code — none of which are needed at runtime. Multi-stage builds use one stage for compilation and a separate stage for the final image containing only the runtime binary and its dependencies. Moreover, this reduces image size from 1GB+ to 50-200MB, cutting both storage costs and the attack surface.
Each unused package in your image is a potential vulnerability. A full Ubuntu base image has 400+ packages, most of which your application never touches. However, scanners flag every CVE in every installed package, creating alert fatigue. By shipping only what your application needs, you eliminate most vulnerabilities without patching anything.
# Multi-stage build: Node.js application
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
cp -r node_modules production_modules && \
npm ci # Install all deps for build
COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy only production artifacts
COPY --from=builder /app/production_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Set ownership and switch to non-root
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]For compiled languages like Go or Rust, the final image can be scratch (empty) or distroless, containing nothing but the static binary. Specifically, a Go API server in a scratch image is 10-20MB with zero OS packages to scan.
Rootless Containers: Principle of Least Privilege
Running containers as root means any container escape gives the attacker root on the host. Always create a dedicated user in your Dockerfile and switch to it with the USER directive. Additionally, use --read-only filesystem and drop all Linux capabilities except the few your application actually needs.
Some applications legitimately need specific capabilities — a web server binding to port 80 needs NET_BIND_SERVICE. Grant only those specific capabilities instead of running as root. Furthermore, Kubernetes PodSecurityStandards enforce these constraints cluster-wide, preventing anyone from deploying a privileged container.
# Kubernetes security context: non-root, read-only, minimal capabilities
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-api
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: myregistry/api:v1.2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/.cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: { sizeLimit: 100Mi }Image Scanning: Catching Vulnerabilities Before Deployment
Image scanning tools like Trivy, Grype, and Snyk Container analyze every layer of your Docker image against CVE databases. Integrate scanning into your CI pipeline so vulnerabilities are caught before images reach your registry. Consequently, your production environment only runs images that pass your security threshold.
Set clear policies: block critical and high vulnerabilities, alert on medium, and track low. However, not every CVE is exploitable in your context — a vulnerability in curl matters only if your container calls curl. Use VEX (Vulnerability Exploitability eXchange) statements to document false positives and reduce alert fatigue.
# Scan image with Trivy (comprehensive scanner)
trivy image --severity CRITICAL,HIGH myapp:latest
# Scan with Grype (fast, focused on containers)
grype myapp:latest --fail-on critical
# Scan Dockerfile for misconfigurations
trivy config --severity HIGH,CRITICAL .
# Generate SBOM and scan together
syft myapp:latest -o cyclonedx-json > sbom.json
grype sbom:./sbom.json --fail-on highDistroless Images: Minimal Attack Surface
Google’s distroless images contain only your application and its runtime dependencies — no shell, no package manager, no utilities. If an attacker gets code execution inside a distroless container, they can’t run bash, curl, wget, or any reconnaissance tools. This makes exploitation significantly harder.
For Java applications, use gcr.io/distroless/java21-debian12. For Python, use gcr.io/distroless/python3-debian12. For Node.js, use gcr.io/distroless/nodejs20-debian12. The trade-off is debugging difficulty — you can’t shell into the container to diagnose issues. As a result, invest in proper logging, metrics, and distributed tracing before adopting distroless images.
# Go application with distroless (or scratch) final image
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
# Distroless: no shell, no package manager, no attack surface
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]Supply Chain Security: Signing and Provenance
Build minimal images, scan them, and then sign them with Sigstore cosign to ensure they haven’t been tampered with between your CI pipeline and production. Configure your Kubernetes cluster to reject unsigned images using admission controllers like Kyverno or OPA Gatekeeper. Additionally, generate SBOMs for every image so you can quickly check if a new CVE affects your deployed containers.
Pin your base images to digests, not tags. A tag like node:20-alpine can point to different images over time. A digest like node@sha256:abc123... is immutable. For example, a compromised base image tag could inject malicious code into every image you build. Digest pinning prevents this because the content hash must match exactly.
Related Reading:
- Supply Chain Security for CI/CD Pipelines
- Docker Compose to Kubernetes Migration
- WAF Rules and OWASP Top 10 Protection
Resources:
In conclusion, container security hardening is a layered approach: multi-stage builds minimize image size, rootless execution limits privilege, image scanning catches known vulnerabilities, and distroless base images remove entire attack categories. Start with multi-stage builds and non-root users — these two changes alone eliminate the majority of container security risks. Add scanning in CI and image signing as your security posture matures.