Dockerfile Mastery: From Basics to Production¶
Core Concepts¶
Every docker build produces an image — a read-only, ordered stack of layers. Each instruction in a Dockerfile adds a layer. Containers are ephemeral processes running on top of that stack with a thin writable layer.
Image = Base Layer + Instruction Layer(s) + Metadata
Container = Image + Writable Layer (dies with container)
Mental Model
Think of a Dockerfile as a reproducible shell script that captures your environment. Unlike shell scripts, every RUN, COPY, and ADD instruction is cached independently and executed in an isolated filesystem snapshot.
Instruction Reference¶
Structural Instructions¶
| Instruction | Purpose | Notes |
|---|---|---|
FROM |
Set base image | First non-comment line; FROM scratch for minimal images |
LABEL |
Add metadata | Use for versioning, maintainer, CI info |
WORKDIR |
Set working directory | Creates dir if missing; prefer over RUN mkdir && cd |
SHELL |
Override default shell | Default: /bin/sh -c on Linux |
FROM ubuntu:24.04
LABEL org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.authors="team@example.com" \
org.opencontainers.image.source="https://github.com/org/repo"
WORKDIR /app
SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
SHELL matters
The default /bin/sh -c silently ignores pipe failures. Use bash -euxo pipefail or explicitly check exit codes for every piped command.
Execution Instructions¶
# RUN — executes during build; creates a new layer
RUN apt-get update && apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/* # Always clean up in same RUN
# CMD — default command when container starts; overridable at runtime
CMD ["node", "server.js"]
# ENTRYPOINT — process that always runs; CMD becomes its default args
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["postgres"]
File System Instructions¶
# COPY — preferred for local files; respects .dockerignore
COPY src/ ./src/
COPY --chown=node:node package*.json ./
# ADD — only use for remote URLs or auto-extracting tarballs
ADD https://example.com/config.tar.gz /etc/app/ # auto-extracts
# For everything else: use COPY
# VOLUME — declares a mount point; doesn't create host mount
VOLUME ["/data", "/logs"]
ADD vs COPY
Prefer COPY over ADD for local files. ADD with a URL bypasses the build cache and can introduce supply chain risks. Use curl in a RUN step if you need checksum verification.
Runtime Configuration¶
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info
EXPOSE 3000 # Documentation only; does NOT publish the port
EXPOSE 3000/udp # Specify protocol if non-TCP
USER nonroot # Run as non-root (see hardening section)
STOPSIGNAL SIGTERM # Signal sent on docker stop (default: SIGTERM)
Build Context & .dockerignore¶
The build context is everything sent to the Docker daemon at build time. A bloated context = slow builds.
# See your context size before building
du -sh .
# Build with explicit context path
docker build -t myapp:latest -f docker/Dockerfile .
docker build -t myapp:latest -f docker/Dockerfile ./src # narrowed context
.dockerignore¶
# Version control
.git
.gitignore
# Dependencies (always reinstalled in image)
node_modules
vendor/
__pycache__/
*.pyc
.venv
# Build artifacts
dist/
build/
target/
*.o *.a
# Dev/test
*.test.js
*.spec.ts
coverage/
.nyc_output
.pytest_cache
# Secrets & local config
.env
.env.*
*.pem
*.key
secrets/
# Tooling
.DS_Store
Thumbs.db
.editorconfig
.eslintrc*
.prettierrc
# Docker files themselves (optional, but clean)
Dockerfile*
docker-compose*
Negation Patterns
Use ! to re-include files after a broad exclusion:
Layer Caching Strategy¶
Cache invalidation is the #1 cause of slow builds. Order instructions from least-changing to most-changing.
# ❌ BAD — code copy before dependency install
FROM node:22-alpine
WORKDIR /app
COPY . . # changes every commit → busts cache
RUN npm ci # reinstalls everything every build
# ✅ GOOD — dependencies cached independently
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # only changes when deps change
RUN npm ci --omit=dev
COPY . . # code changes don't bust dep cache
Cache Busting Techniques¶
# Force cache bust at a specific point with a build arg
ARG CACHE_BUST=1
RUN apt-get update && apt-get install -y ...
# Build with: docker build --build-arg CACHE_BUST=$(date +%s) .
Cache Mounts (BuildKit)¶
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
npm ci
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y curl
Cache mounts persist between builds on the same host — dramatically faster CI builds.
Multi-Stage Builds¶
Multi-stage builds are the single biggest win for production images. Compile in one stage, ship only the runtime in another.
Pattern 1: Builder + Runtime¶
# syntax=docker/dockerfile:1
# ── Stage 1: Build ──────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: Production ─────────────────────────────────────────────
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json .
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Pattern 2: Compiled Binary (Go)¶
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app ./cmd/server
# Distroless — no shell, no package manager, minimal attack surface
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /build/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
Pattern 3: Test Stage in Pipeline¶
FROM base AS test
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm test
FROM test AS lint
RUN npm run lint
FROM production AS final
# docker build --target final . → runs everything
# docker build --target test . → run tests only
Selective Stage Targeting¶
ARG vs ENV¶
| Feature | ARG |
ENV |
|---|---|---|
| Available at | Build time only | Build time + runtime |
Visible in docker inspect |
No (after layer) | Yes |
| Override at runtime | No | docker run -e KEY=val |
| Use case | Versions, build flags | App config, runtime vars |
# ARG: build-time only
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine
ARG BUILD_DATE
ARG GIT_SHA
LABEL build.date="${BUILD_DATE}" build.sha="${GIT_SHA}"
# ENV: persists into container
ENV APP_PORT=3000 \
APP_ENV=production
# Combine: ARG sets default, ENV uses it (with override capability)
ARG APP_VERSION=unknown
ENV APP_VERSION=${APP_VERSION}
Never put secrets in ARG or ENV
Both are visible in docker history and docker inspect. Use BuildKit secret mounts instead (see Secrets Management).
ENTRYPOINT vs CMD¶
| Combination | ENTRYPOINT | CMD | Result |
|---|---|---|---|
| Only CMD | — | ["node", "app.js"] |
node app.js |
| Only ENTRYPOINT | ["node"] |
— | node |
| Both | ["node"] |
["app.js"] |
node app.js |
| Override CMD | ["node"] |
["app.js"] |
docker run img server.js → node server.js |
# Shell form — spawns /bin/sh -c; signal handling is broken (PID 1 issue)
ENTRYPOINT node server.js # ❌ avoid
# Exec form — process is PID 1; receives signals correctly
ENTRYPOINT ["node", "server.js"] # ✅ preferred
# Wrapper script pattern — init tasks then exec to main process
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]
docker-entrypoint.sh Pattern¶
#!/bin/sh
set -e
# Run migrations, wait for DB, etc.
if [ "$1" = "node" ]; then
echo "Running DB migrations..."
node scripts/migrate.js
fi
exec "$@" # Replace shell with CMD (preserves PID 1 and signals)
User & Permission Hardening¶
# Create a dedicated non-root user
RUN groupadd --gid 1001 appgroup \
&& useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser
# Alpine-specific
RUN addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
# Set ownership at COPY time (more efficient than a separate RUN chown)
COPY --chown=appuser:appgroup . .
# Switch to non-root
USER appuser
# Verify in CI
RUN [ "$(id -u)" != "0" ] || (echo "ERROR: running as root" && exit 1)
Read-Only Filesystem¶
Secrets Management¶
BuildKit Secret Mounts (Recommended)¶
# syntax=docker/dockerfile:1
# Mounts secret only during this RUN layer — NOT stored in image
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
pip install -r requirements.txt
# Pass secret at build time
docker build \
--secret id=npmrc,src=$HOME/.npmrc \
--secret id=pip_conf,src=/etc/pip.conf \
-t myapp .
SSH Agent Forwarding¶
Health Checks¶
# Basic HTTP health check
HEALTHCHECK --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Using wget (smaller than curl in Alpine)
HEALTHCHECK --interval=15s --timeout=3s \
CMD wget -qO- http://localhost:8080/ping || exit 1
# TCP check
HEALTHCHECK CMD nc -z localhost 5432 || exit 1
# Disable inherited health check
HEALTHCHECK NONE
# Check container health status
docker inspect --format='{{.State.Health.Status}}' <container>
docker inspect --format='{{json .State.Health}}' <container> | jq
Language-Specific Patterns¶
Node.js¶
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM base AS build
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
FROM base AS production
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
COPY --from=deps --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
Python¶
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
FROM base AS builder
RUN pip install uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM base AS production
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
COPY . .
RUN useradd -r -u 1001 appuser && chown -R appuser /app
USER appuser
CMD ["gunicorn", "app:app", "-w", "4", "-b", "0.0.0.0:8000"]
Java (JVM)¶
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN --mount=type=cache,target=/root/.m2 \
./mvnw dependency:go-offline -q
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
./mvnw package -DskipTests
FROM eclipse-temurin:21-jre-alpine AS production
RUN addgroup -S javaapp && adduser -S javaapp -G javaapp
WORKDIR /app
COPY --from=builder --chown=javaapp:javaapp /build/target/*.jar app.jar
USER javaapp
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Rust¶
# syntax=docker/dockerfile:1
FROM rust:1.82-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
# Cache deps by building a dummy main first
RUN mkdir src && echo "fn main(){}" > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release
RUN rm src/main.rs
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release
FROM scratch AS production
COPY --from=builder /build/target/release/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Image Optimization¶
Choose the Right Base¶
| Base | Size | Use When |
|---|---|---|
ubuntu / debian |
~80–120 MB | Need full apt ecosystem |
debian:slim |
~30–80 MB | Most production apps |
alpine |
~5 MB | Small, security-conscious; beware musl libc |
distroless |
~2–20 MB | No shell, minimal attack surface |
scratch |
0 MB | Static binaries only (Go, Rust) |
Minimize Layer Count¶
# ❌ BAD — 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ GOOD — 1 layer
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Dive Tool — Inspect Layer Efficiency¶
Security Hardening Checklist¶
# 1. Pin base image with digest (immutable)
FROM node:22.11.0-alpine3.20@sha256:abc123...
# 2. Non-root user
USER 1001:1001
# 3. Drop capabilities at runtime
# docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
# 4. Read-only filesystem at runtime
# docker run --read-only --tmpfs /tmp myapp
# 5. No new privileges
# docker run --security-opt=no-new-privileges myapp
# 6. Resource limits
# docker run --memory=512m --cpus=1 myapp
# 7. Scan for vulnerabilities
# docker scout cves myapp:latest
# trivy image myapp:latest
# grype myapp:latest
Common Anti-Patterns¶
| Anti-Pattern | Problem | Fix |
|---|---|---|
FROM ubuntu:latest |
Non-deterministic, cache busts | Pin: ubuntu:24.04 or use digest |
RUN apt-get upgrade |
Creates untracked state divergence | Use base with security patches |
Secrets in ENV or ARG |
Visible in docker history |
Use --mount=type=secret |
| Running as root | Security risk | USER nonroot |
COPY . . before deps |
Busts cache on every code change | Copy manifests first |
Shell form ENTRYPOINT |
Broken signal handling (no graceful shutdown) | Use exec form ["cmd", "arg"] |
| Storing state in container | Lost on restart | Use volumes or external storage |
| Large build context | Slow builds, accidental secret leaks | Use .dockerignore |
ADD for local files |
Implicit behavior, less auditable | Use COPY |
| Multiple processes in one container | Violates single-responsibility, complicates logging | Use separate containers + orchestration |
BuildKit Features¶
Enable BuildKit (default in Docker 23+, explicit in older versions):
Here-Documents¶
# syntax=docker/dockerfile:1
RUN <<EOF
set -ex
apt-get update
apt-get install -y curl git
rm -rf /var/lib/apt/lists/*
EOF
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
listen 80;
location / { proxy_pass http://app:3000; }
}
EOF
Bind Mounts at Build Time¶
# Mount local source without COPY — useful for build steps that don't need cache
RUN --mount=type=bind,source=.,target=/src \
cd /src && make install-to=/app
Named Caches Summary¶
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/root/.m2 mvn install
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod go mod download
RUN --mount=type=cache,target=/var/cache/apt apt-get install -y curl
Quick Reference Card¶
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────
# DOCKERFILE QUICK REFERENCE
# ─────────────────────────────────────────────────────
FROM image:tag AS stagename # Base image + stage name
LABEL key="value" # OCI metadata
WORKDIR /path # cd + mkdir -p
SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
ARG NAME=default # Build-time variable
ENV KEY=value # Runtime environment variable
# File operations
COPY [--chown=u:g] src dest # Local file copy (preferred)
ADD src dest # Remote URL / auto-extract tar only
# Execution
RUN command # Build step (creates layer)
RUN --mount=type=cache,target=/path command
RUN --mount=type=secret,id=name,target=/path command
# Runtime
EXPOSE port[/proto] # Document port (not publish)
VOLUME ["/data"] # Declare mount point
USER uid:gid # Switch user
# Process
ENTRYPOINT ["cmd", "arg"] # Always-run process (exec form)
CMD ["default", "arg"] # Default args (overridable)
STOPSIGNAL SIGTERM # Graceful stop signal
# Observability
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
# Multi-stage
COPY --from=stagename /src /dest # Copy artifact between stages
Build Commands Reference¶
# Basic build
docker build -t name:tag .
docker build -t name:tag -f path/Dockerfile .
# BuildKit with secrets and SSH
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
--ssh default \
--build-arg VERSION=1.2.3 \
--platform linux/amd64,linux/arm64 \
--push \
-t registry/image:tag .
# Target a specific stage
docker build --target test -t myapp:test .
# Inspect image
docker image inspect myapp:latest
docker history --no-trunc myapp:latest
docker scout cves myapp:latest
dive myapp:latest