Post

Best Practices for Dockerfile Multi-Stage Builds

Learn best practices for Dockerfile multi-stage builds: reduce image size, optimize caching, secure build stages, and improve build performance.

Best Practices for Dockerfile Multi-Stage Builds

Secure Docker Multi-Stage Builds Guide

A comprehensive security-focused guide for implementing multi-stage builds in Docker.

Multi-stage builds are a powerful feature in Docker that allow you to create smaller, more secure container images by separating the build environment from the runtime environment. This guide covers best practices for implementing multi-stage builds with a strong focus on security considerations.

1. Table of Contents

2. Introduction to Multi-Stage Builds

Multi-stage builds were introduced in Docker 17.05 to address common challenges in containerization: bloated images, security vulnerabilities, and complex build processes. By using multiple stages within a single Dockerfile, you can separate build-time dependencies from runtime dependencies, resulting in smaller and more secure container images.

2.1 Security Benefits

Multi-stage builds enhance security by:

  • Reducing the attack surface by eliminating build tools and dependencies from the final image
  • Minimizing the number of vulnerable packages in your runtime environment
  • Facilitating separation of concerns between build and runtime environments
  • Enabling more granular control over what gets included in the final image

2.2 Performance Advantages

Beyond security, multi-stage builds provide:

  • Smaller image sizes, reducing storage costs and network transfer times
  • Faster container startup times
  • Improved caching for more efficient rebuilds
  • Simplified CI/CD pipelines

3. Docker Installation and Verification

Before implementing multi-stage builds, you need a properly installed and configured Docker environment. The following are secure installation procedures for various platforms.

3.1 Linux Installation

For Debian/Ubuntu systems:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Remove old versions if present
sudo apt-get remove docker docker-engine docker.io containerd runc

# Set up repository
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release

# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

# Set up stable repository
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker Engine
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io

# Add your user to the docker group (optional, but recommended)
sudo usermod -aG docker $USER
# Log out and log back in for this to take effect

3.2 macOS Installation

For macOS, use Docker Desktop:

  1. Download Docker Desktop from the official Docker website
  2. Verify the checksum:
1
2
shasum -a 256 ~/Downloads/Docker.dmg
# Compare with the checksum published on Docker's website
  1. Install Docker Desktop by dragging it to your Applications folder
  2. Start Docker Desktop and complete the setup process

3.3 Windows Installation

For Windows, use Docker Desktop with WSL 2 backend for improved security and performance:

  1. Enable WSL 2 by following Microsoft’s official documentation
  2. Download Docker Desktop from the official Docker website
  3. Verify the installer using PowerShell:
1
2
Get-FileHash -Algorithm SHA256 -Path .\DockerDesktopInstaller.exe
# Compare with the checksum published on Docker's website
  1. Run the installer and ensure “Use WSL 2 instead of Hyper-V” is selected
  2. Complete the installation and restart if prompted

3.4 Installation Verification

After installation, verify Docker is functioning correctly and securely:

1
2
3
4
5
6
7
8
9
10
11
# Check Docker version
docker --version

# Verify daemon is running
docker info

# Run a test container
docker run --rm hello-world

# Check for any warnings or security issues
docker info | grep -i warning

4. Multi-Stage Build Fundamentals

4.1 Basic Syntax and Structure

A multi-stage Dockerfile uses multiple FROM statements, each starting a new build stage:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Build stage
FROM node:16-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

This example shows a typical web application build, using Node.js to build the application and Nginx to serve it.

4.2 Stage Naming Conventions

Use clear, descriptive names for your build stages:

1
2
3
4
5
6
7
8
FROM golang:1.17 AS builder
# Build stage operations...

FROM alpine:3.14 AS security-scanner
# Security scanning operations...

FROM gcr.io/distroless/static AS runtime
# Final stage operations...

Consistent naming conventions improve readability and maintainability. Consider a prefix system for different types of stages:

  • build-* for compilation stages
  • test-* for testing stages
  • scan-* for security scanning stages
  • final or runtime for the production image

4.3 Copying Between Stages

You can selectively copy artifacts between stages using the COPY --from=<stage> directive:

