Securing Multi-Stage Docker Builds
Learn how to implement security best practices in multi-stage Docker builds, from source code to production images.
Multi-stage Docker builds are essential for creating secure, minimal production images. In this guide, we'll explore how to implement security throughout every stage of your Docker builds.
Why Multi-Stage Builds Matter for Security
Multi-stage builds provide several security benefits:
- Reduced Attack Surface: Final images contain only runtime dependencies
- Secret Management: Build secrets don't persist in final images
- Layer Optimization: Minimize the number of layers and their contents
- Compliance: Easier to audit and maintain smaller images
Basic Multi-Stage Security Pattern
Here's a secure multi-stage build template:
# Stage 1: Build environment with tools and source code
FROM node:18-slim AS builder
# Install security updates
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
# Create non-root user for build
RUN groupadd -g 1001 builder && useradd -r -u 1001 -g builder builder
USER builder
WORKDIR /app
# Copy package files first (better caching)
COPY --chown=builder:builder package*.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy source code
COPY --chown=builder:builder . .
# Build application
RUN npm run build
# Stage 2: Security scanning (optional but recommended)
FROM builder AS security-scan
USER root
# Install security tools
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Run security scans
RUN npm audit --audit-level high
RUN npx retire --path node_modules
# Stage 3: Runtime image
FROM node:18-slim AS runtime
# Install security updates
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appuser && useradd -r -u 1001 -g appuser appuser
WORKDIR /app
# Copy only production files
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appuser /app/package*.json ./
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/server.js"]
Advanced Security Patterns
1. Distroless Final Images
Use distroless images for maximum security:
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Distroless runtime
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/main /
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/main"]
2. Security Tool Integration
Integrate security tools in build stages:
# Build stage
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Security scanning stage
FROM builder AS security
RUN pip install safety bandit semgrep
# Run security scans
RUN safety check --json --output safety-report.json || true
RUN bandit -r . -f json -o bandit-report.json || true
RUN semgrep --config=auto --json --output=semgrep-report.json . || true
# SAST results can be copied to final stage or uploaded to security platform
# Production stage
FROM python:3.11-slim AS production
WORKDIR /app
# Install only runtime dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
pip cache purge && \
rm requirements.txt
# Copy application
COPY --from=builder /app .
# Remove development files
RUN find . -name "*.pyc" -delete && \
find . -name "__pycache__" -type d -exec rm -rf {} + || true
USER 1001:1001
CMD ["python", "app.py"]
3. Secret Management
Handle secrets securely across build stages:
# syntax=docker/dockerfile:1.4
FROM golang:1.21-alpine AS builder
# Use build secrets (don't persist in image)
RUN --mount=type=secret,id=github_token \
GITHUB_TOKEN=$(cat /run/secrets/github_token) && \
go mod download && \
rm -f /run/secrets/github_token
# Private registry authentication
RUN --mount=type=secret,id=registry_auth,target=/etc/docker/config.json \
docker pull private-registry.com/base-image:latest
WORKDIR /app
COPY . .
RUN go build -o app .
# Runtime stage - secrets not included
FROM alpine:3.18
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
USER 1001:1001
CMD ["./app"]
Build with secrets:
# Create secret files
echo "ghp_xxxxxxxxxxxx" | docker secret create github_token -
echo '{"auths":{"registry.com":{"auth":"base64auth"}}}' | docker secret create registry_auth -
# Build with secrets
docker build --secret id=github_token,src=./github_token \
--secret id=registry_auth,src=./docker_config.json \
-t myapp:secure .
Security Scanning Integration
Vulnerability Scanning Per Stage
FROM ubuntu:22.04 AS base
RUN apt-get update && apt-get upgrade -y
# Scan base image
FROM base AS base-scan
RUN apt-get install -y wget
RUN wget -qO - https://github.com/aquasecurity/trivy/releases/download/v0.46.0/trivy_0.46.0_Linux-64bit.deb
RUN dpkg -i trivy_0.46.0_Linux-64bit.deb
RUN trivy fs --format json --output base-scan.json /
FROM base AS build
# ... build steps ...
# Scan after build
FROM build AS build-scan
COPY --from=base-scan /usr/local/bin/trivy /usr/local/bin/
RUN trivy fs --format json --output build-scan.json /app
FROM base AS production
COPY --from=build /app/dist /app/
# ... production setup ...
SBOM Generation in Multi-Stage
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
# Generate SBOM for dependencies
FROM dependencies AS sbom-gen
RUN npx @cyclonedx/cyclonedx-npm --output-file deps-sbom.json
FROM node:18-alpine AS build
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Final stage with SBOM
FROM node:18-alpine AS production
WORKDIR /app
# Copy SBOM
COPY --from=sbom-gen /app/deps-sbom.json ./sbom.json
# Copy production assets
COPY --from=build /app/dist ./dist
COPY --from=dependencies /app/node_modules ./node_modules
USER 1001:1001
CMD ["node", "dist/server.js"]
Performance vs Security Trade-offs
Parallel Security Stages
Use multi-stage builds to run security checks in parallel:
FROM golang:1.21 AS base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Security scan stage (runs in parallel)
FROM base AS security
RUN go install github.com/securecodewarrior/docker-security-scanner@latest
RUN docker-security-scanner scan .
# Lint stage (runs in parallel)
FROM base AS lint
RUN go install golang.org/x/lint/golint@latest
RUN golint ./...
# Test stage (runs in parallel)
FROM base AS test
RUN go test -v ./...
# Build stage
FROM base AS build
RUN CGO_ENABLED=0 go build -o app .
# Final stage - wait for all security checks
FROM scratch AS final
COPY --from=build /app/app /app
COPY --from=security /app/security-report.json /reports/
COPY --from=lint /app/lint-report.txt /reports/
USER 65534:65534
ENTRYPOINT ["/app"]
Build with BuildKit for parallel execution:
# Enable BuildKit
export DOCKER_BUILDKIT=1
# Build with parallel stages
docker build -t myapp:secure .
Build-time Security Policies
Using OPA for Build Policies
Create build policies with Open Policy Agent:
# Policy validation stage
FROM openpolicyagent/opa:latest AS policy-check
COPY build-policy.rego /policy/
COPY image-manifest.json /data/
# Validate build against policy
RUN opa eval -d /policy -d /data "data.build.allow" || exit 1
Build policy example:
# build-policy.rego
package build
import rego.v1
# Deny if running as root
deny[msg] if {
input.config.User == "root"
msg := "Container must not run as root"
}
# Deny if using latest tag
deny[msg] if {
contains(input.config.Image, ":latest")
msg := "Must use specific version tags, not 'latest'"
}
# Require health check
deny[msg] if {
not input.config.Healthcheck
msg := "Container must include health check"
}
allow if {
count(deny) == 0
}
CI/CD Integration
GitHub Actions with Multi-Stage Security
name: Secure Multi-Stage Build
on: [push, pull_request]
jobs:
security-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build security scan stage
uses: docker/build-push-action@v5
with:
context: .
target: security-scan
load: true
tags: myapp:security-scan
- name: Extract security reports
run: |
docker create --name temp myapp:security-scan
docker cp temp:/app/security-report.json ./
docker rm temp
- name: Upload security report
uses: actions/upload-artifact@v4
with:
name: security-report
path: security-report.json
- name: Build production image
uses: docker/build-push-action@v5
with:
context: .
target: production
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Sign image
run: |
cosign sign --yes ghcr.io/${{ github.repository }}:${{ github.sha }}
Monitoring Multi-Stage Builds
Build Metrics and Security
Track security metrics across build stages:
#!/bin/bash
# build-metrics.sh
IMAGE_NAME="$1"
BUILD_LOG="build.log"
echo "Analyzing multi-stage build security..."
# Extract stage information
docker history "$IMAGE_NAME" --format "table {{.CreatedBy}}\t{{.Size}}" > stage-info.txt
# Count layers per stage
echo "Stage layer counts:"
grep -c "RUN" Dockerfile
# Check for security tools usage
echo "Security tools detected:"
grep -E "(trivy|grype|clair|anchore)" "$BUILD_LOG" || echo "None found"
# Validate final image
echo "Final image analysis:"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --format json "$IMAGE_NAME" > final-scan.json
# Extract critical vulnerabilities
jq '.Results[] | select(.Class == "os-pkgs") | .Vulnerabilities[] | select(.Severity == "CRITICAL")' final-scan.json
Best Practices Summary
- Minimize Final Stage: Only include runtime dependencies
- Use Specific Tags: Avoid
latest
tags for base images - Security Scanning: Integrate scanning in dedicated stages
- Secret Management: Use build secrets, not environment variables
- User Management: Run as non-root user in final stage
- Health Checks: Include application health checks
- SBOM Generation: Generate and include software bills of materials
- Policy Validation: Validate builds against security policies
Troubleshooting
Common Multi-Stage Security Issues
# Debug stage outputs
docker build --target=security-scan -t debug:security .
docker run --rm debug:security cat /app/scan-results.json
# Check file permissions between stages
docker build --target=build -t debug:build .
docker run --rm debug:build ls -la /app
# Verify user context
docker build --target=production -t debug:prod .
docker run --rm debug:prod id
Multi-stage builds are a powerful tool for creating secure, minimal container images. By implementing security at each stage, you can catch vulnerabilities early and ensure your production images meet security requirements.
For more advanced multi-stage security patterns and comprehensive container security strategies, check out "Docker & Kubernetes Security" - the complete guide to securing your containerized applications.