Skip to content

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:

**
!src/
!package.json
!package-lock.json


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

docker build --target builder -t myapp:builder .
docker build --target production -t myapp:latest .

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

docker run myimage [OVERRIDE_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.jsnode 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

docker run --read-only \
           --tmpfs /tmp \
           --tmpfs /var/run \
           myapp:latest

Secrets Management

# 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

# syntax=docker/dockerfile:1
RUN --mount=type=ssh \
    git clone git@github.com:private/repo.git /app
eval $(ssh-agent)
ssh-add ~/.ssh/id_ed25519
docker build --ssh default .

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

dive myapp:latest
# Shows wasted space per layer, files added/removed

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):

export DOCKER_BUILDKIT=1
# or
docker buildx build .

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