
Docker Syntax Multistage
- 8 installs
- 9 repo stars
- Updated July 8, 2026
- openaec-foundation/docker-claude-skill-package
Helps with devops & ci/cd tasks.
About
docker-syntax-multistage is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted development.
- docker-syntax-multistage
- DevOps & CI/CD
- AI-coding skill
Docker Syntax Multistage by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,044 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/docker-claude-skill-package --skill docker-syntax-multistageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/docker-claude-skill-package ↗ |
What it does
Helps with devops & ci/cd tasks.
Files
docker-syntax-multistage
Quick Reference
Multi-Stage Build Concept
A multi-stage build uses multiple FROM instructions in a single Dockerfile. Each FROM starts a new stage. Only the final stage (or the --target stage) produces the output image. Earlier stages exist solely to generate artifacts that are copied into later stages.
Stage Types
| Stage Type | Purpose | Final Image? |
|---|---|---|
| Builder | Compile code, run bundlers, generate artifacts | No |
| Test | Run test suites, linting, static analysis | No |
| Dependencies | Install and cache shared dependencies | No |
| Production | Minimal runtime with only required artifacts | Yes |
| Debug | Production + debugging tools | Yes (dev only) |
Image Size Impact
| Language | Without Multi-Stage | With Multi-Stage | Reduction |
|---|---|---|---|
| Go | ~800 MB (golang:1.22) | ~0 MB (scratch) or ~7 MB (alpine) | 99% |
| Node.js | ~1.1 GB (node:20) | ~180 MB (node:20-slim) | 83% |
| Python | ~1.0 GB (python:3.12) | ~150 MB (python:3.12-slim) | 85% |
| Java | ~700 MB (eclipse-temurin:21-jdk) | ~220 MB (eclipse-temurin:21-jre) | 69% |
| Rust | ~1.4 GB (rust:1.77) | ~0 MB (scratch) or ~7 MB (alpine) | 99% |
| .NET | ~900 MB (mcr.microsoft.com/dotnet/sdk:8.0) | ~220 MB (mcr.microsoft.com/dotnet/aspnet:8.0) | 76% |
Final Stage Base Image Selection
| Base Image | Size | Use When |
|---|---|---|
scratch | 0 MB | Statically compiled binaries (Go, Rust with musl) |
alpine:3.19 | ~7 MB | Need a shell and minimal OS utilities |
gcr.io/distroless/static-debian12 | ~2 MB | Static binaries without shell access |
gcr.io/distroless/base-debian12 | ~20 MB | Binaries needing glibc but no shell |
*-slim variants | 30-80 MB | Need package manager for runtime deps |
Critical Warnings
ALWAYS use multi-stage builds for production images. Single-stage builds ship compilers, source code, and build tools to production -- a security risk and a waste of space.
ALWAYS name stages with AS <name>. NEVER reference stages by numeric index (--from=0) because indexes break when stages are added or removed.
NEVER install build tools (gcc, make, npm devDependencies) in the final production stage. Build tools belong in the builder stage only.
NEVER copy the entire build stage filesystem into the production stage. ALWAYS copy only the specific artifacts needed (binaries, compiled assets, config files).
ALWAYS include # syntax=docker/dockerfile:1 as the first line to enable BuildKit features including parallel stage execution.
---
Core Syntax
FROM ... AS (Stage Naming)
FROM <image>[:<tag>] AS <stage-name>Every stage MUST have a descriptive name. Names are case-insensitive but ALWAYS use lowercase by convention.
FROM golang:1.22 AS build
FROM alpine:3.19 AS runtime
FROM build AS testCOPY --from (Artifact Extraction)
# Copy from a named stage
COPY --from=<stage-name> <src> <dest>
# Copy from an external image (auto-pulled)
COPY --from=<image>:<tag> <src> <dest>Examples:
# From a build stage
COPY --from=build /app/binary /usr/local/bin/app
# From an external image
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
# Multiple artifacts from different stages
COPY --from=build-backend /app/server /usr/local/bin/server
COPY --from=build-frontend /app/dist /var/www/html--target (Partial Builds)
Build only a specific stage and its dependencies:
# Build only the test stage
docker build --target test -t myapp:test .
# Build only the debug stage
docker build --target debug -t myapp:debug .
# Build the default (last) stage
docker build -t myapp:latest .BuildKit optimization: when using --target, BuildKit ONLY builds the target stage and stages it depends on. Unrelated stages are skipped entirely.
---
Decision Tree
When to Use Multi-Stage
Need to build/compile code in the container?
YES --> Use multi-stage (builder + runtime)
NO --> Does the image include dev dependencies or build tools?
YES --> Use multi-stage to separate them
NO --> Single stage MAY be acceptable for simple runtime images
Is the final image > 500 MB?
YES --> Multi-stage with a minimal base will likely reduce it significantly
NO --> Still consider multi-stage for security (no build tools in production)Choosing the Final Stage Base
Is the binary statically compiled? (Go with CGO_ENABLED=0, Rust with musl)
YES --> Use `scratch` or `gcr.io/distroless/static-debian12`
NO --> Does it need glibc?
YES --> Does it need a shell for debugging?
YES --> Use `alpine` or `*-slim` variant
NO --> Use `gcr.io/distroless/base-debian12`
NO --> Use `alpine`
Does the runtime need a language runtime? (Node, Python, Java, .NET)
YES --> Use the slim/JRE variant of that runtime
NO --> Use alpine or distroless---
Patterns
1. Basic Builder Pattern
The foundation of all multi-stage builds. Build in a full SDK image, run in a minimal image.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM alpine:3.19 AS production
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/server /usr/local/bin/server
USER app
ENTRYPOINT ["/usr/local/bin/server"]2. Build + Test + Production Pipeline
Run tests as a build gate. The production stage only depends on the build stage, so test failures do not affect the production image -- but CI pipelines MUST target the test stage first.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server ./cmd/server
FROM build AS test
RUN go test -v -race ./...
FROM alpine:3.19 AS production
COPY --from=build /app/server /usr/local/bin/server
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/server"]CI usage -- ALWAYS build test target before production:
docker build --target test .
docker build --target production -t myapp:latest .3. Shared Dependency Stage
When multiple stages need the same dependencies, create a shared base to avoid duplication.
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
FROM deps AS build
COPY . .
RUN npm run build
FROM deps AS test
COPY . .
RUN npm run test
FROM node:20-slim AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]4. Parallel Build Stages
BuildKit automatically parallelizes independent stages. Design your Dockerfile to maximize parallelism.
# syntax=docker/dockerfile:1
FROM node:20-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM golang:1.22 AS backend-build
WORKDIR /backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
FROM alpine:3.19 AS production
COPY --from=backend-build /server /usr/local/bin/server
COPY --from=frontend-build /frontend/dist /var/www/html
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/server"]frontend-build and backend-build execute simultaneously because neither depends on the other.
5. Debug Stage Pattern
Add debugging tools without polluting the production image:
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM alpine:3.19 AS production
COPY --from=build /app/server /usr/local/bin/server
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/server"]
FROM production AS debug
USER root
RUN apk add --no-cache curl strace busybox-extras
USER nobody:nobody# Production: lean
docker build -t myapp:latest .
# Debug: with tools
docker build --target debug -t myapp:debug .6. Cross-Platform Build Pattern
Use BuildKit platform ARGs for multi-architecture builds:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 \
go build -o /app/server ./cmd/server
FROM alpine:3.19
COPY --from=build /app/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest .---
Reference Links
- references/patterns.md -- Complete multi-stage patterns with full Dockerfiles
- references/examples.md -- Language-specific multi-stage examples (Node, Python, Go, Java, Rust, .NET)
- references/anti-patterns.md -- Multi-stage mistakes and how to fix them
Official Sources
- https://docs.docker.com/build/building/multi-stage/
- https://docs.docker.com/reference/dockerfile/
- https://docs.docker.com/build/building/best-practices/
- https://docs.docker.com/build/buildkit/
Multi-Stage Anti-Patterns
Common mistakes in multi-stage Dockerfiles, why they are wrong, and how to fix them.
All corrections verified against Docker Engine 24+ with BuildKit.
---
1. Referencing Stages by Numeric Index
Problem: Numeric indexes break when stages are added, removed, or reordered.
# BAD: Fragile numeric reference
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM alpine:3.19
COPY --from=0 /app/server /usr/local/bin/server# GOOD: Named stage reference
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM alpine:3.19 AS production
COPY --from=build /app/server /usr/local/bin/serverWhy: Adding a new stage before the build stage changes --from=0 to point to the wrong stage. Named references survive any reordering. ALWAYS name every stage.
---
2. Copying the Entire Build Stage
Problem: Copying everything from the build stage defeats the purpose of multi-stage builds.
# BAD: Copies source code, compilers, caches, and temp files
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM alpine:3.19
COPY --from=build /src /src# GOOD: Copy only the compiled artifact
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM alpine:3.19
COPY --from=build /app/server /usr/local/bin/serverWhy: The whole point of multi-stage is to exclude build artifacts. Copying the entire working directory brings compilers, source code, intermediate objects, and package caches into the production image. ALWAYS copy only the specific files needed at runtime.
---
3. Installing Build Tools in the Final Stage
Problem: Build tools in the production image waste space and create attack surface.
# BAD: gcc and build-essential in production
FROM python:3.12-slim AS production
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]# GOOD: Build tools in builder stage only
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
CMD ["python", "app.py"]Why: build-essential adds ~200 MB. libpq-dev (headers) is ~15 MB while libpq5 (runtime) is ~1 MB. Build tools are NEVER needed at runtime and provide attackers with compilers.
---
4. Not Separating Dependency Install from Code Copy
Problem: Copying all source code before installing dependencies invalidates the dependency cache on every code change.
# BAD: Any source change reruns npm ci
FROM node:20-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build# GOOD: Dependencies cached separately from source
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run buildWhy: npm ci takes 30-120 seconds. By copying only package.json and package-lock.json first, the install step is cached until dependencies actually change. Source code changes only trigger the build step.
---
5. Using latest Tag on Build Stage Base Images
Problem: latest resolves to a different image over time, breaking reproducibility.
# BAD: Non-deterministic base images
FROM golang:latest AS build
# ...
FROM alpine:latest AS production# GOOD: Pinned versions
FROM golang:1.22-alpine AS build
# ...
FROM alpine:3.19 AS production# BEST: Pinned by digest
FROM golang:1.22-alpine@sha256:abc123... AS build
# ...
FROM alpine:3.19@sha256:def456... AS productionWhy: A build that works today may fail tomorrow when latest points to a new major version. Pin at least the major.minor version. For critical production images, pin by digest for full reproducibility.
---
6. Forgetting CA Certificates with Scratch
Problem: Applications making HTTPS requests fail silently on scratch because no CA certificates exist.
# BAD: HTTPS requests will fail with x509 certificate errors
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server
FROM scratch
COPY --from=build /server /server
ENTRYPOINT ["/server"]# GOOD: Include CA certificates for HTTPS
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /server /server
ENTRYPOINT ["/server"]Why: scratch is completely empty -- no certificates, no timezone data, no user database. ALWAYS copy CA certificates if the application makes HTTPS calls. ALWAYS copy zoneinfo if the application uses time zones.
---
7. Not Using CGO_ENABLED=0 for Scratch Targets
Problem: Go binaries link against glibc by default, which does not exist on scratch or alpine (which uses musl).
# BAD: Binary requires glibc, fails on scratch
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /server
FROM scratch
COPY --from=build /server /server
ENTRYPOINT ["/server"]
# Runtime error: not found (missing dynamic linker)# GOOD: Static binary with no glibc dependency
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server
FROM scratch
COPY --from=build /server /server
ENTRYPOINT ["/server"]Why: Without CGO_ENABLED=0, Go links against glibc for DNS resolution and other OS features. The binary appears to exist but fails with "not found" because the dynamic linker (/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2) is missing. ALWAYS set CGO_ENABLED=0 when targeting scratch or distroless/static.
---
8. Running Tests Only in CI, Not in the Dockerfile
Problem: Tests that run outside the Dockerfile cannot leverage Docker's caching and may pass locally but fail in the container environment.
# BAD: No test stage -- tests run outside Docker
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM alpine:3.19
COPY --from=build /app/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]# GOOD: Test stage verifies code inside the same build environment
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server
FROM build AS test
RUN go test -v -race ./...
FROM alpine:3.19 AS production
COPY --from=build /app/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]Why: A test stage ensures tests run in the exact same environment as the build. CI pipelines use docker build --target test as a gate before building production. Tests are cached by BuildKit and only rerun when code changes.
---
9. Duplicating Dependency Installation Across Stages
Problem: Multiple stages independently install the same dependencies, wasting build time and bandwidth.
# BAD: Dependencies installed twice
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS test
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # Duplicate install!
COPY . .
RUN npm test# GOOD: Shared dependency stage
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM deps AS test
COPY . .
RUN npm testWhy: The deps stage runs once and is reused by both build and test. BuildKit caches the deps stage result, so both downstream stages start from the same cached layer.
---
10. Ignoring Layer Order in the Final Stage
Problem: Putting frequently-changing files before stable files ruins cache efficiency in the final image.
# BAD: Application code copied before stable dependencies
FROM node:20-slim AS production
WORKDIR /app
COPY --from=build /app/dist ./dist # Changes often
COPY --from=deps /app/node_modules ./node_modules # Changes rarely
COPY package.json ./
CMD ["node", "dist/index.js"]# GOOD: Stable layers first, volatile layers last
FROM node:20-slim AS production
WORKDIR /app
COPY package.json ./ # Almost never changes
COPY --from=deps /app/node_modules ./node_modules # Changes rarely
COPY --from=build /app/dist ./dist # Changes often
CMD ["node", "dist/index.js"]Why: Docker caches layers sequentially. When dist changes (which happens on every code push), all layers AFTER it are invalidated. By putting node_modules before dist, the modules layer stays cached even when application code changes.
---
11. Not Pruning devDependencies for Node.js Production
Problem: Production image includes test frameworks, linters, and build tools in node_modules.
# BAD: devDependencies shipped to production
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules # Includes devDeps!
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]# GOOD: Production-only dependencies
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
RUN npm prune --production
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]Why: devDependencies can add 100-500 MB to node_modules. npm prune --production removes them after the build step completes. ALWAYS prune before copying to the production stage.
---
12. Missing --no-install-recommends on apt-get
Problem: apt installs recommended but unnecessary packages, adding 50-200 MB to build stages.
# BAD: Installs recommended packages (man pages, docs, extra tools)
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y build-essential# GOOD: Only required packages
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*Why: --no-install-recommends prevents apt from pulling in suggested and recommended packages. In a build stage this is less critical (the stage is discarded), but it speeds up the build and reduces the chance of conflicting packages. In a production stage it is CRITICAL for image size.
Language-Specific Multi-Stage Examples
Production-ready multi-stage Dockerfiles for six major languages.
All examples verified against Docker Engine 24+ with BuildKit.
---
Go
Go is the ideal language for multi-stage builds because it produces statically linked binaries that run on scratch.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS build
ARG TARGETOS TARGETARCH
ARG VERSION=dev
WORKDIR /src
# Cache module download (only reruns when go.mod/go.sum change)
COPY go.mod go.sum ./
RUN go mod download -x
COPY . .
# Cross-compile static binary
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags="-s -w -X main.version=$VERSION" \
-o /app/server ./cmd/server
# ---- Test (optional CI target) ----
FROM build AS test
RUN CGO_ENABLED=0 go test -v ./...
# ---- Production ----
FROM scratch AS production
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /app/server /server
USER 65534:65534
ENTRYPOINT ["/server"]Key Go decisions:
CGO_ENABLED=0-- ALWAYS set for scratch/distroless targets. Without it, Go links against glibc.-ldflags="-s -w"-- Strips debug symbols, reduces binary size by ~30%.--platform=$BUILDPLATFORMon build stage -- Compiles natively, cross-compiles for target. 10x faster than QEMU emulation.- Copy CA certs and timezone data from the build stage --
scratchhas nothing.
Final image size: ~10-20 MB (binary only, no OS).
---
Node.js
Node.js requires a runtime, so the final image uses node:*-slim instead of scratch.
# syntax=docker/dockerfile:1
# ---- Dependencies ----
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# ---- Build ----
FROM deps AS build
COPY . .
RUN npm run build
# Remove devDependencies after build
RUN npm prune --production
# ---- Test (optional CI target) ----
FROM deps AS test
COPY . .
RUN npm run lint && npm run test
# ---- Production ----
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
# Copy production node_modules (no devDependencies)
COPY --from=build /app/node_modules ./node_modules
# Copy built application
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
CMD ["node", "dist/index.js"]Key Node.js decisions:
npm ciin deps stage -- Installs exact versions from lockfile. NEVER usenpm installin Docker builds.npm prune --production-- Removes devDependencies after build, before copying to production.--ignore-scripts-- Prevents postinstall scripts from running during dependency install (security).node:20-slimfor production -- ~180 MB vs ~1.1 GB for fullnode:20.- Built-in
nodeuser -- Node images include a non-rootnodeuser. ALWAYS use it.
Final image size: ~180-250 MB.
---
Python
Python multi-stage builds use virtual environments to isolate dependencies for clean copying.
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# ---- Dependencies ----
FROM base AS deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Use a virtual environment for clean copying
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-compile -r requirements.txt
# ---- Test (optional CI target) ----
FROM deps AS test
COPY requirements-dev.txt .
RUN pip install --no-compile -r requirements-dev.txt
COPY . /app
WORKDIR /app
RUN pytest --tb=short -q
# ---- Production ----
FROM base AS production
WORKDIR /app
# Copy the entire virtual environment (includes all installed packages)
COPY --from=deps /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
RUN groupadd -r app && useradd -r -g app app
USER app
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000", "--workers", "4"]Key Python decisions:
- Virtual environment (
/opt/venv) -- ALWAYS use a venv so dependencies can be copied as a single directory. Without it, packages scatter across system directories. build-essentialandlibpq-devin deps stage only -- Needed to compile C extensions (psycopg2, numpy). NEVER include in production.libpq5in base and production -- Runtime library for PostgreSQL. Build headers (libpq-dev) stay in deps.--no-compile-- Skip.pycgeneration during install (happens at runtime). Smaller image.- Cache mount on pip -- Avoids re-downloading packages on rebuild.
Final image size: ~150-250 MB.
---
Java (Spring Boot / Maven)
Java uses a JDK for building and a JRE for running.
# syntax=docker/dockerfile:1
# ---- Build ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
# Cache Maven dependencies
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw
RUN --mount=type=cache,target=/root/.m2/repository \
./mvnw dependency:resolve dependency:resolve-plugins
COPY src ./src
RUN --mount=type=cache,target=/root/.m2/repository \
./mvnw package -DskipTests -Dmaven.javadoc.skip=true
# Extract layers for optimized Docker layering (Spring Boot 3+)
RUN java -Djarmode=layertools -jar target/*.jar extract --destination /extracted
# ---- Test (optional CI target) ----
FROM build AS test
RUN --mount=type=cache,target=/root/.m2/repository \
./mvnw test
# ---- Production ----
FROM eclipse-temurin:21-jre AS production
WORKDIR /app
# Spring Boot layered extraction (most stable layers first)
COPY --from=build /extracted/dependencies/ ./
COPY --from=build /extracted/spring-boot-loader/ ./
COPY --from=build /extracted/snapshot-dependencies/ ./
COPY --from=build /extracted/application/ ./
RUN groupadd -r app && useradd -r -g app app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Key Java decisions:
eclipse-temurin:21-jdkfor build,eclipse-temurin:21-jrefor production -- JDK has compiler tools (~700 MB), JRE has runtime only (~220 MB).- Spring Boot layered extraction -- Splits the JAR into layers ordered by change frequency. Dependencies (rarely change) cache separately from application code (changes often).
- Maven wrapper (
mvnw) -- Ensures consistent Maven version. ALWAYS commit the wrapper to version control. dependency:resolvein separate step -- Caches Maven downloads independently from compilation.
Final image size: ~220-300 MB.
---
Rust
Rust produces statically linked binaries (with musl) that run on scratch, similar to Go.
# syntax=docker/dockerfile:1
FROM rust:1.77-alpine AS build
RUN apk add --no-cache musl-dev
WORKDIR /src
# Cache dependency compilation (Rust's biggest bottleneck)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry/ \
--mount=type=cache,target=/src/target \
cargo build --release
# Remove the dummy build artifact
RUN rm -rf src target/release/deps/$(echo "${PWD##*/}" | tr '-' '_')*
# Build the real application
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry/ \
--mount=type=cache,target=/src/target \
cargo build --release \
&& cp target/release/myapp /usr/local/bin/myapp
# ---- Test (optional CI target) ----
FROM build AS test
RUN --mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry/ \
--mount=type=cache,target=/src/target \
cargo test
# ---- Production ----
FROM scratch AS production
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/local/bin/myapp /myapp
USER 65534:65534
ENTRYPOINT ["/myapp"]Key Rust decisions:
rust:1.77-alpine+musl-dev-- Alpine uses musl libc, producing fully static binaries. ALWAYS use alpine for scratch targets.- Dummy
main.rstrick -- Compiles dependencies first. When only source code changes, dependencies are cached. This saves 5-15 minutes on large projects. - Cache mounts on cargo registry and target -- Rust compilation is slow. Cache mounts persist compiled dependencies across builds.
cpfrom cache mount target -- Cache mounts are not included in the layer, so ALWAYS copy the binary out before the RUN ends.
Final image size: ~5-15 MB (binary only, no OS).
---
.NET
.NET uses the SDK for building and the ASP.NET runtime for running.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore dependencies (cached unless .csproj files change)
COPY *.sln .
COPY src/MyApp/*.csproj src/MyApp/
COPY src/MyApp.Tests/*.csproj src/MyApp.Tests/
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
# Build
COPY src/ src/
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
# ---- Test (optional CI target) ----
FROM build AS test
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet test --no-restore --verbosity normal
# ---- Production ----
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS production
WORKDIR /app
COPY --from=build /app/publish .
RUN groupadd -r app && useradd -r -g app app
USER app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "MyApp.dll"]Key .NET decisions:
dotnet/sdk:8.0for build,dotnet/aspnet:8.0for production -- SDK is ~900 MB (includes compiler, NuGet, analyzers). ASP.NET runtime is ~220 MB.- Copy
.csprojfiles first, thendotnet restore-- NuGet restore is cached until project references change. dotnet publish -c Release-- Produces optimized, ready-to-deploy output.--no-restoreon publish -- Skips redundant restore since it was done in a prior step.ASPNETCORE_URLS-- Configures Kestrel to listen on the correct port.
For self-contained deployments (no runtime needed):
RUN dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:PublishSingleFile=true \
-p:PublishTrimmed=true \
-o /app/publishWith self-contained + trimmed, the final image can use mcr.microsoft.com/dotnet/runtime-deps:8.0 (~30 MB) or even alpine.
Final image size: ~220-300 MB (framework-dependent) or ~80-120 MB (self-contained + trimmed).
Multi-Stage Build Patterns
Complete Dockerfiles for every major multi-stage pattern.
All patterns verified against Docker Engine 24+ with BuildKit.
---
1. Minimal Builder Pattern (Scratch Final)
The smallest possible production image. ONLY works with statically compiled binaries.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM scratch AS production
COPY --from=build /app/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]Key points:
CGO_ENABLED=0produces a fully static binary (no glibc dependency).-ldflags="-s -w"strips debug info and symbol tables, reducing binary size by ~30%.scratchhas NO shell, NO filesystem, NO user database -- the binary must be completely self-contained.- ALWAYS copy CA certificates if the binary makes HTTPS requests.
- ALWAYS copy timezone data (
/usr/share/zoneinfo) if the binary usestime.LoadLocation.
---
2. Distroless Final Stage
More secure than alpine (no shell for attackers to use) but provides glibc and CA certs.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot AS production
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]Distroless variants:
| Image | Size | Includes |
|---|---|---|
gcr.io/distroless/static-debian12 | ~2 MB | CA certs, tzdata, /etc/passwd |
gcr.io/distroless/base-debian12 | ~20 MB | Above + glibc |
gcr.io/distroless/cc-debian12 | ~25 MB | Above + libgcc |
gcr.io/distroless/java21-debian12 | ~220 MB | Above + OpenJDK 21 JRE |
gcr.io/distroless/nodejs22-debian12 | ~130 MB | Above + Node.js 22 |
gcr.io/distroless/python3-debian12 | ~50 MB | Above + Python 3 |
ALWAYS use the :nonroot tag variant to run as UID 65534 without explicit USER instruction.
---
3. Build + Test + Lint Pipeline
Three-stage pipeline where each stage serves a CI/CD purpose.
# syntax=docker/dockerfile:1
# ---- Stage 1: Build ----
FROM golang:1.22 AS build
WORKDIR /src
# Cache dependencies separately
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# ---- Stage 2: Lint ----
FROM golangci/golangci-lint:v1.57 AS lint
WORKDIR /src
COPY --from=build /src /src
RUN golangci-lint run ./...
# ---- Stage 3: Test ----
FROM build AS test
RUN go test -v -race -coverprofile=/coverage.out ./...
# ---- Stage 4: Production ----
FROM alpine:3.19 AS production
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/server /usr/local/bin/server
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/server"]CI pipeline usage:
# Step 1: Run lint (fails fast)
docker build --target lint .
# Step 2: Run tests
docker build --target test .
# Step 3: Build production image
docker build --target production -t myapp:latest .BuildKit parallelism: lint and test stages run in parallel because both depend only on build, not on each other.
---
4. Shared Dependency Base Pattern
When multiple stages need identical dependencies, create a shared base stage. This avoids downloading and installing packages multiple times.
# syntax=docker/dockerfile:1
# ---- Shared base with system dependencies ----
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ---- Dependencies stage ----
FROM base AS deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- Test stage ----
FROM deps AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
RUN pytest --tb=short
# ---- Production stage ----
FROM base AS production
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
COPY . .
RUN groupadd -r app && useradd -r -g app app
USER app
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000"]Stage dependency graph:
base --> deps --> test
| |
+--------+--> production---
5. Parallel Frontend + Backend Pattern
Build independent components simultaneously. BuildKit detects that frontend and backend have no dependency relationship and runs them in parallel.
# syntax=docker/dockerfile:1
# ---- Frontend Build (runs in parallel with backend) ----
FROM node:20-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# ---- Backend Build (runs in parallel with frontend) ----
FROM golang:1.22-alpine AS backend
WORKDIR /backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
# ---- Production (depends on both, waits for both to finish) ----
FROM alpine:3.19 AS production
RUN addgroup -S app && adduser -S app -G app
COPY --from=backend /server /usr/local/bin/server
COPY --from=frontend /frontend/dist /var/www/html
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]---
6. Multi-Architecture Build Pattern
Use BuildKit platform ARGs for cross-compilation. The build stage runs on the BUILD platform for speed, while the output targets the specified platform.
# syntax=docker/dockerfile:1
# Build on host architecture for speed
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS build
# Target architecture variables (auto-set by BuildKit)
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Cross-compile for the target platform
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags="-s -w" -o /app/server ./cmd/server
# Runtime image uses the target platform automatically
FROM alpine:3.19
COPY --from=build /app/server /usr/local/bin/server
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/server"]Build for multiple platforms:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
-t myregistry/myapp:latest \
--push .Key: --platform=$BUILDPLATFORM on the build stage means compilation runs natively (fast), while cross-compiling for the target architecture. Without this flag, Docker would emulate the target architecture for the entire build (slow).
---
7. Cache-Optimized Builder Pattern
Maximize cache hits by separating dependency installation from code compilation.
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
# Step 1: Copy ONLY dependency manifests (changes rarely)
COPY package.json package-lock.json ./
# Step 2: Install dependencies (cached unless manifests change)
RUN npm ci
# ---- Build stage ----
FROM deps AS build
# Step 3: Copy source code (changes frequently)
COPY . .
# Step 4: Build (only reruns when source changes)
RUN npm run build
# ---- Production ----
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]Cache behavior:
- Change
package.json--> Steps 2, 3, 4 all rerun. - Change source code only --> Step 2 is cached, only steps 3 and 4 rerun.
- Change nothing --> Everything is cached.
---
8. COPY --from External Image Pattern
Pull files from published images without building them. Useful for including third-party tools.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app/server ./cmd/server
FROM alpine:3.19 AS production
# Copy tools from external images
COPY --from=busybox:uclibc /bin/wget /usr/local/bin/wget
COPY --from=nginx:1.25-alpine /etc/nginx/nginx.conf /etc/nginx/default.conf
COPY --from=build /app/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]NEVER use COPY --from with :latest tag on external images. ALWAYS pin to a specific version for reproducibility.
---
9. Monorepo Multi-Service Pattern
Build multiple services from a single Dockerfile using --target.
# syntax=docker/dockerfile:1
# ---- Shared dependency base ----
FROM golang:1.22 AS base
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# ---- API Service ----
FROM base AS build-api
RUN CGO_ENABLED=0 go build -o /bin/api ./cmd/api
FROM alpine:3.19 AS api
COPY --from=build-api /bin/api /usr/local/bin/api
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/api"]
# ---- Worker Service ----
FROM base AS build-worker
RUN CGO_ENABLED=0 go build -o /bin/worker ./cmd/worker
FROM alpine:3.19 AS worker
COPY --from=build-worker /bin/worker /usr/local/bin/worker
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/worker"]
# ---- Migration Tool ----
FROM base AS build-migrate
RUN CGO_ENABLED=0 go build -o /bin/migrate ./cmd/migrate
FROM alpine:3.19 AS migrate
COPY --from=build-migrate /bin/migrate /usr/local/bin/migrate
USER nobody:nobody
ENTRYPOINT ["/usr/local/bin/migrate"]Build each service independently:
docker build --target api -t myapp-api:latest .
docker build --target worker -t myapp-worker:latest .
docker build --target migrate -t myapp-migrate:latest .---
10. Security-Hardened Production Pattern
Production image with all security best practices applied.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /src
# Use cache mounts for faster rebuilds
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=bind,source=go.sum,target=go.sum \
--mount=type=bind,source=go.mod,target=go.mod \
go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM alpine:3.19 AS production
# Remove unnecessary packages and caches
RUN apk --no-cache add ca-certificates tzdata \
&& rm -rf /var/cache/apk/*
# Create non-root user with explicit UID/GID
RUN addgroup -g 10001 -S app \
&& adduser -u 10001 -S app -G app -h /app -s /sbin/nologin
# Copy only the binary
COPY --from=build /app/server /usr/local/bin/server
# Make filesystem read-only friendly
RUN chmod 555 /usr/local/bin/server
# Metadata
LABEL org.opencontainers.image.title="My App" \
org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.licenses="MIT"
# 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
# Switch to non-root user
USER app:app
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]Security checklist for multi-stage production images:
- Non-root user with explicit UID/GID
- No build tools or compilers in final image
- No source code in final image
- No package manager caches in final image
- CA certificates present for HTTPS
- Read-only filesystem compatible
- HEALTHCHECK defined
- OCI metadata labels