1
2
3
4
5
6
7
8
# Copy entire directories
COPY --from=builder /app/bin /app

# Copy specific files
COPY --from=builder /app/bin/myapp /usr/local/bin/

# Copy from a specific stage using its index (less recommended, as it's brittle)
COPY --from=0 /app/config.json /config.json

Best practices for copying between stages:

  • Be explicit about what files you’re copying
  • Copy only what you need for the runtime
  • Use named stages instead of numeric indices
  • Consider using checksums for verifying copied artifacts

5. Security Best Practices

5.1 Base Image Selection

Choose secure, minimal, and regularly updated base images:

1
2
3
4
5
6
7
8
9
10
11
# Prefer official images
FROM python:3.10-slim

# Or distroless images for even smaller attack surface
FROM gcr.io/distroless/python3

# Avoid using :latest tag - pin specific versions
FROM node:16.13.1-alpine3.14

# Use specific digests for immutability
FROM debian@sha256:f5676abb29a3c6a0c50046069d862ebb6f91c8c1b3e4dc12dbd02f3f03b3cdf7

Security considerations:

  • Minimal images: Alpine, slim variants, or distroless images contain fewer packages
  • Official images: Maintained by the Docker team or the software’s maintainers
  • Version pinning: Use specific versions or SHA digests for reproducibility
  • Scan before use: Validate images with security scanners before using them

5.2 Minimal Runtime Images

Keep your final image as small as possible by including only what’s needed to run your application:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Build stage
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Runtime stage with minimal JRE
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
# Specify exact version of dependencies
RUN apk add --no-cache tzdata=2021e-r0
USER nobody:nobody
ENTRYPOINT ["java", "-jar", "app.jar"]

Security benefits of minimal images:

  • Fewer packages means fewer potential vulnerabilities
  • Smaller images reduce the attack surface
  • Faster scanning and deployment
  • Easier to understand and audit what’s in the image

5.3 User Permissions

Avoid running containers as root:

1
2
3
4
5
6
7
8
9
10
11
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set ownership of application files
COPY --from=builder --chown=appuser:appgroup /app/myapp /app/

# Switch to the non-root user
USER appuser

# Make sure the application can run as non-root
ENTRYPOINT ["/app/myapp"]

For distroless images that don’t have shell access:

1
2
3
4
5
6
7
8
9
# Use the nonroot user that comes with distroless
FROM gcr.io/distroless/static:nonroot

COPY --from=builder /go/bin/app /app

# Use the nonroot user (uid=65532)
USER nonroot:nonroot

ENTRYPOINT ["/app"]

5.4 Secrets Management

Never include secrets in your Docker images:

Bad practice (DO NOT DO THIS):

1
2
# DON'T DO THIS - Secrets in Dockerfile
ENV API_KEY="secret-api-key-value"

Better approaches:

  1. Use Docker BuildKit’s secret mounting (available since Docker 18.09):
1
2
3
# syntax=docker/dockerfile:1.2
FROM alpine
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret

Build with:

1
DOCKER_BUILDKIT=1 docker build --secret id=mysecret,src=./secret.txt .
  1. Use multi-stage builds to prevent secrets from being stored in layers:
1
2
3
4
5
6
7
FROM alpine AS builder
ARG TEMP_API_KEY
RUN echo "ApiKey=${TEMP_API_KEY}" >> /app/config.temp

FROM alpine
COPY --from=builder /app/config.temp /app/config
# The API key is not stored in an image layer
  1. Use environment variables at runtime:
1
2
FROM alpine
CMD ["sh", "-c", "echo Using API key: $API_KEY"]

Run with:

1
docker run --env API_KEY=value myimage

5.5 Image Scanning

Integrate scanning directly in your multi-stage builds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Build stage
FROM node:16-alpine AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build

# Security scanning stage
FROM aquasec/trivy:latest AS security-scan
COPY --from=build /app /app
RUN trivy fs --exit-code 1 --severity HIGH,CRITICAL /app

# Production stage (only proceeds if scan passes)
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html

