Optimizing Docker Images for Production

Introduction

Optimizing Docker images is crucial for production deployments. Smaller, more efficient images lead to faster deployments, reduced attack surface, and lower costs. This guide covers advanced techniques for building production-ready Docker images.

Multi-Stage Builds

.NET Application Example

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /source

# Copy csproj and restore dependencies (this is done separately to leverage Docker layer caching)
COPY *.csproj .
RUN dotnet restore

# Copy everything else and build
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app

# Install required packages and create non-root user
RUN apk add --no-cache \
    icu-libs \
    tzdata \
    && adduser -D -u 1000 appuser

# Copy published app from build stage
COPY --from=build --chown=appuser:appuser /app .

# Switch to non-root user
USER appuser

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

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet"
  - MyApp.dll

Node.js Application with Dependencies Optimization

# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files
COPY package*.json ./

# Install production dependencies
RUN npm ci --only=production

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app

# Copy package files and install all dependencies
COPY package*.json ./
RUN npm ci

# Copy source code
COPY . .

# Build the application
RUN npm run build

# Runtime stage
FROM node:20-alpine AS runtime
WORKDIR /app

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init

# Create non-root user
RUN addgroup -g 1001 nodejs && \
    adduser -S -u 1001 -G nodejs nodejs

# Copy production dependencies
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules

# Copy built application
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package*.json ./

# Switch to non-root user
USER nodejs

# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init"
  - --
