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 areaEffect of a large image
Build timeSlower pulls in CI
Deploy timeLonger rollout on every node
Storage costHigher registry and node disk usage
SecurityMore packages and CVEs to patch
Cold startSlower container launch on serverless platforms

Start from a Minimal Base Image

BaseTypical sizeNotes
ubuntu:24.04~78 MBFull userland, familiar tools
debian:bookworm-slim~75 MBStripped but still apt-based
alpine:3.20~7 MBmusl libc; some binaries do not run
distroless/static~2 MBNo shell; best for compiled Go/Rust
scratch0 MBOnly 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

FeatureBenefit
Cache mountsPersist package manager caches across builds
Secret mountsPass secrets at build time without storing in layers
SSH mountsClone private repositories during build
Multi-platform buildsBuild amd64 and arm64 images in one command
Inline cacheStore 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

PracticeTypical improvement
Multi-stage build60–90% smaller image
Alpine or distroless baseAnother 50% smaller
Dependencies before sourceBuild time drops from minutes to seconds on code-only changes
.dockerignoreSmaller build context, faster upload to the daemon
Non-root userReduced blast radius on compromise

Checklist

  • Use a multi-stage build.
  • Start from a minimal base image.
  • Copy dependency manifests before application code.
  • Combine RUN commands and clean up in the same layer.
  • Add a .dockerignore file.
  • 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.

Scan your images automatically in CI with Trivy or Grype.