Best practices for image scanning:

  • Scan both your base images and your application code
  • Set appropriate severity thresholds based on your risk tolerance
  • Integrate scanning into your CI/CD pipeline
  • Keep vulnerability databases updated
  • Implement policies for addressing discovered vulnerabilities

5.6 Build-Time vs. Runtime Variables

Separate build-time variables from runtime configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Build stage with build arguments
FROM node:16-alpine AS build
ARG NODE_ENV=production
ARG BUILD_VERSION
WORKDIR /app
COPY . .
RUN echo "Building version: $BUILD_VERSION with NODE_ENV=$NODE_ENV" && \
    npm ci && \
    npm run build

# Runtime stage with environment variables
FROM nginx:alpine
ENV API_URL="https://api.example.com"
ENV LOG_LEVEL="info"
COPY --from=build /app/dist /usr/share/nginx/html

Security considerations:

  • Use ARG for build-time variables (these don’t persist in the final image)
  • Use ENV for runtime configuration
  • Consider externalizing sensitive configuration using Docker secrets or config maps
  • Don’t use build arguments for secrets
  • Document which environment variables are required at runtime

6. Advanced Configuration

6.1 Multi-Architecture Builds

Create images that work across different CPU architectures:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Use BuildKit's architecture-specific build capability
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS builder

ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"

WORKDIR /app
COPY . .

# Set architecture-specific build flags
RUN case "$TARGETPLATFORM" in \
      "linux/amd64") GOARCH=amd64 ;; \
      "linux/arm64") GOARCH=arm64 ;; \
      "linux/arm/v7") GOARCH=arm ;; \
      *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
    esac && \
    GOOS=linux go build -o myapp

FROM --platform=$TARGETPLATFORM alpine
COPY --from=builder /app/myapp /usr/local/bin/
CMD ["/usr/local/bin/myapp"]

Build for multiple platforms:

1
docker buildx build --platform linux/amd64,linux/arm64 -t myorg/myapp:latest .

Security aspects of multi-architecture builds:

  • Ensure your security measures work across all targeted architectures
  • Verify packages and dependencies for each architecture
  • Test security controls on all supported platforms
  • Consider platform-specific vulnerabilities

6.2 Optimizing Layer Caching

Arrange your Dockerfile commands to maximize cache efficiency while maintaining security:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM node:16-alpine AS builder

# Copy package files first to leverage caching
WORKDIR /app
COPY package.json package-lock.json ./

# Install dependencies (cached if package files don't change)
RUN npm ci

# Copy source code (changes more frequently)
COPY . .

# Build the application
RUN npm run build

# Use a clean image for production
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Security considerations for layer caching:

  • Don’t sacrifice security for build speed
  • Remember that any RUN instruction creates a new layer that becomes part of the image history
  • Consider using multi-stage builds to discard intermediate layers with potentially sensitive data
  • Use .dockerignore to prevent unnecessary files from being included

Example .dockerignore file:

1
2
3
4
5
6
.git
node_modules
*.log
.env*
secrets/
tests/

6.3 Dockerfile Linting and Analysis

Use automated tools to detect issues in your Dockerfiles:

Example integration in a multi-stage build:

1
2
3
4
5
6
7
8
9
10
11
12
FROM hadolint/hadolint:latest AS dockerfile-lint
COPY Dockerfile /
RUN hadolint /Dockerfile

FROM golang:1.17-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

FROM alpine:3.14
COPY --from=builder /app/myapp /usr/local/bin/
CMD ["/usr/local/bin/myapp"]

Run linting as part of your CI/CD pipeline:

1
docker run --rm -i hadolint/hadolint < Dockerfile

6.4 Common Misconfigurations

Avoid these common security mistakes in multi-stage builds:

1. Unnecessary privileges:

1
2
3
4
5
6
7
8
# BAD: Running as root by default
FROM alpine
# No USER instruction means container runs as root

# GOOD: Specify a non-root user
FROM alpine
RUN adduser -D myuser
USER myuser

2. Copying more than needed:

1
2
3
4
5
# BAD: Copying everything from build stage
COPY --from=builder /app /app