CMD ["node"
  - dist/index.js

Layer Caching Strategies

Optimizing Layer Order

# Bad: Changes to code invalidate dependency cache
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python"
  - app.py

# Good: Dependencies cached separately
FROM python:3.11-slim
WORKDIR /app

# Install dependencies first (cached if requirements.txt doesn't change)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code (changes don't invalidate dependency cache)
COPY . .
CMD ["python"
  - app.py

Using BuildKit Cache Mounts

# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Cache go modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

# Copy source code
COPY . .

# Build with cache mount for go build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o app .

# Runtime stage
FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app

Security Best Practices

Distroless Images

# Build stage
FROM golang:1.21 AS builder
WORKDIR /app

COPY go.* ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Runtime stage using distroless
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server

Security Scanning in Build

# Build stage with security scanning
FROM node:20-alpine AS builder
WORKDIR /app

# Install security scanning tools
RUN apk add --no-cache npm
RUN npm install -g npm-audit-resolver

COPY package*.json ./
RUN npm ci && npm audit --production

COPY . .
RUN npm run build

# Scan for vulnerabilities with Trivy
FROM aquasec/trivy:latest AS scanner
COPY --from=builder /app /app
RUN trivy fs --no-progress --security-checks vuln,config /app

# Runtime stage
FROM node:20-alpine
WORKDIR /app

# Security hardening
RUN apk upgrade --no-cache && \
    apk add --no-cache libcap && \
    rm -rf /var/cache/apk/*

# Create non-root user with specific UID/GID
RUN addgroup -g 10001 appgroup && \
    adduser -D -u 10001 -G appgroup appuser

# Copy application
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules

# Drop all capabilities
RUN setcap -r /usr/local/bin/node || true

USER appuser

# Read-only root filesystem
# VOLUME ["/tmp

CMD ["node"
  - dist/index.js

Image Size Optimization

Alpine vs Debian Slim Comparison

# Alpine-based image (smaller)
FROM python:3.11-alpine
RUN apk add --no-cache \
    gcc \
    musl-dev \
    postgresql-dev
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Final size: ~150MB

# Debian-slim based image (more compatible)
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
    gcc \
    postgresql-client \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Final size: ~200MB

Minimizing Final Image Size

# Multi-stage build for Go application
FROM golang:1.21 AS builder
WORKDIR /build

# Enable Go modules
ENV GO111MODULE=on
ENV CGO_ENABLED=0

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build static binary
COPY . .
RUN go build \
    -ldflags="-w -s -extldflags '-static'" \
    -a -installsuffix cgo \
    -o app .

# Compress binary with UPX
FROM alpine AS compressor
RUN apk add --no-cache upx
COPY --from=builder /build/app /app
RUN upx --best --lzma /app

# Final scratch image
FROM scratch
COPY --from=compressor /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app
# Final size: ~5-10MB

Build Arguments and Secrets

Secure Secrets Handling

# syntax=docker/dockerfile:1
FROM node:20-alpine

# Use BuildKit secrets (not stored in image)
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN && \
    npm install --production && \
    npm config delete //registry.npmjs.org/:_authToken

# Build with: docker build --secret id=npm_token,src=.npmtoken .

Build Arguments for Versioning

ARG BUILD_DATE
ARG VCS_REF
ARG VERSION

FROM alpine:3.18

# Use build arguments for labels
LABEL org.opencontainers.image.created=$BUILD_DATE \
      org.opencontainers.image.revision=$VCS_REF \
      org.opencontainers.image.version=$VERSION \
      org.opencontainers.image.title="My Application" \
      org.opencontainers.image.description="Production-ready application"

# Runtime configuration
ENV APP_VERSION=$VERSION

Docker Compose for Development

Development vs Production Configs

# docker-compose.yml (base)
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime
    environment:
      - NODE_ENV=production
    ports:
      - "3000:3000"
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  redis-data:
# docker-compose.override.yml (development)
version: '3.8'

services:
  app:
    build:
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DEBUG=*
    command: npm run dev

  redis:
    ports:
      - "6379:6379"

Container Health and Lifecycle

Comprehensive Health Checks

FROM node:20-alpine

WORKDIR /app
COPY . .

# Install curl for health checks
RUN apk add --no-cache curl

# Health check script
COPY <<EOF /healthcheck.sh
#!/bin/sh
set -e

# Check if the application is responding
curl -f http://localhost:3000/health || exit 1

# Check if critical dependencies are accessible
curl -f http://localhost:3000/ready || exit 1

# Check memory usage
MEM_USAGE=$(cat /proc/1/status | grep VmRSS | awk '{print $2}')
MEM_LIMIT=524288  # 512MB in KB
if [ "$MEM_USAGE" -gt "$MEM_LIMIT" ]; then
    exit 1
fi

exit 0
EOF

RUN chmod +x /healthcheck.sh

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD /healthcheck.sh

CMD ["node"
  - server.js

Graceful Shutdown

FROM node:20-alpine

# Install tini for proper signal handling
RUN apk add --no-cache tini

WORKDIR /app
COPY . .

# Create shutdown script
COPY <<'EOF' /app/shutdown.sh
#!/bin/sh
echo "Received shutdown signal, gracefully stopping..."
kill -TERM $(pgrep node)
sleep 10
exit 0
EOF

RUN chmod +x /app/shutdown.sh

# Use tini as init system
ENTRYPOINT ["/sbin/tini"
  - --

# Handle signals properly
STOPSIGNAL SIGTERM

CMD ["node"
  - server.js

CI/CD Integration

GitHub Actions Docker Build

name: Docker Build and Push

on:
  push:
    branches: [main]
    tags:
  - 'v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: myapp/api
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix={{branch}}-

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=myapp/api:buildcache
          cache-to: type=registry,ref=myapp/api:buildcache,mode=max
          build-args: |
            BUILD_DATE=${{ github.event.head_commit.timestamp }}
            VCS_REF=${{ github.sha }}
            VERSION=${{ steps.meta.outputs.version }}

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp/api:${{ steps.meta.outputs.version }}
          format: 'sarif'
          output: 'trivy-results.sarif'

      - name: Upload Trivy results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-results.sarif'

Debugging and Troubleshooting

Debug Image

# Production image
FROM alpine:3.18 AS production
COPY --from=builder /app/binary /app/binary
USER nobody
ENTRYPOINT ["/app/binary

# Debug image with tools
FROM production AS debug
USER root
RUN apk add --no-cache \
    curl \
    wget \
    netcat-openbsd \
    bind-tools \
    busybox-extras \
    strace \
    tcpdump
USER nobody

# Build debug image with: docker build --target debug -t myapp:debug .

Conclusion

Optimizing Docker images requires balancing size, security, and functionality. By implementing multi-stage builds, leveraging layer caching, following security best practices, and using appropriate base images, you can create efficient, secure, and maintainable Docker images for production use.

Comments