Overview
A well-written Dockerfile produces small, secure, fast-building images. A poorly written one produces multi-gigabyte images that rebuild from scratch on every code change. This tutorial walks through the practices that matter most in production.
Why Image Size Matters
| Impact area | Effect of a large image |
|---|---|
| Build time | Slower pulls in CI |
| Deploy time | Longer rollout on every node |
| Storage cost | Higher registry and node disk usage |
| Security | More packages and CVEs to patch |
| Cold start | Slower container launch on serverless platforms |
Start from a Minimal Base Image
| Base | Typical size | Notes |
|---|---|---|
ubuntu:24.04 | ~78 MB | Full userland, familiar tools |
debian:bookworm-slim | ~75 MB | Stripped but still apt-based |
alpine:3.20 | ~7 MB | musl libc; some binaries do not run |
distroless/static | ~2 MB | No shell; best for compiled Go/Rust |
scratch | 0 MB | Only for fully static binaries |
Choose the smallest base that satisfies your runtime requirements. An Alpine or distroless image significantly reduces attack surface.
Multi-Stage Builds
Multi-stage builds keep build tools out of the final image.
Bad: Single Stage
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Result: 1.1 GB — includes devDependencies, source, and build cache
Good: Multi-Stage
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime stage ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Result: ~150 MB
Go Example with Scratch
FROM golang:1.23-alpine AS builder
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
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["/server"]
# Result: ~8 MB
Layer Caching
Docker caches each instruction as a layer. When a layer's inputs change, that layer and every layer after it are rebuilt.
Bad: Copy Everything First
COPY . .
RUN npm install
# Any source file change invalidates npm install
Good: Dependencies First
COPY package*.json ./
RUN npm ci
COPY . .
# npm install is cached unless package.json changes
The rule is simple: put things that change rarely near the top, and things that change often near the bottom.
Combining RUN Commands
Each RUN creates a layer. Combine related commands and clean up in the same layer.
# Bad — apt lists remain in the layer
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# Good — single layer, no leftover metadata
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*
Use .dockerignore
Exclude files from the build context. This speeds up the build and prevents leaking secrets.
# .dockerignore
.git
.gitignore
node_modules
dist
coverage
.env
.env.*
*.log
Dockerfile
docker-compose*.yml
README.md
.vscode
.idea
Run as a Non-Root User
By default, containers run as root. If an attacker escapes the container, they inherit root on the host in some configurations.
# Debian / Ubuntu
RUN groupadd -r app && useradd -r -g app app
USER app
# Alpine
RUN addgroup -S app && adduser -S app -G app
USER app
# Node.js images already include a node user
USER node
For a fixed numeric UID (recommended for Kubernetes runAsNonRoot):
USER 1000:1000
Pin Image Versions
# Avoid — tag can change without warning
FROM node:latest
# Better — pin to a major version
FROM node:20-alpine
# Best — pin to a digest for full reproducibility
FROM node:20-alpine@sha256:2f202d5f...
Health Checks
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
Health checks let orchestrators restart unhealthy containers and delay traffic until the app is ready.
Metadata Labels
LABEL org.opencontainers.image.title="My API" \
org.opencontainers.image.version="1.4.0" \
org.opencontainers.image.source="https://github.com/example/my-api" \
org.opencontainers.image.licenses="MIT"
Environment Variables and Secrets
Never bake secrets into an image. ENV values are visible in docker history.
# Never do this
ENV API_KEY=sk_live_abc123
# Inject at runtime instead
docker run -e API_KEY=$API_KEY myapp
# Or use BuildKit secrets for build-time needs
# RUN --mount=type=secret,id=npmrc npm ci
BuildKit Features
| Feature | Benefit |
|---|---|
| Cache mounts | Persist package manager caches across builds |
| Secret mounts | Pass secrets at build time without storing in layers |
| SSH mounts | Clone private repositories during build |
| Multi-platform builds | Build amd64 and arm64 images in one command |
| Inline cache | Store build cache in the image registry |
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 --push .
Before and After Comparison
| Practice | Typical improvement |
|---|---|
| Multi-stage build | 60–90% smaller image |
| Alpine or distroless base | Another 50% smaller |
| Dependencies before source | Build time drops from minutes to seconds on code-only changes |
| .dockerignore | Smaller build context, faster upload to the daemon |
| Non-root user | Reduced blast radius on compromise |
Checklist
- Use a multi-stage build.
- Start from a minimal base image.
- Copy dependency manifests before application code.
- Combine
RUNcommands and clean up in the same layer. - Add a
.dockerignorefile. - Run as a non-root user.
- Pin base image versions.
- Never embed secrets in the image.
- Add a
HEALTHCHECK. - Scan images for vulnerabilities with Trivy or Grype in CI.