# GOOD: Copy only what's needed
COPY --from=builder /app/bin/myapp /usr/local/bin/myapp

3. Not cleaning up:

1
2
3
4
5
6
7
8
9
# BAD: Leaving package manager caches
RUN apt-get update && \
    apt-get install -y some-package

# GOOD: Clean up after installation
RUN apt-get update && \
    apt-get install -y some-package && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

4. Using latest tag:

1
2
3
4
5
# BAD: Unpredictable base image
FROM ubuntu:latest

# GOOD: Pin to specific version
FROM ubuntu:20.04

5. Mixing build and runtime stages:

1
2
3
4
5
6
7
8
9
10
11
# BAD: Installing build tools in runtime image
FROM alpine
RUN apk add --no-cache build-base gcc python3-dev

# GOOD: Separate build and runtime concerns
FROM alpine AS builder
RUN apk add --no-cache build-base gcc python3-dev
# ...build steps...

FROM alpine
# ...copy only built artifacts from builder...

7. Production Hardening Techniques

7.1 Image Signing and Verification

Sign your images to ensure integrity in your supply chain:

1
2
3
4
5
6
7
8
# Enable Docker Content Trust
export DOCKER_CONTENT_TRUST=1

# Sign and push image
docker push myorg/myapp:1.0.0

# Verify signed image
docker trust inspect --pretty myorg/myapp:1.0.0

You can integrate this with your multi-stage builds by having a final stage that verifies signatures:

1
2
3
4
5
FROM alpine:3.14 AS verify
ARG IMAGE_NAME
ARG IMAGE_TAG
RUN apk add --no-cache notary && \
    notary verify -s https://notary.docker.io $IMAGE_NAME $IMAGE_TAG

7.2 Content Trust

Configure Docker Content Trust for your build pipeline:

1
2
3
4
5
6
7
8
9
10
11
# In your CI/CD pipeline script
set -e
export DOCKER_CONTENT_TRUST=1
export DOCKER_CONTENT_TRUST_SERVER=https://notary.docker.io

# Import signing keys securely from your CI/CD secrets
docker trust key load /path/to/secured/keys

# Build and push with content trust enabled
docker build -t myorg/myapp:${VERSION} .
docker push myorg/myapp:${VERSION}

Security considerations:

  • Manage signing keys securely
  • Implement key rotation policies
  • Consider a dedicated build server for signing
  • Document verification procedures for downstream consumers

7.3 Runtime Constraints

Apply security constraints when running containers:

1
2
3
4
5
6
7
8
9
10
11
# Run with restricted capabilities
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myorg/myapp:1.0.0

# Set read-only filesystem
docker run --read-only myorg/myapp:1.0.0

# Set memory limits
docker run --memory=512m --memory-swap=512m myorg/myapp:1.0.0

# Apply seccomp profiles
docker run --security-opt seccomp=/path/to/seccomp.json myorg/myapp:1.0.0

Document these constraints in your README.md or deployment manifests.

Example Docker Compose file with security constraints:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
version: '3.8'
services:
  webapp:
    image: myorg/myapp:1.0.0
    read_only: true
    tmpfs:
      - /tmp
      - /var/run
    security_opt:
      - no-new-privileges
      - seccomp=/path/to/seccomp.json
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

7.4 Logging and Audit

Implement proper logging for your containerized applications:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM alpine:3.14
# Configure logging
RUN mkdir -p /var/log/myapp && \
    chmod 755 /var/log/myapp

# Use volumes for persistent logs
VOLUME /var/log/myapp

# Set up log rotation
COPY logrotate-config /etc/logrotate.d/myapp

# Your application should log to this directory
CMD ["/bin/sh", "-c", "exec myapp > /var/log/myapp/app.log 2>&1"]

For Docker deployment, configure logging drivers:

1
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 myorg/myapp:1.0.0

Security considerations for logging:

  • Don’t log sensitive information
  • Implement log rotation to prevent disk space issues
  • Use structured logging when possible
  • Consider using a centralized logging solution
  • Define retention policies compliant with your regulatory requirements

8. Secure CI/CD Integration

8.1 Pipeline Configuration

