image-optimization
This skill should be used when optimizing Docker image size, improving build performance, implementing caching strategies, or hardening container security.
From ccfg-dockernpx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-dockerThis skill uses the workspace's default tool permissions.
Docker Image Optimization and Security
This skill defines strategies for optimizing Docker image size, improving build performance, implementing effective caching, and hardening container security. Following these practices ensures efficient, fast, and secure container deployments.
Existing Repository Compatibility
When working in established repositories, always respect existing optimization strategies and security practices. If the repository has established build patterns, base image choices, or security configurations, maintain consistency with those practices. Only introduce new optimizations when explicitly requested or when modernizing legacy configurations. This principle applies to layer optimization, caching strategies, security hardening, and image scanning procedures.
Layer Minimization
Reduce the number of layers and their size for faster pulls and smaller images.
Combining RUN Commands
# CORRECT: Combined RUN commands with cleanup
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
software-properties-common && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" && \
apt-get update && \
apt-get install -y --no-install-recommends docker-ce-cli && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# WRONG: Separate RUN commands leave artifacts
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl gnupg
RUN curl -fsSL https://example.com/setup.sh | bash
RUN apt-get clean
# Each RUN creates a layer; clean doesn't remove previous layers' artifacts
Multi-Stage Build Optimization
# CORRECT: Multi-stage eliminates build dependencies
FROM golang:1.22-alpine AS builder
WORKDIR /build
# Install build-only dependencies
RUN apk add --no-cache git make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o app .
# Final stage: minimal runtime
FROM scratch
COPY --from=builder /build/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
# Result: ~10MB vs 500MB+ with builder stage included
Chain Cleanup in Same Layer
# CORRECT: Cleanup in same layer
FROM node:20-alpine
WORKDIR /app
# Download, extract, cleanup in one layer
RUN wget https://example.com/package.tar.gz && \
tar -xzf package.tar.gz -C /usr/local && \
rm package.tar.gz
# Install dependencies and cleanup cache in one layer
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force && \
rm -rf /tmp/*
# WRONG: Cleanup in separate layer doesn't reduce size
FROM node:20-alpine
WORKDIR /app
RUN wget https://example.com/package.tar.gz
RUN tar -xzf package.tar.gz -C /usr/local
RUN rm package.tar.gz
# File exists in previous layers, image size unchanged
Size Reduction Strategies
Minimal Base Images
| Base Image | Size | Use Case | Trade-offs |
|---|---|---|---|
scratch | 0 B | Static binaries (Go, Rust) | No shell, no debugging tools |
distroless/static | ~2 MB | Static binaries with CA certs | No shell, no package manager |
distroless/base | ~20 MB | Dynamic binaries | No shell, no package manager |
alpine | ~7 MB | General purpose | musl libc (compatibility issues) |
-slim | ~40-80 MB | Good compatibility | Debian-based |
ubuntu:22.04 | ~80 MB | Full compatibility | Larger, more attack surface |
# CORRECT: Choosing appropriate base image
# Static Go binary: use scratch
FROM golang:1.22-alpine AS builder
RUN CGO_ENABLED=0 go build -o app .
FROM scratch
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]
# Dynamic binary: use distroless
FROM golang:1.22-alpine AS builder
RUN go build -o app .
FROM gcr.io/distroless/base-debian12
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]
# Complex dependencies: use alpine or slim
FROM python:3.12-slim
RUN pip install --no-cache-dir -r requirements.txt
Strip Debug Symbols
# CORRECT: Strip debug symbols from binaries
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY . .
# Build with flags to strip debug info
RUN CGO_ENABLED=0 go build \
-ldflags="-w -s" \
-a -installsuffix cgo \
-o app .
# -w: Omit DWARF symbol table
# -s: Omit symbol table and debug info
# Result: 30-50% smaller binary
# CORRECT: Strip C/C++ binaries
FROM gcc:12 AS builder
WORKDIR /build
COPY . .
RUN make && \
strip --strip-all /build/app
# Reduces size by removing debug symbols
Exclude Development Dependencies
# CORRECT: Install only production dependencies
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Install all dependencies for build
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && \
npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
# CORRECT: Python production dependencies
FROM python:3.12-slim AS builder
WORKDIR /build
# Build wheels for all dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Production stage
FROM python:3.12-slim
WORKDIR /app
# Install from pre-built wheels
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && \
rm -rf /wheels
COPY . .
CMD ["python", "app.py"]
Comprehensive .dockerignore
# CORRECT: Comprehensive .dockerignore reduces context size
# Version control
.git/
.gitignore
.gitattributes
# Dependencies (installed during build)
node_modules/
vendor/
__pycache__/
*.pyc
# Build artifacts (created during build)
dist/
build/
target/
*.o
*.a
# Environment and secrets
.env
.env.*
!.env.example
*.key
*.pem
# Development files
.vscode/
.idea/
*.swp
.DS_Store
# Documentation
README.md
docs/
*.md
!API.md
# CI/CD
.github/
.gitlab-ci.yml
Jenkinsfile
# Tests
test/
tests/
spec/
*.test.js
*.spec.ts
coverage/
# Large files
*.tar
*.tar.gz
*.zip
*.log
logs/
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
Measure context size:
# Before optimization
docker build --no-cache .
# Sending build context to Docker daemon: 450MB
# After .dockerignore
docker build --no-cache .
# Sending build context to Docker daemon: 12MB
Build Caching Strategies
Instruction Ordering for Cache Hits
# CORRECT: Optimal ordering for cache hits
FROM node:20-alpine
WORKDIR /app
# 1. Copy dependency files first (change rarely)
COPY package*.json ./
# 2. Install dependencies (cached unless package.json changes)
RUN npm ci --only=production && \
npm cache clean --force
# 3. Copy source code last (changes frequently)
COPY . .
# 4. Build (only runs if source or deps change)
RUN npm run build
CMD ["node", "dist/index.js"]
# Typical development:
# - Code change: Runs steps 3-4 (fast)
# - Dependency change: Runs steps 2-4 (medium)
# - Base image change: Runs all steps (slow)
BuildKit Cache Mounts
# CORRECT: BuildKit cache mounts for package managers
# syntax=docker/dockerfile:1.4
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Cache npm packages across builds
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
# Benefits:
# - npm packages cached on host
# - Faster reinstalls
# - Works across different branches
# CORRECT: Python pip cache mount
# syntax=docker/dockerfile:1.4
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# Cache pip packages
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
# Pip cache persists across builds
# CORRECT: Go module cache mount
# syntax=docker/dockerfile:1.4
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.* ./
# Cache Go modules and build cache
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o app .
# Go modules and build artifacts cached
CI/CD Layer Caching
# CORRECT: CI-friendly caching with --cache-from
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Build with cache in CI:
# Pull previous images for cache
docker pull myapp:latest || true
docker pull myapp:deps || true
docker pull myapp:builder || true
# Build with cache
docker buildx build \
--cache-from type=registry,ref=myapp:latest \
--cache-from type=registry,ref=myapp:deps \
--cache-from type=registry,ref=myapp:builder \
--cache-to type=inline \
--target production \
-t myapp:${VERSION} \
-t myapp:latest \
--push \
.
Inline Cache Export
# CORRECT: Export cache with image
docker buildx build \
--cache-to type=inline \
--tag myapp:latest \
--push \
.
# Import cache from registry
docker buildx build \
--cache-from type=registry,ref=myapp:latest \
--tag myapp:new \
.
Registry Cache Backend
# CORRECT: Dedicated cache storage in registry
docker buildx build \
--cache-from type=registry,ref=myapp:buildcache \
--cache-to type=registry,ref=myapp:buildcache,mode=max \
--tag myapp:latest \
--push \
.
# mode=max: Export all layers (larger but better cache hits)
# mode=min: Export only result layers (smaller but fewer cache hits)
Local Cache Backend
# CORRECT: Local cache directory
docker buildx build \
--cache-from type=local,src=/tmp/buildx-cache \
--cache-to type=local,dest=/tmp/buildx-cache,mode=max \
--tag myapp:latest \
.
# Useful for CI systems with persistent volumes
Security Hardening
Non-Root User
# CORRECT: Run as non-root user
FROM node:20-alpine
WORKDIR /app
# Install dependencies as root
COPY package*.json ./
RUN npm ci --only=production
# Copy application
COPY . .
# Switch to node user (built into official node images)
USER node
# All subsequent commands and runtime as node user
CMD ["node", "server.js"]
# CORRECT: Create custom user
FROM alpine:3.19
# Create user and group
RUN addgroup -g 1000 appgroup && \
adduser -D -u 1000 -G appgroup appuser
WORKDIR /app
# Set ownership
RUN chown -R appuser:appgroup /app
# Copy with ownership
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["./app"]
# CORRECT: Distroless with non-root
FROM golang:1.22-alpine AS builder
RUN CGO_ENABLED=0 go build -o app .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/app /app
# nonroot user (UID 65532) built in
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Read-Only Root Filesystem
# CORRECT: Read-only root filesystem
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Create writable temp directory
RUN mkdir -p /tmp/app && \
chown -R node:node /tmp/app
USER node
# Application must write only to /tmp/app or mounted volumes
CMD ["node", "server.js"]
Run with read-only root:
docker run --read-only --tmpfs /tmp/app myapp:latest
In Kubernetes:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
Drop Capabilities
# CORRECT: Document required capabilities
FROM nginx:alpine
LABEL security.capabilities="NET_BIND_SERVICE,CHOWN,SETUID,SETGID"
# Nginx needs NET_BIND_SERVICE for port 80
Run with minimal capabilities:
docker run \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--cap-add=CHOWN \
--cap-add=SETUID \
--cap-add=SETGID \
nginx:alpine
In Kubernetes:
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
No New Privileges
# CORRECT: Prevent privilege escalation
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
USER node
# Prevent setuid binaries from gaining privileges
CMD ["node", "server.js"]
Run with no-new-privileges:
docker run --security-opt=no-new-privileges:true myapp:latest
In Kubernetes:
securityContext:
allowPrivilegeEscalation: false
CVE Scanning
# CORRECT: Scan images for vulnerabilities
# Using Docker Scout
docker scout cves myapp:latest
# Using Trivy
trivy image myapp:latest
# Using Grype
grype myapp:latest
# Using Snyk
snyk container test myapp:latest
# Fail build on high/critical vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
Integrate into CI:
# CORRECT: GitHub Actions security scanning
name: Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:test .
- name: Run Trivy scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:test
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1
- name: Upload results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: trivy-results.sarif
Never Embed Secrets
# WRONG: Secrets in image
FROM node:20-alpine
ENV API_KEY=sk-abc123xyz789
ENV DATABASE_PASSWORD=super_secret
# CORRECT: Secrets via environment at runtime
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
USER node
# No secrets in image
CMD ["node", "server.js"]
# CORRECT: Secrets via BuildKit secret mount
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Access secret during build without embedding
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Build with secret:
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
Image Size Analysis
Analyze Layer Sizes
# CORRECT: Analyze image layers
docker history myapp:latest
# Show layer sizes
docker history --human --no-trunc myapp:latest
# Use dive for detailed analysis
dive myapp:latest
# CI-friendly layer analysis
docker history --format "{{.Size}}\t{{.CreatedBy}}" myapp:latest
Compare Image Sizes
# Example: Optimize Python image
# BEFORE: 1.2GB
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# AFTER: 150MB
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && \
rm -rf /wheels
COPY . .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
CMD ["python", "app.py"]
# Savings: 87% reduction
Benchmark Results
| Technique | Before | After | Savings |
|---|---|---|---|
| Multi-stage build (Node.js) | 1.1 GB | 180 MB | 84% |
| Alpine base (Python) | 950 MB | 85 MB | 91% |
| Slim base (Python) | 950 MB | 150 MB | 84% |
| Distroless (Go) | 850 MB | 12 MB | 99% |
| Scratch (Go static) | 850 MB | 8 MB | 99% |
| Combined RUN commands | 500 MB | 450 MB | 10% |
| .dockerignore | Build context: 450 MB | 12 MB | 97% |
| Strip Go binary | 25 MB | 12 MB | 52% |
Complete Optimization Example
# CORRECT: Fully optimized Node.js application
# syntax=docker/dockerfile:1.4
# Stage 1: Dependencies
FROM node:20.11-alpine3.19 AS deps
WORKDIR /app
# Copy dependency manifests
COPY package.json package-lock.json ./
# Install dependencies with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
# Stage 2: Build
FROM node:20.11-alpine3.19 AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy source
COPY . .
# Build with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm run build && \
npm run test
# Stage 3: Production dependencies
FROM node:20.11-alpine3.19 AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
# Install only production dependencies
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production --prefer-offline && \
npm cache clean --force
# Stage 4: Runtime
FROM node:20.11-alpine3.19 AS runtime
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
WORKDIR /app
# Copy production dependencies
COPY --from=prod-deps /app/node_modules ./node_modules
# Copy built artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Security: run as non-root
USER node
# Metadata
LABEL org.opencontainers.image.title="MyApp API" \
org.opencontainers.image.description="Optimized production API" \
org.opencontainers.image.version="1.0.0"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
# Runtime config
ENV NODE_ENV=production
EXPOSE 3000
# Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
# Final image: ~50MB
# - Alpine base: 7MB
# - Node.js runtime: 35MB
# - Application: 8MB
Build optimized:
docker buildx build \
--cache-from type=registry,ref=myapp:buildcache \
--cache-to type=registry,ref=myapp:buildcache,mode=max \
--target runtime \
-t myapp:1.0.0 \
-t myapp:latest \
--push \
.
Security scan:
trivy image --severity HIGH,CRITICAL myapp:1.0.0
Run hardened:
docker run \
--read-only \
--tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
-p 3000:3000 \
myapp:1.0.0
This comprehensive guide covers optimization strategies that ensure efficient, fast, and secure Docker images ready for production deployment.