A Dockerfile is infrastructure code, and it deserves the same rigour as any other code. A naive Dockerfile ships a 1–2 GB image running as root with a 3-year-old base and credentials baked in. A well-written one ships a 50–150 MB image running as an unprivileged user with a minimal attack surface and a reliable layer cache. The difference is a handful of intentional choices.
What changed in 2026
- Rootless containers are the default. Kubernetes 1.32+, ECS Fargate, and Cloud Run all enforce or strongly prefer non-root containers. Security scanners block root images by default in most enterprise pipelines.
docker buildx and BuildKit are standard. BUILDKIT_INLINE_CACHE, --mount=type=cache, and --platform multi-arch builds are first-class features you should be using.
- SBOM generation is expected. Regulated industries and many enterprise customers require a Software Bill of Materials. Docker Scout and Syft integrate directly into the build pipeline.
- Base image sizes shrank.
distroless, chainguard, and wolfi-based images produce images in the 10–30 MB range for Go and Rust applications. Alpine remains the practical default for scripting runtimes.
The anatomy of a good Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
# Copy dependency manifests FIRST to maximise cache
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── Build stage ──────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Runtime stage ────────────────────────────────────────
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]
Multi-stage builds explained
Multi-stage builds let you use a heavy image for building and a lean image for running.
| Stage |
Purpose |
Included in final image |
deps |
Install all dependencies |
No |
build |
Compile, transpile, bundle |
No |
runtime |
Run the application |
Yes — only this stage ships |
Everything in build stages (compilers, dev deps, test tools) is discarded. Only explicitly COPY --from=<stage> items make it into the final image.
Layer cache strategy
Docker caches each layer. A layer is invalidated if its instruction or any file it depends on changes. Structure your Dockerfile so the most stable instructions come first:
# 1. Base image — rarely changes
FROM python:3.13-slim
# 2. System dependencies — rarely changes
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 3. Python dependencies — changes when requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 4. Application source — changes on every commit
COPY src/ ./src/
If you put COPY . . before RUN pip install, every code change invalidates the pip cache. The ordering above means pip only re-runs when requirements.txt changes.
BuildKit cache mounts
For package managers with slow installs, use BuildKit cache mounts to persist the download cache across builds:
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
This keeps the package cache across builds on the same machine — dramatically faster rebuilds.
Security essentials
Non-root user:
# Alpine
RUN addgroup -S app && adduser -S app -G app
USER app
# Debian/Ubuntu
RUN useradd --system --create-home --shell /sbin/nologin appuser
USER appuser
Read-only filesystem (set in your orchestrator, not Dockerfile):
# kubernetes pod spec
securityContext:
readOnlyRootFilesystem: true
No secrets in build args or ENV:
# Bad — secret visible in image layers
ARG DB_PASSWORD
ENV DB_PASSWORD=${DB_PASSWORD}
# Good — use runtime environment variables, not build-time
# Or use --secret for build-time secrets
RUN --mount=type=secret,id=db_password \
cat /run/secrets/db_password | do_something
Scan your image:
docker scout cves myapp:latest
# or
trivy image myapp:latest
Minimal Dockerfile by language
Python (FastAPI / Django)
FROM python:3.13-slim AS runtime
WORKDIR /app
RUN useradd --system appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser . .
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Go (single binary)
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The final Go image is ~5 MB with no shell, no package manager, and no OS utilities.
How to reduce image size
- Use
alpine or slim variants over full Debian images.
- Use multi-stage builds — the single largest win.
- Combine RUN commands with
&& to avoid intermediate layers.
- Clean up in the same
RUN that installs: apt-get install && rm -rf /var/lib/apt/lists/*.
- Use
.dockerignore to exclude node_modules, .git, test files, and docs.
# .dockerignore
node_modules
.git
**/*.test.ts
**/*.spec.ts
coverage/
docs/
.env
Common mistakes
COPY . . before dependency install. Kills the cache; every file change triggers a full reinstall.
Running as root. Increases blast radius if the container is compromised. Almost no production workload requires it.
CMD ["sh", "-c", "node server.js"] — wrapping in a shell means SIGTERM goes to sh, not node. Your app never gets the shutdown signal. Use exec form: CMD ["node", "server.js"].
Ignoring .dockerignore. Copying node_modules or .git into the build context slows every build and can leak secrets.
What to skip
- Installing git, curl, wget in the runtime image unless your application genuinely requires them at runtime.
latest tag as a base image — it changes without notice and breaks reproducible builds.
- Secrets in ARG or ENV — they are stored in image layers and visible with
docker history.
FAQ
What is the right base image for Node.js?
node:22-alpine for most use cases. node:22-slim (Debian) if you need glibc for native modules. distroless/nodejs22-debian12 for maximum security posture.
How do I make my image build faster in CI?
Use BuildKit cache mounts, push your build cache to a registry (--cache-to type=registry), and structure layers so stable instructions come first.
Should I use Docker Compose in production?
Docker Compose is fine for simple single-host deployments. For anything multi-host or requiring auto-scaling, use Kubernetes, ECS, or Cloud Run instead.
How do I handle database migrations in Docker?
Run migrations in an init container or as a separate step before deploying the new app container — never as part of the app startup CMD.
Where to go next