Example GitLab CI/CD configuration for secure multi-stage builds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
stages:
  - lint
  - build
  - scan
  - test
  - deploy

variables:
  DOCKER_BUILDKIT: 1
  DOCKER_CONTENT_TRUST: 1

lint:
  stage: lint
  image: hadolint/hadolint:latest
  script:
    - hadolint Dockerfile

build:
  stage: build
  image: docker:20.10
  services:
    - docker:20.10-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

scan:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

test:
  stage: test
  image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  script:
    - /app/run-tests.sh

deploy:
  stage: deploy
  image: docker:20.10
  services:
    - docker:20.10-dind
  script:
    - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

Security considerations:

  • Use separate stages for building, testing, and scanning
  • Implement branch protection rules
  • Restrict who can merge to production branches
  • Store credentials securely in CI/CD variables
  • Implement approval workflows for production deployments

8.2 Automated Security Scanning

Integrate security scanning into your CI/CD pipeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scan-dependencies:
  image: owasp/dependency-check
  script:
    - /usr/share/dependency-check/bin/dependency-check.sh --scan /app --format JSON --out dependency-check-report.json
  artifacts:
    paths:
      - dependency-check-report.json

scan-image:
  image: aquasec/trivy
  script:
    - trivy image --exit-code 0 --format json --output trivy-report.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  artifacts:
    paths:
      - trivy-report.json

scan-secrets:
  image: zricethezav/gitleaks
  script:
    - gitleaks detect -v --source . --report-path=gitleaks-report.json
  artifacts:
    paths:
      - gitleaks-report.json

Use centralized scanning results for compliance reporting.

8.3 Deployment Strategies

Implement secure deployment strategies:

1. Blue-Green Deployment:

1
2
3
4
5
6
7
8
9
10
# Deploy new version to staging
docker run -d --name app-green $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

# Run tests against staging
./run-tests.sh app-green

# If tests pass, update production
docker stop app-blue
docker rm app-blue
docker run -d --name app-blue $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

2. Canary Deployment:

1
2
3
4
5
6
# Deploy new version to 10% of users
kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
kubectl scale deployment/myapp --replicas=10
kubectl scale deployment/myapp-canary --replicas=1

# Monitor and gradually increase traffic

Security considerations:

  • Implement rollback procedures
  • Monitor application health during deployment
  • Use immutable infrastructure principles
  • Implement proper access controls for deployment systems
  • Document emergency rollback procedures

9. Testing and Validation

9.1 Security Testing Tools

Integrate these tools into your workflow:

  • Clair: For container vulnerability scanning
  • Trivy: For comprehensive vulnerability scanning
  • Anchore: For deep image analysis
  • Dagda: For runtime monitoring
  • Falco: For runtime security monitoring

Example Trivy integration in a multi-stage build:

1
2
3
4
5
6
7
8
FROM aquasec/trivy:latest AS security-scanner
RUN mkdir /scan
COPY --from=builder /app /scan
RUN trivy fs --exit-code 1 --no-progress --severity HIGH,CRITICAL /scan

FROM alpine:3.14
COPY --from=builder /app/myapp /usr/local/bin/
CMD ["/usr/local/bin/myapp"]

9.2 Validation Scripts

Create scripts to validate your Docker images:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/bin/bash
# validate-image.sh

set -e

IMAGE="$1"
echo "Validating image: $IMAGE"

# Check for basic runtime capabilities
echo "Testing container startup..."
CONTAINER_ID=$(docker run -d $IMAGE)
sleep 2
CONTAINER_STATUS=$(docker inspect -f '{{.State.Status}}' $CONTAINER_ID)
if [ "$CONTAINER_STATUS" != "running" ]; then
  echo "Container failed to start properly"
  docker logs $CONTAINER_ID
  docker rm -f $CONTAINER_ID
  exit 1
fi

# Check for non-root user
echo "Checking for non-root user..."
USER_ID=$(docker exec $CONTAINER_ID id -u)
if [ "$USER_ID" == "0" ]; then
  echo "WARNING: Container is running as root"
fi

# Check exposed ports
echo "Checking exposed ports..."
EXPOSED_PORTS=$(docker inspect -f '{{range $port, $_ := .Config.ExposedPorts}}{{$port}} {{end}}' $CONTAINER_ID)
echo "Exposed ports: $EXPOSED_PORTS"

# Clean up
docker rm -f $CONTAINER_ID

echo "Validation complete"

Run this script in your CI/CD pipeline:

1
./validate-image.sh myorg/myapp:latest

9.3 Compliance Checking

Implement compliance checks for your Docker images:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/bash
# check-compliance.sh

IMAGE="$1"
echo "Checking compliance for image: $IMAGE"

# Extract metadata
DOCKER_INFO=$(docker inspect $IMAGE)

# Check for required labels
if ! echo "$DOCKER_INFO" | grep -q '"org.label-schema.version"'; then
  echo "FAIL: Missing required version label"
  exit 1
fi

# Check for exposed ports
if echo "$DOCKER_INFO" | grep -q '"22/tcp"'; then
  echo "FAIL: SSH port 22 should not be exposed"
  exit 1
fi

# Check for read-only filesystem capability
if ! echo "$DOCKER_INFO" | grep -q '"ReadonlyRootfs": true'; then
  echo "WARNING: Container does not use read-only filesystem"
fi

# Check for custom seccomp profile
if ! echo "$DOCKER_INFO" | grep -q '"SecurityOpt"'; then
  echo "WARNING: No custom seccomp profile applied"
fi

echo "Compliance check complete"

10. References and Resources

Official Documentation

Security Guidance

Tools

Blogs and Articles

11. Appendices

11.1 Troubleshooting

Common Issues and Solutions

IssueSolution
Build context too largeUse .dockerignore to exclude unnecessary files
Image size too largeImplement multi-stage builds to reduce final image size
Caching not working properlyReview layer order and optimize for cache efficiency
Build fails in CI but works locallyEnsure CI environment has same Docker version and BuildKit settings
Container can’t access networkCheck network configuration and firewall rules
Permission denied errorsSet proper file permissions or use appropriate user

Debugging Build Issues

1
2
3
4
5
6
7
8
9
10
11
12
# Enable BuildKit verbose output
BUILDKIT_PROGRESS=plain docker build .

# Check image layers
docker history myorg/myapp:latest

# Inspect image configuration
docker inspect myorg/myapp:latest

# Run intermediate build stages for debugging
docker build --target builder -t debug-image .
docker run -it debug-image /bin/sh

11.2 Language-Specific Considerations

Java Applications

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Build stage
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /app
COPY pom.xml .
# Download dependencies first for better caching
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
# Use specific JVM flags for containers
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseContainerSupport"
USER nobody:nobody
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Security considerations:

  • Use JRE instead of JDK in production
  • Set appropriate memory limits
  • Enable JVM container support
  • Run as non-root user

Node.js Applications

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Build stage
FROM node:16-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Run security audit
RUN npm audit --production
# Build application
RUN npm run build

# Runtime stage
FROM node:16-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -S nodejs && adduser -S nodejs -G nodejs
# Copy only production dependencies and built files
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
RUN npm ci --only=production
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
# Use non-root user
USER nodejs
# Set NODE_ENV
ENV NODE_ENV production
CMD ["node", "dist/index.js"]

Security considerations:

  • Run npm audit during build
  • Use npm ci instead of npm install
  • Set NODE_ENV=production
  • Don’t run as root
  • Only install production dependencies in the final image

Python Applications

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Build stage
FROM python:3.10-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libffi-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt

# Runtime stage
FROM python:3.10-slim
WORKDIR /app
# Create non-root user
RUN useradd -m appuser
# Copy wheels from builder
COPY --from=builder /app/wheels /app/wheels
# Install dependencies from wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/app/wheels -r requirements.txt && \
    rm -rf /app/wheels
# Copy application code
COPY --chown=appuser:appuser app /app/app
# Switch to non-root user
USER appuser
# Run with Python optimization
CMD ["python", "-O", "-m", "app"]

Security considerations:

  • Use slim base images
  • Separate build and runtime dependencies
  • Use wheels for deterministic builds
  • Run as non-root user
  • Enable Python optimizations

Go Applications

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Build stage
FROM golang:1.17-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git ca-certificates
# Copy go.mod and go.sum first for better caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o app .

# Runtime stage - scratch is the most minimal image
FROM scratch
# Copy CA certificates for secure connections
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binary
COPY --from=builder /app/app /app
# Run as non-root (note: no shell in scratch image)
USER 1000:1000
ENTRYPOINT ["/app"]

Security considerations:

  • Use static linking to avoid library dependencies
  • Use scratch or distroless base images
  • Include CA certificates for secure connections
  • Strip debug information from binaries
  • Run as non-root user

11.3 Glossary

TermDefinition
BuildKitDocker’s next-generation build system with advanced caching, parallel building, and more secure features.
Content TrustA feature that ensures image integrity through digital signatures.
DistrolessMinimal container images that contain only the application and its runtime dependencies, but not package managers or shells.
Layer CachingDocker’s mechanism for reusing existing layers when building images to speed up build time.
Multi-Stage BuildA Docker feature that allows using multiple FROM statements in a Dockerfile, where each FROM instruction can use a different base.
OCIOpen Container Initiative, an open governance structure for container formats and runtimes.
Scratch ImageThe most minimal Docker image possible, containing no files or programs.
SeccompSecure Computing Mode, a Linux kernel feature that restricts the system calls that a process can make.
SBOMSoftware Bill of Materials, a list of components in a software artifact.
Vulnerability ScanningThe process of identifying security vulnerabilities in container images.

12. Quick Reference

Essential Multi-Stage Build Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Build stage
FROM node:16-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Test stage
FROM build AS test
RUN npm test

# Security scan stage
FROM aquasec/trivy:latest AS security-scan
COPY --from=build /app /app
RUN trivy fs --exit-code 1 --severity HIGH,CRITICAL /app

# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Add security headers
RUN echo 'server {\n\
    listen 80;\n\
    server_name localhost;\n\
    location / {\n\
        root /usr/share/nginx/html;\n\
        index index.html;\n\
        try_files $uri $uri/ /index.html;\n\
        add_header X-Content-Type-Options nosniff;\n\
        add_header X-Frame-Options DENY;\n\
        add_header X-XSS-Protection "1; mode=block";\n\
    }\n\
}' > /etc/nginx/conf.d/default.conf
# Run as non-root
RUN chown -R nginx:nginx /usr/share/nginx/html && \
    chown -R nginx:nginx /var/cache/nginx && \
    chown -R nginx:nginx /var/log/nginx && \
    touch /var/run/nginx.pid && \
    chown -R nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Security Checklist

  • Use specific versions of base images, not :latest
  • Implement multi-stage builds to reduce attack surface
  • Run containers as non-root users
  • Use minimal or distroless base images for production
  • Keep secrets out of images using BuildKit secrets
  • Scan images for vulnerabilities
  • Sign and verify images
  • Apply appropriate security contexts
  • Regularly update base images
  • Implement proper logging and monitoring
  • Use .dockerignore to exclude sensitive files
  • Lint Dockerfiles for best practices
  • Remove unnecessary tools and packages

Command Reference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Build with BuildKit (recommended)
DOCKER_BUILDKIT=1 docker build -t myapp:latest .

# Build specific stage
docker build --target build -t myapp-build .

# Build with build args
docker build --build-arg VERSION=1.2.3 -t myapp:1.2.3 .

# Build with secrets
DOCKER_BUILDKIT=1 docker build --secret id=api_key,src=./api_key.txt -t myapp .

# Scan image for vulnerabilities
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image myapp:latest

# Sign image
export DOCKER_CONTENT_TRUST=1
docker push myorg/myapp:latest

# Run with security options
docker run --security-opt=no-new-privileges --cap-drop=ALL --read-only myorg/myapp:latest

By following the guidance in this document, you’ll be able to create more secure, efficient, and maintainable Docker images using multi-stage builds. Remember that container security is a continuous process that requires regular updates, monitoring, and adherence to best practices.

This post is licensed under CC BY 4.0 by the author.