
Dockerfile Optimise
- 264 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
dockerfile-optimise: A skill for development. This provides functionality for development workflows.
Key points
- dockerfile-optimise
Dockerfile Optimise by the numbers
- 264 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,467 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill dockerfile-optimiseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 264 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use dockerfile-optimise for development tasks?
Use dockerfile-optimise for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with dockerfile-optimise.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use dockerfile-optimise for development tasks, or when dockerfile-optimise: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to dockerfile-optimise: dockerfile-optimise.
Files
Dockerfile Optimization Best Practices
Comprehensive Dockerfile optimization guide sourced exclusively from official Docker documentation. Contains 48 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new Dockerfiles or modifying existing ones
- Optimizing Docker build times (layer caching, cache mounts, context management)
- Reducing Docker image size (multi-stage builds, minimal base images)
- Hardening container security (secret mounts, non-root users, attestations)
- Setting up CI/CD pipelines with Docker builds
- Reviewing Dockerfiles for anti-patterns
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Layer Caching & Ordering | CRITICAL | cache- |
| 2 | Multi-Stage Builds | CRITICAL | stage- |
| 3 | Base Image Selection | HIGH | base- |
| 4 | Build Context Management | HIGH | ctx- |
| 5 | Security & Secrets | HIGH | sec- |
| 6 | Dependency Management | MEDIUM-HIGH | dep- |
| 7 | Instruction Patterns | MEDIUM | inst- |
| 8 | Quality & Validation | MEDIUM | lint- |
Quick Reference
1. Layer Caching & Ordering (CRITICAL)
- `cache-layer-order` - Order layers by change frequency
- `cache-copy-deps-first` - Copy dependency files before source code
- `cache-copy-link` - Use COPY --link for cache-efficient layer copying
- `cache-mount-package` - Use cache mounts for package managers
- `cache-apt-combine` - Combine apt-get update with install
- `cache-external` - Use external cache for CI/CD builds
- `cache-invalidation` - Avoid unnecessary cache invalidation
- `cache-minimize-layers` - Consolidate related RUN instructions
2. Multi-Stage Builds (CRITICAL)
- `stage-separate-build-runtime` - Separate build and runtime stages
- `stage-named-stages` - Use named build stages
- `stage-parallel-branches` - Exploit parallel stage execution
- `stage-target-builds` - Use target builds for dev/prod
- `stage-copy-artifacts-only` - Copy only final artifacts between stages
- `stage-reusable-base` - Create reusable base stages
3. Base Image Selection (HIGH)
- `base-minimal-image` - Use minimal base images
- `base-official-images` - Use Docker Official Images
- `base-pin-versions` - Pin base image versions with digests
- `base-arg-version` - Use ARG before FROM to parameterize base images
- `base-rebuild-regularly` - Rebuild images regularly with --pull
- `base-distroless` - Use distroless or scratch images for production
4. Build Context Management (HIGH)
- `ctx-dockerignore` - Use .dockerignore to exclude unnecessary files
- `ctx-bind-mounts` - Use bind mounts instead of COPY for build-only files
- `ctx-minimize-context` - Keep build context small
- `ctx-syntax-directive` - Use syntax directive for latest BuildKit features (prerequisite for cache mounts, secret mounts, heredocs, COPY --link)
5. Security & Secrets (HIGH)
- `sec-secret-mounts` - Use secret mounts for sensitive data
- `sec-non-root-user` - Run as non-root user
- `sec-no-secrets-in-args` - Never pass secrets via ARG or ENV
- `sec-ssh-mounts` - Use SSH mounts for private repository access
- `sec-attestations` - Enable SBOM and provenance attestations
- `sec-no-unnecessary-packages` - Avoid installing unnecessary packages
- `sec-ephemeral-containers` - Design ephemeral, stateless containers
6. Dependency Management (MEDIUM-HIGH)
- `dep-cache-mount-apt` - Use cache mount for apt package manager
- `dep-cache-mount-npm` - Use cache mount for npm, yarn, and pnpm
- `dep-cache-mount-pip` - Use cache mount for pip
- `dep-version-pin` - Pin package versions for reproducibility
- `dep-cleanup-caches` - Clean package manager caches in the same layer
7. Instruction Patterns (MEDIUM)
- `inst-json-cmd` - Use JSON form for CMD and ENTRYPOINT
- `inst-healthcheck` - Define HEALTHCHECK for container orchestration
- `inst-heredoc-scripts` - Use heredocs for multi-line scripts
- `inst-entrypoint-exec` - Use exec in entrypoint scripts
- `inst-workdir-absolute` - Use absolute paths with WORKDIR
- `inst-copy-over-add` - Prefer COPY over ADD
8. Quality & Validation (MEDIUM)
- `lint-build-checks` - Enable Docker build checks
- `lint-pipefail` - Use pipefail for piped RUN commands
- `lint-labels` - Use standard labels for image metadata
- `lint-sort-arguments` - Sort multi-line arguments alphabetically
- `lint-single-concern` - One concern per container
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on build time, image size, security, or robustness implications.}
Incorrect ({what's wrong}):
{Bad Dockerfile example - production-realistic, not strawman}
# Comments explaining the costCorrect ({what's right}):
{Good Dockerfile example - minimal diff from incorrect}
# Comments explaining the benefit{Optional sections as needed:}
Alternative ({context}):
{Alternative approach when applicable}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.4",
"organization": "Docker",
"technology": "Dockerfile",
"date": "February 2026",
"abstract": "Comprehensive Dockerfile optimization guide for build time, robustness, and image quality, designed for AI agents and LLMs. Contains 48 rules across 8 categories, prioritized by impact from critical (layer caching, multi-stage builds) to incremental (quality validation). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics. Sources exclusively from official Docker documentation.",
"references": [
"https://docs.docker.com/build/building/best-practices/",
"https://docs.docker.com/build/cache/optimize/",
"https://docs.docker.com/build/building/multi-stage/",
"https://docs.docker.com/build/cache/",
"https://docs.docker.com/build/building/secrets/",
"https://docs.docker.com/build/checks/",
"https://docs.docker.com/reference/dockerfile/",
"https://docs.docker.com/build/buildkit/",
"https://docs.docker.com/build/metadata/attestations/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Layer Caching & Ordering (cache)
Impact: CRITICAL Description: Wrong layer order invalidates all downstream cache, causing full rebuilds. Cache mounts and instruction ordering are the #1 build time optimization — a single misplaced COPY can add minutes to every build.
2. Multi-Stage Builds (stage)
Impact: CRITICAL Description: Without multi-stage, build tools, compilers, and intermediate artifacts ship to production — 2-10x image size bloat, expanded attack surface, and slower deployments.
3. Base Image Selection (base)
Impact: HIGH Description: The base image determines the size floor, security surface, and compatibility of every layer above it. Choosing the wrong base cascades through the entire image.
4. Build Context Management (ctx)
Impact: HIGH Description: Large build contexts cause slow transfers to the builder daemon and spurious cache invalidation. Bind mounts and .dockerignore eliminate unnecessary data transfer.
5. Security & Secrets (sec)
Impact: HIGH Description: Secrets in ARG or ENV persist in image layers forever and are readable by anyone with image access. Secret mounts, non-root users, and attestations are essential for production images.
6. Dependency Management (dep)
Impact: MEDIUM-HIGH Description: Package manager operations are the most expensive build steps. Cache mounts, version pinning, and cleanup strategies eliminate redundant downloads and reduce image size.
7. Instruction Patterns (inst)
Impact: MEDIUM Description: Incorrect CMD/ENTRYPOINT form breaks signal handling; missing HEALTHCHECK prevents orchestrator health detection; heredocs improve multi-line script readability.
8. Quality & Validation (lint)
Impact: MEDIUM Description: Docker build checks, pipefail, standard labels, and single-concern containers catch silent failures and maintain long-term image quality.
Use ARG Before FROM to Parameterize Base Images
Hardcoding the base image version in FROM means updating it requires editing the Dockerfile. In CI/CD pipelines that test against multiple language versions, this forces maintaining separate Dockerfiles or complex sed-based substitutions. An ARG instruction before FROM parameterizes the version so it can be set at build time without modifying the Dockerfile.
Incorrect (hardcoded version requires Dockerfile edit to change):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](Testing against Python 3.13 requires editing the Dockerfile. CI matrix builds cannot vary the Python version without maintaining multiple Dockerfiles or using build-time string replacement.)
Correct (ARG before FROM enables build-time version selection):
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]Build commands:
# Use the default version (3.12)
docker build -t myapp .
# Override for CI matrix testing
docker build --build-arg PYTHON_VERSION=3.13 -t myapp:py313 .
docker build --build-arg PYTHON_VERSION=3.11 -t myapp:py311 .(The same Dockerfile works for all Python versions. CI pipelines can define a version matrix without duplicating Dockerfiles.)
Important: ARG Scope with FROM
An ARG declared before FROM is only available in the FROM instruction itself. To use it inside the build stage, re-declare it after FROM:
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
# Re-declare to use inside the stage
ARG NODE_VERSION
RUN echo "Building with Node.js ${NODE_VERSION}"(Without the re-declaration after FROM, ${NODE_VERSION} inside the stage resolves to an empty string.)
Reference: Dockerfile reference - ARG
Use Distroless or Scratch Images for Production
Even slim images include a shell (/bin/sh), a package manager (apt/apk), and dozens of system utilities. If an attacker gains code execution inside a container, these tools become their toolkit for privilege escalation, data exfiltration, and lateral movement. Distroless and scratch images remove everything except the application and its runtime dependencies, leaving nothing for an attacker to exploit.
Incorrect (slim image for a compiled binary):
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server ./cmd/server
FROM python:3.12-slim
COPY --from=build /bin/server /bin/server
EXPOSE 8080
CMD ["/bin/server"](A statically-linked Go binary is copied into a Python slim image that includes 150MB+ of Python runtime, apt, bash, and hundreds of utilities the binary never uses. The final image is ~170MB instead of ~12MB.)
Correct (scratch for statically-linked Go binaries):
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server ./cmd/server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /bin/server /bin/server
EXPOSE 8080
ENTRYPOINT ["/bin/server"](The scratch image is completely empty. The final image contains only the static binary and CA certificates for TLS. No shell, no package manager, no OS -- approximately 12MB total.)
Correct (distroless for Java applications):
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN ./gradlew bootJar --no-daemon
FROM gcr.io/distroless/java21-debian12
COPY --from=build /src/build/libs/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"](The gcr.io/distroless/java21-debian12 image contains only the JRE and its dependencies. No shell, no package manager, no coreutils. The image is ~220MB compared to ~450MB for a slim JDK image.)
Correct (distroless for Node.js applications):
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["server.js"](The distroless Node.js image ships only the Node.js runtime. There is no npm, no shell, and no OS utilities in the final image.)
Choosing the Right Minimal Base
| Base Image | Contents | Best For | Size |
|---|---|---|---|
scratch | Nothing at all | Statically-linked binaries (Go, Rust) | Binary size only |
gcr.io/distroless/static-debian12 | CA certs, timezone data, /etc/passwd | Static binaries needing TLS + user IDs | ~2MB |
gcr.io/distroless/base-debian12 | Above + glibc | Dynamically-linked C/C++ binaries | ~20MB |
gcr.io/distroless/java21-debian12 | Above + JRE 21 | Java applications | ~220MB |
gcr.io/distroless/nodejs22-debian12 | Above + Node.js 22 | Node.js applications | ~170MB |
gcr.io/distroless/python3-debian12 | Above + Python 3 | Python applications | ~50MB |
When NOT to Use Distroless or Scratch
When you need to exec into running containers for debugging, distroless images make troubleshooting difficult because there is no shell. Use a multi-stage approach with a debug target instead:
FROM golang:1.23 AS build
# ... build steps ...
# Debug target with shell access
FROM alpine:3.20 AS debug
COPY --from=build /bin/server /bin/server
ENTRYPOINT ["/bin/server"]
# Production target without shell
FROM scratch AS production
COPY --from=build /bin/server /bin/server
ENTRYPOINT ["/bin/server"]Build with --target debug during development and --target production for deployment.
Reference: Building best practices
Use Minimal Base Images
Full distribution images like ubuntu or debian bundle hundreds of packages your application never uses -- compilers, man pages, documentation, and system utilities. This bloats the image, increases pull and deploy times, and widens the attack surface with software that is present but never needed.
Incorrect (full distribution base image):
FROM python:3.12
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The python:3.12 tag is built on full Debian and includes gcc, make, man pages, and hundreds of system packages. The resulting image exceeds 900MB before any application code is added.)
Correct (slim variant removes non-essential packages):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The python:3.12-slim tag strips compilers, documentation, and rarely-used system packages. The base layer drops from ~900MB to ~150MB with no code changes required.)
Correct (Alpine variant for maximum size reduction):
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The python:3.12-alpine tag uses Alpine Linux with musl libc. The base layer is ~50MB, but some Python packages with C extensions may require additional build dependencies.)
Approximate Base Image Sizes
| Variant | Base Size | Use Case |
|---|---|---|
python:3.12 | ~900MB | Development or when full toolchain needed |
python:3.12-slim | ~150MB | Production default for most applications |
python:3.12-alpine | ~50MB | Maximum size reduction for compatible apps |
When NOT to Use Minimal Images
Alpine uses musl libc instead of glibc. Libraries that depend on glibc-specific behaviour (such as certain scientific computing packages, DNS resolution edge cases, or pre-compiled binary wheels) may fail to build or behave differently on Alpine. In those cases, use the -slim variant, which retains glibc compatibility while still removing non-essential packages.
Reference: Building best practices
Use Docker Official Images
Random community images may contain outdated packages, known vulnerabilities, misconfigured defaults, or even intentional malware. Docker Official Images are curated by Docker in partnership with upstream maintainers, regularly scanned for vulnerabilities, and rebuilt when security patches are available.
Incorrect (unverified community image):
FROM random-user/node-custom:latest
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"](The random-user/node-custom image has unverified provenance, unknown update cadence, and no guarantee of security scanning. It may bundle unnecessary tools or contain known CVEs that are never patched.)
Correct (Docker Official Image):
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"](The node:22-alpine image is a Docker Official Image maintained by the Node.js Docker team. It receives regular security updates, follows Dockerfile best practices, and is scanned for vulnerabilities on Docker Hub.)
Docker Hub Trust Tiers
Docker Hub organises images into three trust tiers that indicate the level of curation and verification:
| Tier | Badge | Meaning |
|---|---|---|
| Docker Official Image | docker-official-image | Curated by Docker, reviewed by upstream maintainers, regularly scanned and rebuilt |
| Verified Publisher | verified-publisher | Published by a verified commercial entity (e.g., Bitnami, Datadog, Nginx Inc.) |
| Docker-Sponsored Open Source | open-source | Published by an open-source project sponsored through Docker's OSS programme |
When choosing a base image, prefer Docker Official Images first. If an official image is not available for your runtime, look for Verified Publisher images before falling back to community images. Always check the image description, Dockerfile source, and update frequency before trusting any image as a base.
Reference: Building best practices
Pin Base Image Versions with Digests
Mutable tags like :latest or :3.12 can resolve to a completely different image over time. A build that worked yesterday may break today because the base image was silently updated. Worse, a compromised tag could inject malicious code into your supply chain without any change to your Dockerfile.
Incorrect (mutable tag, non-deterministic builds):
FROM python:latest
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The python:latest tag resolves to a different image every time the upstream publishes a new Python release. Builds are non-reproducible and can break without any Dockerfile change.)
Better (pinned minor version, still mutable):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The python:3.12-slim tag limits drift to patch-level updates within the 3.12 series, but the underlying image still changes when Debian packages are updated or security patches are applied.)
Correct (immutable digest, fully reproducible):
FROM python:3.12-slim@sha256:a866731a6b71c4a194a845d86e06568725e430ed21271324873d91eb9e2a0c81
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"](The @sha256:... digest is immutable. Every build uses the exact same base image regardless of when or where it runs. The human-readable tag python:3.12-slim is kept for readability but the digest takes precedence.)
The Trade-off: Reproducibility vs. Automatic Updates
Pinning a digest means you opt out of automatic security patches. If the upstream publishes a fix for a critical CVE, your builds will continue using the old, vulnerable image until you manually update the digest.
To manage this trade-off:
- Use Docker Scout or a similar tool to monitor pinned images for new vulnerabilities and receive automated remediation pull requests.
- Automate digest updates in CI by periodically resolving the latest digest for your pinned tag and opening a PR when it changes.
- Pin in production Dockerfiles where reproducibility matters most, and use mutable tags in development or CI images where freshness matters more.
Finding the Digest
# Get the digest for a specific tag
docker inspect --format='{{index .RepoDigests 0}}' python:3.12-slim
# Or pull and inspect in one step
docker pull python:3.12-slim
docker images --digests pythonReference: Building best practices
Rebuild Images Regularly with --pull
When Docker builds an image, it caches the base image locally. Subsequent builds reuse that cached copy even if the upstream publisher has released security patches, OS updates, or critical bug fixes. Over time, a locally cached base image accumulates unpatched vulnerabilities that were fixed months ago.
Incorrect (building without --pull):
docker build -t myapp:latest .(Docker uses the locally cached node:22-alpine base image, which was pulled three months ago and is missing twelve security patches published since then.)
Correct (force-pull latest base image on every build):
docker build --pull -t myapp:latest .(The --pull flag instructs Docker to check the registry for a newer version of every FROM image in the Dockerfile and download it before building. This ensures the base image includes the latest security patches.)
Correct (full freshness for security-critical builds):
docker build --pull --no-cache -t myapp:latest .(Adding --no-cache forces Docker to re-execute every layer from scratch, ensuring that apt-get update, apk upgrade, and similar commands fetch the latest package lists instead of reusing a cached layer with stale package metadata.)
Recommended Rebuild Strategy
| Environment | Frequency | Flags |
|---|---|---|
| Development | On dependency change | --pull |
| CI / Staging | Every build | --pull |
| Production | Weekly or on CVE | --pull --no-cache |
Automating Rebuilds in CI
Schedule a weekly CI pipeline that rebuilds and redeploys your production images with --pull. This catches base image updates without requiring manual intervention:
# Example: GitHub Actions scheduled rebuild
on:
schedule:
- cron: '0 3 * * 1' # Every Monday at 03:00 UTC
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build --pull -t myapp:latest .
- run: docker push myapp:latest(The scheduled workflow pulls fresh base images weekly, ensuring production containers never run on base images more than seven days old.)
Reference: Building best practices
Combine apt-get update with install
Running apt-get update and apt-get install in separate RUN instructions means the update layer gets cached independently. When a new package is added to the install instruction, Docker reuses the stale cached package index, which can resolve to deleted or outdated package versions and cause build failures.
Incorrect (separate RUN instructions):
FROM ubuntu:24.04
# This layer gets cached with today's package index
RUN apt-get update
# Weeks later, adding "jq" uses the stale index from the cached layer above.
# The referenced package versions may no longer exist on the mirror.
RUN apt-get install -y \
curl \
ca-certificates \
jq(The cached apt-get update layer contains a stale package index. Adding or changing packages in the install layer triggers E: Unable to locate package or version mismatch errors.)
Correct (single RUN with cleanup):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
jq \
&& rm -rf /var/lib/apt/lists/*(The update and install always run together, ensuring a fresh package index. The --no-install-recommends flag avoids pulling in unnecessary suggested packages. Removing /var/lib/apt/lists/* reduces the layer size by 20-40 MB.)
Alternative (cache mount approach for faster rebuilds):
FROM ubuntu:24.04
# Required: disable the Docker-specific hook that wipes apt's cache after install
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
jq(The docker-clean removal is required — without it, apt deletes cached .deb files before the cache mount can persist them. Cache mounts persist the downloaded .deb files and package lists outside the image layer. No rm -rf cleanup needed because the cached data lives in the mount, not the image. The sharing=locked flag prevents parallel builds from corrupting the shared package database.)
Reference: Docker Build - Best Practices
Copy Dependency Files Before Source Code
Copying the entire source tree before installing dependencies means any source file change triggers a full dependency reinstall. By copying only the dependency manifest files (lock files) first, Docker can cache the expensive install step and only re-run it when dependencies actually change.
Node.js
Incorrect (full source copy before install):
FROM node:22-slim
WORKDIR /app
# Every source file change invalidates the install layer
COPY . .
RUN npm ci --production(Editing src/api/routes.ts forces a complete npm ci, re-downloading and installing all packages.)
Correct (copy manifests, install, then copy source):
FROM node:22-slim
WORKDIR /app
# Only changes to package.json or lock file invalidate install
COPY package.json package-lock.json ./
RUN npm ci --production
# Source changes only affect this layer and below
COPY . .(Editing src/api/routes.ts skips the npm ci layer entirely since package-lock.json has not changed.)
Python
Incorrect (full source copy before install):
FROM python:3.13-slim
WORKDIR /app
# Any source change reinstalls all dependencies
COPY . .
RUN pip install --no-cache-dir -r requirements.txt(Editing app/views.py forces a full pip install of every package in requirements.txt.)
Correct (copy requirements first, then source):
FROM python:3.13-slim
WORKDIR /app
# Only changes to requirements.txt invalidate the install layer
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Source changes only affect this layer
COPY . .(Editing app/views.py reuses the cached pip install layer. Dependencies only reinstall when requirements.txt changes.)
Reference: Docker Build Cache - Optimize
Use COPY --link for Cache-Efficient Layer Copying
A regular COPY instruction adds files on top of the previous layer, creating a dependency chain -- if any earlier layer changes, the COPY layer must be rebuilt even when the copied content is identical. COPY --link creates an independent layer that does not depend on its predecessors, so it can be reused from cache even when earlier layers in the same stage have changed. This is especially valuable in multi-stage builds where final images copy artifacts from build stages.
Incorrect (regular COPY -- cache invalidated by any upstream change):
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/server ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/server /usr/local/bin/server
CMD ["server"](If the RUN apk add layer changes -- say you add a new package -- the COPY --from=build layer must be rebuilt even though the copied binary is identical. The COPY depends on the layer stack below it.)
Correct (COPY --link creates independent layer):
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/server ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --link --from=build /out/server /usr/local/bin/server
CMD ["server"](The COPY --link creates a self-contained layer. Changing the apk add line does not invalidate the copied binary layer. BuildKit can also resolve this layer without needing the base image to exist locally, enabling faster builds.)
When to Use --link
Use COPY --link by default for all COPY instructions unless you depend on symlinks or need files to merge with existing directory contents. The performance is always equal to or better than regular COPY.
Multi-stage artifact copies (highest impact):
COPY --link --from=build /out/app /usr/local/bin/app
COPY --link --from=assets /out/static /srv/staticStatic file copies into the image:
COPY --link package.json package-lock.json ./
COPY --link . .When NOT to Use --link
- When your
COPYrelies on symlinks in the source or destination (linked copies cannot create symlinks that reference existing content) - When you need copied files to merge into a directory that must already exist from a prior layer
Requirements
COPY --link requires the # syntax=docker/dockerfile:1 directive (or BuildKit with Dockerfile frontend 1.4+).
Reference: Building best practices
Use External Cache for CI/CD Builds
CI/CD runners are typically ephemeral -- each job starts with no local Docker cache, forcing a full rebuild of every layer from scratch. By exporting the build cache to an external backend (like a container registry) and importing it on subsequent builds, layers that have not changed are reused across different runners and pipeline runs.
Incorrect (no cache sharing in CI):
# .github/workflows/build.yml
name: Build
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .(Every CI run starts cold. A 5-minute build runs in full on every push, even when only a README changed.)
Correct (registry-backed cache with GitHub Actions):
# .github/workflows/build.yml
name: Build
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max(The mode=max flag exports cache for all intermediate layers, not just the final image. Subsequent builds on any runner pull cached layers from the registry, reducing a 5-minute build to under 30 seconds when only source code changed.)
Direct docker build equivalent:
# Build with external cache (CLI usage)
docker buildx build \
--cache-from type=registry,ref=ghcr.io/myorg/myapp:buildcache \
--cache-to type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
--tag ghcr.io/myorg/myapp:latest \
--push .(The same --cache-from and --cache-to flags work with any CI system -- GitLab CI, CircleCI, Jenkins -- by pointing to an accessible registry.)
Reference: Docker Build Cache - Optimize
Avoid Unnecessary Cache Invalidation
Docker's COPY and ADD instructions compute a checksum of the copied files to determine cache validity. When COPY . . is used without a .dockerignore file, every file in the build context -- including READMEs, test suites, IDE configurations, and git history -- becomes part of the checksum. Changing any of these files invalidates the layer and forces a rebuild of everything below it.
Incorrect (no .dockerignore, broad COPY):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
# Copies everything: .git/, node_modules/, README.md, docs/, tests/, .env
COPY . .
RUN npm run build(Editing README.md or adding a test file invalidates the COPY . . layer, triggering a full npm run build even though the application source has not changed.)
Correct (use .dockerignore to exclude irrelevant files):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run buildWith a .dockerignore file:
# .dockerignore
.git
node_modules
npm-debug.log*
Dockerfile
docker-compose*.yml
.dockerignore
README.md
CHANGELOG.md
LICENSE
docs/
tests/
__tests__/
coverage/
.env
.env.*
.vscode/
.idea/
*.swp
*.swo(Now only production-relevant source files are included in the checksum. Changes to documentation, tests, or editor config do not invalidate any build layers.)
Alternative (use specific COPY paths instead of broad wildcard):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
# Copy only the directories that matter for the build
COPY src/ ./src/
COPY public/ ./public/
COPY tsconfig.json ./
RUN npm run build(Explicitly naming source directories provides even tighter control. Only changes inside src/, public/, or tsconfig.json invalidate the build layer.)
Reference: Docker Build Cache
Order Layers by Change Frequency
Docker caches each layer and reuses it as long as the instruction and its inputs have not changed. When a layer's cache is invalidated, every subsequent layer is also invalidated and must be rebuilt. Placing frequently-changing instructions (like copying application source code) before stable instructions (like installing system packages) forces Docker to redo all the stable work on every build.
Incorrect (frequently-changing layer before stable layers):
FROM node:22-slim
WORKDIR /app
# Copying source code first means ANY file edit invalidates
# the npm install layer below, triggering a full reinstall
COPY . .
RUN npm install --production
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"](Any change to a source file, README, or test invalidates the npm install layer and every layer after it.)
Correct (layers ordered from least to most frequently changing):
FROM node:22-slim
WORKDIR /app
# 1. System-level setup (rarely changes)
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
&& rm -rf /var/lib/apt/lists/*
# 2. Dependency manifest (changes only when deps change)
COPY package.json package-lock.json ./
# 3. Dependency install (cached until manifests change)
RUN npm ci --production
# 4. Application source (changes most frequently)
COPY . .
RUN npm run build
EXPOSE 3000
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"](Source code changes only invalidate the COPY . . layer and the build step. Dependency installation remains cached.)
Reference: Docker Build Cache - Optimize
Consolidate Related RUN Instructions
Each RUN instruction creates a new layer in the image. Files created in one layer and deleted in a subsequent layer still occupy space in the image because layers are additive. Consolidating related operations into a single RUN instruction ensures that temporary files, caches, and build artifacts are cleaned up within the same layer they were created in.
Incorrect (separate RUN instructions -- cleanup does not reduce size):
FROM ubuntu:24.04
# Layer 1: downloads ~35MB of .deb files into /var/cache/apt
RUN apt-get update
# Layer 2: installs packages, adds ~120MB
RUN apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl
# Layer 3: removes lists, but Layer 1 still contains the 35MB
RUN rm -rf /var/lib/apt/lists/*(The rm -rf in Layer 3 creates a whiteout entry that hides the files but does not reclaim the 35 MB from Layer 1. The image carries all three layers at their full size.)
Correct (single RUN with chained commands):
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*(All operations happen in a single layer. The package lists are downloaded, used, and deleted before the layer is committed. The final layer only contains the installed packages.)
Alternative (heredoc syntax for readability):
FROM ubuntu:24.04
RUN <<EOF
set -e
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl
rm -rf /var/lib/apt/lists/*
EOF(Heredoc syntax, available since Docker BuildKit, improves readability for multi-command operations while keeping everything in a single layer. Always include `set -e` at the top of heredocs — unlike && chaining, heredoc runs commands sequentially but does not stop on failure by default. Without set -e, a failed apt-get update silently continues to apt-get install with a stale index.)
When NOT to consolidate: Keep layers separate when they change at different frequencies and you want to preserve cache granularity. For example, system package installation and application dependency installation should be separate layers because they change at different rates.
Reference: Docker Build - Best Practices
Use Cache Mounts for Package Managers
Without cache mounts, each build that misses the layer cache re-downloads every package from the internet. A RUN --mount=type=cache instruction persists a directory across builds so package managers can reuse previously downloaded files, even when the dependency manifest changes. This turns a full download into an incremental update.
Incorrect (re-downloads everything on cache miss):
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt(Adding a single new dependency forces pip to re-download all packages from PyPI.)
Correct (cache mount preserves downloaded packages):
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt(Adding a new dependency only downloads that one package. The cached data lives in the mount on the host, not in the image layer — so images stay small without explicit cleanup commands.)
Package Manager Cache Directories
| Package Manager | Cache Target | Notes |
|---|---|---|
| apt (Debian/Ubuntu) | /var/cache/apt + /var/lib/apt | Requires disabling docker-clean hook; use sharing=locked |
| pip (Python) | /root/.cache/pip | Remove --no-cache-dir flag when using cache mounts |
| npm | /root/.npm | Works with npm ci and npm install |
| yarn Classic v1 | /root/.yarn | Set YARN_CACHE_FOLDER=/root/.yarn |
| yarn Berry v4 | /root/.yarn/berry/cache | Use --immutable instead of --frozen-lockfile |
| pnpm | /root/.local/share/pnpm/store | Content-addressable store deduplicates across projects |
| Go modules | /go/pkg/mod | Also cache /root/.cache/go-build for compiled artifacts |
Language-Specific Details
For full examples with incorrect/correct patterns and edge cases, see the dedicated rules:
- `dep-cache-mount-apt` — apt/Debian/Ubuntu (requires
docker-cleanremoval) - `dep-cache-mount-npm` — npm, yarn Classic, yarn Berry, pnpm
- `dep-cache-mount-pip` — pip/Python
Go Modules (Quick Reference)
FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download(Only new or updated modules are downloaded. The rest are served from the persistent /go/pkg/mod cache.)
Reference: Docker Build Cache - Optimize
Use Bind Mounts Instead of COPY for Build-Only Files
COPY instructions persist every copied file into an image layer, even when the files are only needed during compilation and the final artifact is a single binary. For compiled languages like Go, C, C++, or Rust, this means the entire source tree and all intermediate object files are stored in the layer cache permanently. A RUN --mount=type=bind instruction makes files available to a single RUN step without writing them into any layer -- only the build output remains.
Go
Incorrect (entire source tree persisted in layer):
FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/server ./cmd/server(The COPY . . layer permanently stores every .go file, test file, and internal package in the build cache. For a large Go project this can be 50-200 MB of source that is never used after compilation.)
Correct (source mounted temporarily via bind mount):
FROM golang:1.23 AS build
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
CMD ["/server"](The source tree is mounted read-only into the build step and disappears when the RUN instruction finishes. The Go module cache and build cache are persisted across builds via cache mounts. Only the compiled binary is written to the layer.)
C / C++
Incorrect (source and object files persisted in layer):
FROM gcc:14
WORKDIR /build
COPY . .
RUN make -j$(nproc) && make install(The COPY . . layer stores all source files, headers, and Makefiles. The RUN make layer stores all intermediate .o files alongside the final binary. Both layers persist in the cache indefinitely.)
Correct (bind mount keeps source out of layers):
FROM gcc:14 AS build
RUN --mount=type=bind,target=/src \
cd /src && make -j$(nproc) DESTDIR=/out install
FROM debian:bookworm-slim
COPY --from=build /out/ /
CMD ["/usr/local/bin/myapp"](Source files and object files exist only during the RUN step. The final image contains only the installed binaries and libraries from the DESTDIR.)
Reference: Docker Build Cache - Optimize
Use .dockerignore to Exclude Unnecessary Files
Without a .dockerignore file, Docker sends the entire build context directory to the builder daemon before the build starts. This includes version control history, installed dependencies, test suites, editor configuration, and any other files in the directory -- even if the Dockerfile never references them. The extra transfer time slows every build, and any change to an excluded-but-transferred file invalidates the cache for COPY . . instructions.
Incorrect (no .dockerignore -- entire project directory sent as context):
# Project structure:
# .git/ (150 MB of history)
# node_modules/ (400 MB of installed deps)
# coverage/ (20 MB of test reports)
# .env (secrets!)
# dist/
# src/
# Dockerfile
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"](Docker transfers ~570 MB of context to the builder on every build. Editing a README, switching a git branch, or running tests locally changes files in .git/ or coverage/, invalidating the COPY . . layer and forcing a full rebuild of everything after it.)
Correct (comprehensive .dockerignore excludes irrelevant files):
.dockerignore:
# Version control
.git
.gitignore
# Dependencies (installed inside container)
node_modules
# Build output (rebuilt inside container)
dist
# Test and coverage artifacts
test
tests
__tests__
coverage
*.test.js
*.spec.js
jest.config.*
# Documentation
*.md
LICENSE
docs
# Environment and secrets
.env
.env.*
# Editor and IDE files
.vscode
.idea
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# CI/CD configuration
.github
.gitlab-ci.yml
.circleci
# Docker files (avoid recursive context)
Dockerfile*
docker-compose*
.dockerignoreFROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"](Docker now transfers only the source code and configuration files needed for the build -- typically a few MB instead of hundreds. Changes to git history, test results, or documentation no longer invalidate any build cache layers.)
Reference: Building best practices
Keep Build Context Small
Docker must serialize and transfer the entire build context to the builder daemon before the first instruction runs. In a monorepo or large project, passing the repository root as context can mean transferring gigabytes of unrelated code, assets, and data -- adding seconds or minutes of overhead to every build even when nothing relevant has changed.
Incorrect (monorepo root as build context):
project/
├── services/
│ ├── api/
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── src/
│ ├── worker/
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── src/
│ └── frontend/
│ ├── Dockerfile
│ ├── package.json
│ └── src/
├── data/ (500 MB of seed data)
├── ml-models/ (2 GB of model weights)
├── docs/
└── scripts/# Building the API service from monorepo root
docker build -f services/api/Dockerfile .(Docker sends the entire 3+ GB monorepo to the builder, including ML models, seed data, other services, and documentation. This transfer happens on every build regardless of what changed.)
Correct (scoped context to the service directory):
# Pass only the service directory as context
docker build -f services/api/Dockerfile services/api/(Docker sends only the services/api/ directory -- typically a few MB of source code and configuration. The build starts almost instantly instead of waiting for gigabytes to transfer.)
Correct (use a dedicated context path with shared dependencies):
When services share code (e.g. a common library), scope the context to include only what is needed:
project/
├── libs/
│ └── shared/
│ ├── package.json
│ └── src/
└── services/
└── api/
├── Dockerfile
├── package.json
└── src/# Context includes libs/ and services/api/ but not data/, ml-models/, etc.
docker build -f services/api/Dockerfile services/# services/api/Dockerfile
FROM node:22-slim
WORKDIR /app
# Copy shared library
COPY libs/shared/package.json libs/shared/
RUN cd libs/shared && npm ci
# Copy API service
COPY api/package.json api/package-lock.json api/
RUN cd api && npm ci --production
COPY libs/shared/src/ libs/shared/src/
COPY api/src/ api/src/
WORKDIR /app/api
RUN npm run build
CMD ["node", "dist/server.js"](The context is services/, which contains only the relevant service code and shared libraries. Multi-gigabyte directories like data/ and ml-models/ are never transferred.)
Reference: Docker Build Cache - Optimize
Use Syntax Directive for Latest BuildKit Features
The syntax directive tells BuildKit to pull a specific Dockerfile parser image from a registry instead of using whatever version is bundled with the local Docker daemon. Without it, available features depend entirely on the daemon version installed on the build machine -- meaning builds may silently fail or behave differently across environments. Adding the directive ensures every build environment has access to the same set of features regardless of the installed Docker version.
Incorrect (no syntax directive -- limited to local daemon parser):
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
CMD ["/server"](This Dockerfile cannot use RUN --mount=type=cache, RUN --mount=type=secret, RUN --mount=type=bind, heredoc syntax, or other BuildKit-specific features if the Docker daemon is older than the version that introduced them. The build may work on a developer's machine but fail in CI with a different Docker version.)
Correct (syntax directive pins the latest stable parser):
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
CMD ["/server"](The # syntax=docker/dockerfile:1 directive pulls the latest 1.x parser from Docker Hub on every build. This guarantees access to cache mounts, bind mounts, secret mounts, heredocs, and all other stable BuildKit features regardless of the local Docker daemon version.)
Features Unlocked by the Syntax Directive
| Feature | Syntax | Purpose |
|---|---|---|
| Cache mounts | RUN --mount=type=cache,target=/path | Persist package manager caches across builds |
| Bind mounts | RUN --mount=type=bind,target=/path | Mount context files without creating a layer |
| Secret mounts | RUN --mount=type=secret,id=mysecret | Inject secrets without leaking them into layers |
| SSH mounts | RUN --mount=type=ssh | Forward SSH agent for private repo access |
| Heredocs | RUN <<EOF | Multi-line scripts without backslash continuation |
Important Notes
The syntax directive is a parser directive, not a comment. It must be the very first line of the Dockerfile -- before any comments, ARG instructions, or blank lines. Any content before it causes Docker to treat it as a regular comment and ignore it:
# This comment causes the syntax directive below to be ignored!
# syntax=docker/dockerfile:1
FROM node:22-slimThe tag docker/dockerfile:1 follows semver: it always resolves to the latest stable 1.x.x release, so you get bug fixes and new features automatically without changing your Dockerfile.
Reference: Dockerfile frontend
Use Cache Mount for apt Package Manager
Standard apt-get install downloads every package from the archive each time the layer rebuilds, even when only one dependency changed. A --mount=type=cache directive persists both the package index and downloaded .deb files across builds, turning a full re-download into an incremental update. This is especially valuable when installing many system-level libraries for C extension compilation.
Incorrect (full re-download on any change):
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libxml2-dev \
libxslt1-dev \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*(Every rebuild re-downloads the full package index and all six .deb files plus their transitive dependencies from the Debian mirror. Adding a seventh package forces a complete re-download of all packages.)
Correct (cache mount preserves apt data across builds):
FROM debian:bookworm-slim
# Required: official Debian/Ubuntu Docker images delete cached .deb files
# after every install via a DPkg post-invoke hook. Remove it first.
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libxml2-dev \
libxslt1-dev \
curl \
ca-certificates(The docker-clean removal is essential — without it, apt deletes cached .deb files before the cache mount can persist them. The sharing=locked flag serializes concurrent access to the shared cache, preventing database corruption when parallel builds run. Downloaded .deb files and the package index persist in the mount — not in the image layer — so no rm -rf /var/lib/apt/lists/* cleanup is needed. Adding a seventh package only downloads that one new package.)
See also: `cache-mount-package` for a general overview of cache mounts across all package managers.
Reference: Docker Build Cache - Optimize
Use Cache Mount for npm, yarn, and pnpm
Node package managers maintain a local cache of downloaded tarballs so repeated installs can skip the network round-trip. Inside Docker, this cache is lost every time the install layer rebuilds because the filesystem is ephemeral. A --mount=type=cache directive persists the cache directory across builds, so only new or updated packages are fetched from the registry.
Incorrect (re-downloads everything on cache miss):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci(Every rebuild fetches the entire dependency tree from the npm registry. A project with 800 transitive dependencies re-downloads all 800 tarballs even when only one package changed.)
Correct (cache mount preserves npm tarball cache):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci(npm checks its persistent cache before hitting the registry. Only new or updated packages are downloaded.)
Correct — yarn Classic v1 (cache mount with explicit cache folder):
FROM node:22-slim
WORKDIR /app
COPY package.json yarn.lock ./
RUN --mount=type=cache,target=/root/.yarn \
YARN_CACHE_FOLDER=/root/.yarn \
yarn install --frozen-lockfile(The YARN_CACHE_FOLDER environment variable redirects yarn's cache into the mounted directory. The --frozen-lockfile flag ensures the lockfile is not modified during install. Note: This pattern applies to Yarn Classic (v1) only. In Yarn Berry/v4, YARN_CACHE_FOLDER is ignored when enableGlobalCache: true (the default), and --frozen-lockfile was replaced by --immutable.)
Correct — yarn Berry v4 (cache mount with project-local cache):
FROM node:22-slim
RUN corepack enable
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/ .yarn/
RUN --mount=type=cache,target=/root/.yarn/berry/cache \
yarn install --immutable(Yarn Berry uses a project-local cache by default. The --immutable flag replaces --frozen-lockfile in v4.)
Correct — pnpm (cache mount preserves content-addressable store):
FROM node:22-slim
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile(pnpm's content-addressable store deduplicates packages across projects. The cache mount preserves the store so identical package versions are never downloaded twice across builds.)
See also: `cache-mount-package` for a general overview of cache mounts across all package managers.
Reference: Docker Build Cache - Optimize
Use Cache Mount for pip
pip caches downloaded packages and compiled wheels in ~/.cache/pip so subsequent installs can skip both the download and the compilation step. Inside Docker this cache is discarded every time the layer rebuilds. Worse, many Dockerfiles explicitly pass --no-cache-dir to reduce image size, which disables caching entirely. A --mount=type=cache directive preserves the cache outside the image layer, giving you both fast rebuilds and a small image.
Incorrect (caching explicitly disabled):
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt(The --no-cache-dir flag forces pip to download and compile every package from scratch on every rebuild. Packages with C extensions like numpy or pandas take minutes to build each time.)
Correct (cache mount preserves downloaded wheels):
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt(pip reuses previously downloaded wheels from the persistent cache. Adding a new dependency only downloads and compiles that one package. The cache lives in the mount, not in the image layer, so image size stays small without needing --no-cache-dir.)
See also: `cache-mount-package` for cache mounts across all package managers including Go modules.
Reference: Docker Build Cache - Optimize
Clean Package Manager Caches in the Same Layer
Each RUN instruction creates an immutable layer in the image. Files written in one layer cannot be removed by a later layer — the later layer only masks them with a whiteout entry, but the bytes remain in the image. This means cleaning package manager caches in a separate RUN instruction has zero effect on image size.
Incorrect (cleanup in separate layer):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
gnupg
RUN rm -rf /var/lib/apt/lists/*(The apt package lists — typically 20-40 MB — are baked into the first layer. The second RUN creates a whiteout layer that hides the files but does not reclaim the space. The total image carries the full weight of both layers.)
Correct (cleanup in same layer):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
gnupg \
&& rm -rf /var/lib/apt/lists/*(The package lists are created and removed within the same RUN instruction, so they never appear in the final layer. This saves 20-40 MB per image.)
Better (cache mount eliminates the problem entirely):
FROM ubuntu:24.04
# Required: disable the Docker-specific hook that wipes apt's cache after install
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
gnupg(The docker-clean removal is required — without it, apt deletes cached .deb files before the cache mount can persist them. Cache mounts are not part of the image layer at all — they exist only in the build cache on the host. No cleanup command is needed, and the cached files are reused by subsequent builds. This is the best of both worlds: small images and fast rebuilds.)
See also: `cache-mount-package` (CRITICAL) for full coverage of cache mounts across all package managers.
pip — same principle applies
Incorrect (cache persists in layer):
RUN pip install flask sqlalchemy requests
RUN rm -rf /root/.cache/pip(The pip cache — which can be hundreds of MB for packages with compiled wheels — is stored in the first layer and cannot be reclaimed by the second.)
Correct (single layer with cache disabled):
RUN pip install --no-cache-dir flask sqlalchemy requests(The --no-cache-dir flag prevents pip from writing a cache at all. Simpler than manual cleanup, but rebuilds must re-download everything.)
Better (cache mount):
RUN --mount=type=cache,target=/root/.cache/pip \
pip install flask sqlalchemy requests(The pip cache persists across builds for fast installs but never appears in the image layer.)
Reference: Building best practices
Pin Package Versions for Reproducibility
Unpinned packages resolve to whatever version is current at build time. A new major or minor release of a system dependency can introduce breaking API changes, binary incompatibilities, or subtle behaviour differences without any change to your Dockerfile. Pinning versions ensures that every build produces the same result regardless of when it runs.
Incorrect (unpinned, non-deterministic installs):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
nginx \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*(Installs whatever version the Ubuntu mirror resolves today. A build on Monday may get nginx 1.24 while the same Dockerfile on Friday gets nginx 1.26 after an archive update — silently breaking reverse-proxy configuration.)
Correct (pinned to exact versions):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3=3.12.3-1ubuntu2 \
nginx=1.24.0-2ubuntu7 \
postgresql-client-16=16.2-1ubuntu4 \
&& rm -rf /var/lib/apt/lists/*(Pins each package to an exact version. Builds are reproducible across machines and time, and upstream updates cannot silently break your image. Use docker run --rm ubuntu:24.04 apt-cache policy <package> to discover available versions for your base image.)
pip — use a locked requirements file
Incorrect (loose constraints allow version drift):
# requirements.txt
flask>=3.0
sqlalchemy
requests(Every build may resolve to a different combination of versions. A breaking release of sqlalchemy can fail your application without any code change.)
Correct (exact versions pinned):
# requirements.txt
flask==3.0.2
sqlalchemy==2.0.29
requests==2.31.0(Use pip freeze > requirements.txt or a tool like pip-compile to generate exact pins. Every build installs the identical dependency tree.)
Discovering Available Versions
To find available versions for apt packages in your target base image, run:
docker run --rm ubuntu:24.04 apt-cache policy nginxVersion availability varies by distribution release. Always verify against the specific base image you are using.
Reference: Building best practices
Prefer COPY Over ADD
ADD has two implicit behaviors beyond simple file copying: it automatically extracts recognized archive formats (tar, gzip, bzip2, xz) and supports fetching files from remote URLs. These hidden behaviors make the Dockerfile harder to reason about -- a reader cannot tell whether ADD archive.tar.gz /app/ is intentionally extracting the archive or accidentally triggering auto-extraction. COPY does exactly one thing: copy files from the build context into the image. Explicit is better than implicit.
Incorrect (ADD auto-extracts archives -- intent is ambiguous):
FROM python:3.12-slim
WORKDIR /app
ADD ./app.tar.gz /app/
ADD ./config.yaml /app/(The ADD ./app.tar.gz /app/ instruction silently extracts the archive into /app/. Was this intentional? A future maintainer cannot tell. The ADD ./config.yaml /app/ instruction does a plain copy because YAML is not a recognized archive format. Using the same instruction for two fundamentally different operations makes the Dockerfile misleading.)
Correct (COPY for files, explicit extraction when needed):
FROM python:3.12-slim
WORKDIR /app
COPY ./app.tar.gz /staging/
RUN tar -xzf /staging/app.tar.gz -C /app/ && rm /staging/app.tar.gz
COPY ./config.yaml /app/(Every operation is explicit. COPY moves the archive into the image, RUN extracts it with visible flags, and the archive is cleaned up in the same layer. A reader immediately understands that extraction is intentional.)
Incorrect (ADD for remote URLs -- no checksum verification, no caching control):
FROM debian:bookworm-slim
ADD https://example.com/bin/tool-v1.2.3 /usr/local/bin/tool
RUN chmod +x /usr/local/bin/tool(ADD fetches the remote file at build time but provides no checksum verification. A compromised or changed file at the URL would be silently incorporated into the image. The downloaded file also cannot benefit from Docker layer caching effectively because ADD always checks the remote URL.)
Correct (curl/wget with explicit checksum verification):
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /usr/local/bin/tool https://example.com/bin/tool-v1.2.3 && \
echo "a]b3c4d5e6f7... /usr/local/bin/tool" | sha256sum -c - && \
chmod +x /usr/local/bin/tool(The download, checksum verification, and permission setting are explicit. A tampered file fails the sha256sum check and aborts the build.)
When ADD is Appropriate
ADD is the right choice when auto-extraction is intentional and the context makes this clear:
# ADD with checksum for remote files (BuildKit feature)
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2015d0c6e5c848e4 \
https://example.com/releases/app-v2.1.0.tar.gz /tmp/
# ADD for intentional local archive extraction in a build stage
FROM debian:bookworm-slim AS extract
ADD rootfs.tar.gz /(The --checksum flag (requires BuildKit) provides verified remote file fetching in a single instruction. Local archive extraction in a dedicated build stage is also a valid use of ADD when the intent is unambiguous.)
Quick Reference
| Operation | Use | Instruction |
|---|---|---|
| Copy local files | Always | COPY |
| Copy and extract local archive | Prefer explicit | COPY + RUN tar |
| Fetch remote file | Prefer explicit | RUN curl / RUN wget |
| Fetch remote file with checksum | Acceptable | ADD --checksum=... |
| Extract local archive (build stage) | Acceptable | ADD archive.tar.gz / |
Reference: Building best practices
Use exec in Entrypoint Scripts
When a shell entrypoint script launches the application without exec, the shell remains as PID 1 and the application runs as a child process. The shell does not forward OS signals (SIGTERM, SIGINT) to its children by default, so the application never receives the stop signal. Docker waits for the grace period (default 10 seconds) then forcibly kills the entire process tree with SIGKILL, preventing graceful shutdown, connection draining, and cleanup operations.
Incorrect (no exec -- shell remains PID 1, application cannot receive signals):
#!/bin/bash
# docker-entrypoint.sh
setup_database
python manage.py migrate
python manage.py runserver 0.0.0.0:8000(The shell script is PID 1. When Docker sends SIGTERM to stop the container, bash receives the signal but does not forward it to the python child process. The python server continues running until Docker sends SIGKILL after the timeout, abruptly terminating in-flight requests and database transactions.)
Correct (exec replaces the shell with the application as PID 1):
#!/bin/bash
# docker-entrypoint.sh
set -e
setup_database
python manage.py migrate
exec python manage.py runserver 0.0.0.0:8000(The exec builtin replaces the shell process with python, making python PID 1. SIGTERM is delivered directly to the application, which can close database connections, finish in-flight requests, and exit cleanly.)
Correct (exec "$@" pattern -- entrypoint delegates to CMD arguments):
#!/bin/bash
# docker-entrypoint.sh
set -e
# Run initialization tasks
if [ "$1" = 'postgres' ]; then
# Initialize data directory if needed
if [ -z "$(ls -A "$PGDATA")" ]; then
initdb --username="$POSTGRES_USER" --pwfile=<(echo "$POSTGRES_PASSWORD")
fi
fi
# Replace shell with the CMD arguments
exec "$@"(The exec "$@" pattern passes all CMD arguments through to exec, making the CMD process PID 1. This is the pattern used by official Docker images like PostgreSQL, Redis, and MySQL. It allows the Dockerfile to define the default command while the entrypoint handles initialization.)
Dockerfile using the exec "$@" pattern:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:8000", "--workers", "4"](ENTRYPOINT uses exec form to run the script directly without a wrapping shell. CMD provides default arguments that exec "$@" in the entrypoint script will use. Running docker run myimage celery worker overrides CMD, and the entrypoint script runs initialization before exec celery worker.)
Process Tree Comparison
Without exec:
PID 1: /bin/bash /docker-entrypoint.sh
PID 2: python manage.py runserver 0.0.0.0:8000SIGTERM goes to PID 1 (bash). Python never receives it.
With exec:
PID 1: python manage.py runserver 0.0.0.0:8000SIGTERM goes directly to python. Bash is gone.
Guidelines
- Always use
set -eat the top of entrypoint scripts so initialization failures abort the container start instead of silently continuing. - Place
execon the last line of the entrypoint script. Any commands afterexecwill never run becauseexecreplaces the current process. - Use
exec "$@"rather than hardcoding the application command in the entrypoint. This preserves the separation between initialization (ENTRYPOINT) and the default command (CMD). - Use ENTRYPOINT in exec form (
ENTRYPOINT ["/docker-entrypoint.sh"]) in the Dockerfile. Shell form (ENTRYPOINT /docker-entrypoint.sh) wraps the script in/bin/sh -c, adding yet another shell layer.
Reference: Building best practices
Define HEALTHCHECK for Container Orchestration
Without a HEALTHCHECK instruction, Docker considers a container healthy as long as its main process is running. A deadlocked application, an exhausted connection pool, or an unresponsive web server all report as "healthy" because the process itself has not exited. Orchestrators like Docker Swarm and Docker Compose continue routing traffic to these broken containers, causing user-visible outages that persist until manual intervention.
Incorrect (no HEALTHCHECK -- container appears healthy even when the application is unresponsive):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"](Docker reports this container as "healthy" indefinitely, even if the event loop is blocked, the database connection is lost, or the HTTP server has stopped accepting requests. Docker Swarm will not replace it and Docker Compose depends_on with condition: service_healthy will hang.)
Correct (HEALTHCHECK with curl for Debian-based images):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["curl", "-f", "http://localhost:3000/health"]
CMD ["node", "server.js"](Docker probes the /health endpoint every 30 seconds. After 3 consecutive failures (5-second timeout each), the container is marked unhealthy. The 10-second start period gives the application time to initialize before health checks begin counting failures.)
Correct (HEALTHCHECK with wget for Alpine images that lack curl):
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
CMD ["node", "server.js"](Alpine images include wget by default but not curl. The --spider flag performs a HEAD request without downloading the response body, and --no-verbose suppresses output to keep container logs clean.)
Correct (HEALTHCHECK with a zero-dependency custom binary):
FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/server /bin/server
COPY --from=build /bin/healthcheck /bin/healthcheck
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["/bin/healthcheck"]
ENTRYPOINT ["/bin/server"](Distroless images have no shell, curl, or wget. A statically compiled healthcheck binary built in the build stage avoids adding runtime dependencies to the production image.)
HEALTHCHECK Parameters
| Parameter | Default | Purpose |
|---|---|---|
--interval | 30s | Time between health check probes |
--timeout | 30s | Maximum time a single probe can take before it is considered failed |
--start-period | 0s | Grace period for container initialization (failures during this period do not count toward retries) |
--retries | 3 | Number of consecutive failures required to mark the container as unhealthy |
When NOT to Use HEALTHCHECK
In Kubernetes deployments, liveness and readiness probes defined in the Pod spec are preferred over the Dockerfile HEALTHCHECK instruction. Kubernetes does not use the Docker HEALTHCHECK and its probes offer more granular control (separate liveness, readiness, and startup probes with configurable HTTP, TCP, and exec checks). However, HEALTHCHECK remains essential for Docker Compose and Docker Swarm environments where Kubernetes probes are not available.
Reference: Dockerfile reference - HEALTHCHECK
Use Heredocs for Multi-Line Scripts
Long RUN instructions with backslash continuations are hard to read, easy to break, and do not support inline comments between continued lines. A missing trailing backslash silently splits a single command into two separate commands, causing subtle build failures that are difficult to diagnose. BuildKit heredoc syntax (RUN <<EOF) allows writing multi-line scripts in natural shell syntax with full comment support.
Incorrect (backslash continuation -- fragile and hard to read):
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*(A missing \ after any line silently splits the command. For example, removing the backslash after ca-certificates causes libpq-dev to run as a standalone command, failing the build with a confusing error. Comments cannot be placed between continued lines without breaking the chain.)
Correct (heredoc syntax -- readable and resilient):
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libpq-dev
rm -rf /var/lib/apt/lists/*
EOF(Each command is a standalone line within the heredoc. Missing a backslash within the apt-get install list still fails clearly because the next line is a separate command. Comments can be added freely between lines without breaking the script.)
Correct (heredoc with explicit shell and error handling):
# syntax=docker/dockerfile:1
FROM node:22-slim
RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends tini
rm -rf /var/lib/apt/lists/*
# Verify tini was installed correctly
tini --version
EOF(The set -eux flags enable strict error handling: -e exits on any failure, -u treats unset variables as errors, and -x prints each command for build log debugging. Inline comments document intent without breaking the script.)
Correct (COPY heredoc for creating configuration files inline):
# syntax=docker/dockerfile:1
FROM nginx:1.27-alpine
COPY <<EOF /etc/nginx/conf.d/default.conf
server {
listen 8080;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
try_files \$uri \$uri/ /index.html;
}
location /health {
access_log off;
return 200 "ok";
}
}
EOF(The COPY heredoc creates a configuration file inline without needing a separate file in the build context. This keeps simple configs co-located with the Dockerfile for self-contained builds.)
Correct (multiple files in a single layer with heredocs):
# syntax=docker/dockerfile:1
FROM python:3.12-slim
COPY <<requirements.txt <<gunicorn.conf.py /app/
Flask==3.0.3
gunicorn==22.0.0
psycopg2-binary==2.9.9
requirements.txt
import multiprocessing
bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
timeout = 120
gunicorn.conf.py(Multiple heredocs in a single COPY instruction create several files in one layer. Each heredoc is terminated by its own delimiter -- requirements.txt and gunicorn.conf.py respectively.)
Requirements
Heredoc syntax requires BuildKit and the # syntax=docker/dockerfile:1 directive at the top of the Dockerfile. BuildKit is the default builder in Docker Engine 23.0+ and Docker Desktop 4.0+. For older Docker versions, enable BuildKit by setting DOCKER_BUILDKIT=1.
Reference: Building best practices
Use JSON Form for CMD and ENTRYPOINT
Shell form (CMD command arg1) wraps the process in /bin/sh -c, which means the shell becomes PID 1 instead of your application. The shell does not forward OS signals (SIGTERM, SIGINT) to the child process, so the application cannot shut down gracefully. In orchestrated environments like Kubernetes or Docker Swarm, this results in a 10-second forced kill timeout on every deploy, restart, or scale-down event.
Incorrect (shell form -- shell is PID 1, application does not receive signals):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD node server.js(The CMD node server.js instruction is interpreted as CMD ["/bin/sh", "-c", "node server.js"]. The shell becomes PID 1 and node runs as a child process. When Docker sends SIGTERM to stop the container, the shell receives it but does not forward it to node. After a 10-second grace period, Docker sends SIGKILL, forcibly terminating the process without cleanup.)
Correct (JSON/exec form -- application is PID 1, receives signals directly):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"](The CMD ["node", "server.js"] instruction runs node directly as PID 1 with no intermediate shell. SIGTERM is delivered to the node process, allowing it to close database connections, flush logs, and finish in-flight requests before exiting.)
Correct (ENTRYPOINT in exec form for Python applications):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
ENTRYPOINT ["python", "-m", "uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "8000"](Both ENTRYPOINT and CMD use exec form. The ENTRYPOINT defines the executable and CMD provides default arguments. Running docker run myimage --workers 4 replaces CMD but keeps the ENTRYPOINT, resulting in python -m uvicorn --workers 4.)
Shell Form vs Exec Form Reference
| Instruction | Shell Form | Exec Form |
|---|---|---|
| CMD | CMD node server.js | CMD ["node", "server.js"] |
| ENTRYPOINT | ENTRYPOINT python app.py | ENTRYPOINT ["python", "app.py"] |
| PID 1 | /bin/sh -c | Your application |
| Signal forwarding | No | Yes |
| Variable expansion | Yes ($HOME) | No (use shell explicitly if needed) |
When Shell Form is Acceptable
Shell form is needed when the command relies on shell features like variable expansion, pipes, or redirection. In that case, combine it with exec to ensure proper signal handling:
CMD ["sh", "-c", "exec java $JAVA_OPTS -jar /app/server.jar"](The exec replaces the shell with the java process, restoring proper PID 1 signal handling while retaining shell variable expansion.)
Reference: Building best practices
Use Absolute Paths with WORKDIR
Each RUN instruction executes in a new shell, so cd /path within a RUN command does not affect subsequent instructions. Using RUN cd /path && command is fragile because forgetting the && chain silently runs the command in the wrong directory. Relative WORKDIR paths (e.g., WORKDIR src) resolve against the previous WORKDIR, creating an implicit dependency chain that is hard to follow and easy to break during refactoring.
Incorrect (cd does not persist across RUN instructions):
FROM node:22-slim
RUN mkdir -p /usr/src/app
RUN cd /usr/src/app && npm install
RUN npm run build(The second RUN npm run build executes in / (the default working directory), not /usr/src/app. The cd in the previous RUN instruction only affected that instruction's shell. The build fails because there is no package.json in /.)
Incorrect (relative WORKDIR creates implicit dependency chain):
FROM node:22-slim
WORKDIR app
WORKDIR src
WORKDIR ../config(Each relative WORKDIR resolves against the previous one: app becomes /app, then src becomes /app/src, then ../config becomes /app/config. This chain is hard to follow and breaks if any WORKDIR is reordered or removed.)
Correct (absolute WORKDIR persists across all subsequent instructions):
FROM node:22-slim
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"](WORKDIR sets the working directory for all subsequent RUN, CMD, ENTRYPOINT, COPY, and ADD instructions. Both npm ci and npm run build execute in /usr/src/app without needing cd. The COPY . . instruction copies into /usr/src/app because the relative . destination resolves against WORKDIR.)
Correct (WORKDIR with multi-stage builds):
# -- Build stage --
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/server ./cmd/server
# -- Runtime stage --
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=build /bin/server ./server
ENTRYPOINT ["./server"](Each stage has its own WORKDIR. The build stage works in /src and the runtime stage works in /app. Using absolute paths in each stage makes the Dockerfile self-documenting and prevents confusion about which directory is active.)
Guidelines
- Always use absolute paths with WORKDIR (e.g.,
WORKDIR /app, notWORKDIR app). - Set WORKDIR once per stage near the top, after the FROM instruction. Avoid changing WORKDIR multiple times within a stage unless there is a clear reason.
- WORKDIR creates the directory if it does not exist. There is no need for a preceding
RUN mkdir -p /app. - Use WORKDIR instead of `cd` in RUN instructions. If a specific directory is needed temporarily, use
cdwithin a single chained command:RUN cd /tmp && wget ... && tar -xzf ....
Reference: Building best practices
Enable Docker Build Checks
Docker build checks analyze your Dockerfile for common anti-patterns -- shell-form CMD, secrets leaked through ARG, duplicate stage names, and more. Without checks enabled, these issues silently pass through the build and surface as runtime failures or security vulnerabilities in production.
Incorrect (building without checks -- anti-patterns silently pass):
# No check directive -- build proceeds even with issues
FROM ubuntu:24.04
ARG DB_PASSWORD
ENV DB_PASSWORD=$DB_PASSWORD
RUN apt-get update && apt-get install -y curl
# Shell form CMD -- PID 1 runs as /bin/sh -c wrapper, breaks signal handling
CMD node server.js(Anti-patterns like secrets in ARG, missing cleanup of apt lists, and shell-form CMD go completely undetected. The build succeeds and the image ships with all of these issues intact.)
Correct (build checks enabled to catch anti-patterns before build):
Validation-only mode from the CLI -- checks the Dockerfile without producing an image:
docker build --check .Or embed the check directive directly in the Dockerfile to enforce checks on every build:
# syntax=docker/dockerfile:1
# check=error=true
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
CMD ["node", "server.js"](The # check=error=true directive causes the build to fail if any check violations are found. This turns warnings into hard errors, preventing non-compliant images from being built.)
Skipping Specific Checks
Some checks may not apply to your use case. Skip individual checks with the skip parameter:
# check=error=true;skip=JSONArgsRecommended
# syntax=docker/dockerfile:1
FROM ubuntu:24.04
# Shell form is intentional here -- we need variable expansion
CMD echo "Starting $APP_NAME" && exec node server.jsSkip multiple checks with a comma-separated list:
# check=error=true;skip=JSONArgsRecommended,SecretsUsedInArgOrEnvUsing the Build Arg
Enable checks via the BUILDKIT_DOCKERFILE_CHECK build argument without modifying the Dockerfile:
# Run checks only (no image output)
docker build --check --build-arg BUILDKIT_DOCKERFILE_CHECK=error=true .
# Build normally but fail on check violations
docker build --build-arg BUILDKIT_DOCKERFILE_CHECK=error=true -t myapp .Requirements
Docker build checks require Docker Engine 27.0+ or Docker Desktop 4.33+. Older versions silently ignore the # check directive and --check flag.
Reference: Docker Build Checks
Use Standard Labels for Image Metadata
Without labels, container images are opaque blobs -- you cannot determine which commit built them, who maintains them, what license applies, or which version of the application they contain without pulling and inspecting the application itself. Standard OCI labels make images self-describing, enabling automated vulnerability scanning, license auditing, garbage collection policies, and faster incident triage.
Incorrect (no labels -- image metadata is empty):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"](docker inspect shows no useful metadata. When a CVE alert fires at 2 AM, you cannot determine which repository, commit, or team owns this image without tracing deployment configs backward.)
Correct (OCI standard labels for complete image provenance):
FROM node:22-slim
LABEL org.opencontainers.image.title="payment-service"
LABEL org.opencontainers.image.description="Handles payment processing and webhook delivery"
LABEL org.opencontainers.image.source="https://github.com/acme/payment-service"
LABEL org.opencontainers.image.version="1.2.3"
LABEL org.opencontainers.image.created="2025-01-15T10:30:00Z"
LABEL org.opencontainers.image.revision="a1b2c3d4e5f6"
LABEL org.opencontainers.image.authors="platform-team@acme.com"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Acme Corp"
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"](Every label follows the OCI org.opencontainers.image.* namespace. These are recognized by Docker Hub, GitHub Container Registry, and vulnerability scanners. docker inspect now returns actionable metadata for any image consumer.)
Single-Line Multi-Label Syntax
Combine multiple labels into a single instruction to reduce layers (though this has negligible impact with BuildKit):
LABEL org.opencontainers.image.source="https://github.com/acme/payment-service" \
org.opencontainers.image.version="1.2.3" \
org.opencontainers.image.licenses="MIT"Dynamic Labels with Build Args
Inject values from CI at build time instead of hardcoding them:
ARG GIT_SHA
ARG BUILD_DATE
ARG APP_VERSION
LABEL org.opencontainers.image.revision=$GIT_SHA
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.version=$APP_VERSIONdocker build \
--build-arg GIT_SHA=$(git rev-parse HEAD) \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg APP_VERSION=1.2.3 \
-t acme/payment-service:1.2.3 .OCI Standard Label Keys
| Label Key | Purpose | Example Value |
|---|---|---|
org.opencontainers.image.title | Human-readable name | payment-service |
org.opencontainers.image.description | Short description | Handles payments |
org.opencontainers.image.source | Repository URL | https://github.com/acme/repo |
org.opencontainers.image.version | Semver or tag | 1.2.3 |
org.opencontainers.image.created | RFC 3339 timestamp | 2025-01-15T10:30:00Z |
org.opencontainers.image.revision | VCS commit SHA | a1b2c3d4e5f6 |
org.opencontainers.image.authors | Contact information | team@example.com |
org.opencontainers.image.licenses | SPDX expression | MIT |
org.opencontainers.image.vendor | Organization name | Acme Corp |
org.opencontainers.image.url | Image homepage | https://hub.docker.com/r/acme/repo |
Querying Labels
# Inspect labels for a running container
docker inspect --format '{{json .Config.Labels}}' payment-service | jq .
# Filter images by label
docker images --filter "label=org.opencontainers.image.vendor=Acme Corp"Reference: Building best practices
Use pipefail for Piped RUN Commands
In a piped command (cmd1 | cmd2), the shell only evaluates the exit code of the last command in the pipeline. If an earlier command fails but the final command succeeds, the entire RUN instruction is considered successful. This means a failed download, a broken compilation step, or a corrupted data stream can silently pass through the build while producing an image with missing or incomplete content.
Incorrect (piped command without pipefail -- silent failure):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends wget
# If wget fails (DNS error, 404, timeout), bash receives empty input
# but exits 0 because bash itself succeeds -- build continues silently
RUN wget -O - https://example.com/install.sh | bash(If wget fails due to a network error or 404, bash receives empty input and exits with code 0. The build continues without any indication that the installation script never ran.)
Correct (pipefail causes the pipeline to fail on any command error):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends wget
# set -o pipefail makes the pipeline return the exit code of the
# first command that fails, not just the last command
RUN set -o pipefail && wget -O - https://example.com/install.sh | bash(With pipefail enabled, if wget exits with a non-zero code, the entire pipeline fails immediately and the build stops with a clear error.)
Correct (explicit shell form for images where /bin/sh is dash):
FROM debian:12-slim
# dash (the default /bin/sh on Debian) does not support pipefail.
# Explicitly invoke bash to ensure the option is available.
RUN ["/bin/bash", "-c", "set -o pipefail && curl -fsSL https://deb.nodesource.com/setup_22.x | bash -"]
RUN apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*(The exec form ["/bin/bash", "-c", "..."] guarantees bash is the interpreter regardless of what /bin/sh points to.)
Combining with set -e
For maximum safety, combine pipefail with set -e (exit on error) in multi-line scripts:
RUN set -euo pipefail && \
curl -fsSL https://example.com/gpg-key.asc | gpg --dearmor -o /usr/share/keyrings/example.gpg && \
echo "deb [signed-by=/usr/share/keyrings/example.gpg] https://example.com/repo stable main" \
> /etc/apt/sources.list.d/example.list && \
apt-get update && \
apt-get install -y --no-install-recommends example-package && \
rm -rf /var/lib/apt/lists/*Shell Compatibility
set -o pipefail is a bash feature. The default /bin/sh in Debian-based and Ubuntu-based images is dash, which does not support it. If your RUN instructions use the shell form (not exec form), they run under /bin/sh by default. Either use the exec form with an explicit bash invocation, or set the default shell for subsequent instructions:
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# All subsequent RUN instructions now use bash with pipefail enabled
RUN curl -fsSL https://example.com/install.sh | bashReference: Building best practices
One Concern per Container
Running multiple services in a single container -- such as a web server, application process, and background worker managed by supervisord -- prevents each service from being scaled, updated, restarted, or monitored independently. When one service crashes, it takes down the others. When one service needs more capacity, you must scale the entire bundle. Logs from multiple processes interleave, making debugging harder and structured logging pipelines unreliable.
Incorrect (multiple services in one container via supervisord):
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \
supervisor \
&& rm -rf /var/lib/apt/lists/*
COPY supervisord.conf /etc/supervisord.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY . /app
RUN pip install --no-cache-dir -r /app/requirements.txt
EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisord.conf"](This container runs nginx, gunicorn, and a celery worker under supervisord. If the celery worker OOMs, supervisord restarts it but the nginx/gunicorn processes may drop requests during the restart cycle. Scaling requires tripling resources for all three services even if only the worker needs more capacity. Container health is ambiguous -- the container is "running" even if two of three processes have crashed.)
Correct (separate containers for each concern):
Web server container:
# web.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER nobody
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000", "--workers", "4"]Background worker container:
# worker.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER nobody
CMD ["celery", "-A", "tasks", "worker", "--loglevel=info", "--concurrency=2"]Connected via Docker Compose:
services:
web:
build:
dockerfile: web.Dockerfile
ports:
- "8000:8000"
deploy:
replicas: 2
worker:
build:
dockerfile: worker.Dockerfile
deploy:
replicas: 4
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- web(Each service scales independently. The worker can be scaled to 4 replicas while the web server stays at 2. If the worker crashes, web requests continue uninterrupted. Each container produces a single log stream, making log aggregation straightforward.)
Not a Hard Rule
"One concern per container" is a guideline, not a strict "one process per container" rule. Helper processes that directly support the main process are acceptable:
- A log rotation sidecar that manages the main process's log files
- An init process like
tinithat reaps zombie processes - A startup script that runs migrations before handing off to the application
The distinction is between tightly coupled helpers (acceptable) and independent services (should be separate containers). If a process could reasonably have its own scaling policy, deployment lifecycle, or health check, it belongs in its own container.
Signals and Health
Separate containers also simplify signal handling and health checks:
# Each container has a clear health check for its single concern
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1With multiple services in one container, a single HEALTHCHECK cannot accurately represent the state of all services. One healthy process masks failures in the others.
Reference: Building best practices
Sort Multi-Line Arguments Alphabetically
Unordered package lists in RUN instructions make duplicates invisible, diffs noisy, and merge conflicts frequent. When packages are added ad hoc over months of development, the same package can appear two or three times without anyone noticing. Alphabetical ordering turns package lists into a predictable structure where duplicates are immediately obvious and version control diffs show exactly what changed.
Incorrect (unsorted packages with a hidden duplicate):
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libpq-dev \
curl \
build-essential \
curl \
wget \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*(curl appears twice -- difficult to spot in an unsorted list. Each duplicate adds noise to the install step and signals that the package list is not actively maintained. Reviewers scanning this list cannot quickly verify completeness or spot unrelated additions.)
Correct (alphabetically sorted, one package per line):
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
git \
libffi-dev \
libpq-dev \
wget \
&& rm -rf /var/lib/apt/lists/*(Alphabetical order makes duplicates impossible to miss. Diffs show a single added or removed line per package change. The trailing && rm -rf /var/lib/apt/lists/* cleanup is visually separate from the package list.)
Why This Matters for Code Review
Unsorted lists produce noisy diffs when packages are added or removed:
RUN apt-get update && apt-get install -y --no-install-recommends \
- git \
- libpq-dev \
curl \
build-essential \
+ jq \
+ libpq-dev \
+ git \
wget \The same change with a sorted list produces a clean, single-line diff:
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
git \
+ jq \
libpq-dev \
wget \Applies Beyond apt-get
The same principle applies to any multi-line argument list:
# pip packages
RUN pip install --no-cache-dir \
celery==5.4.0 \
flask==3.1.0 \
gunicorn==23.0.0 \
redis==5.2.1 \
sqlalchemy==2.0.36
# apk packages (Alpine)
RUN apk add --no-cache \
curl \
git \
openssh-client \
python3COPY and Multi-Source Instructions
Sort source files in multi-source COPY instructions for the same readability benefit:
COPY \
docker-entrypoint.sh \
healthcheck.sh \
migrate.sh \
/usr/local/bin/Reference: Building best practices
Enable SBOM and Provenance Attestations
Without attestations, there is no machine-readable record of what packages are inside an image or how it was built. This makes vulnerability auditing, license compliance, and incident response slow and unreliable -- teams resort to running containers and inspecting them manually. SBOM (Software Bill of Materials) and provenance attestations embed this metadata directly into the image index, enabling automated scanning and verifiable build provenance.
Incorrect (no attestation metadata):
docker build -t registry.example.com/myapp:latest .
docker push registry.example.com/myapp:latest(The pushed image contains no SBOM or provenance data. Vulnerability scanners must pull the image and perform a full filesystem scan to identify packages. There is no cryptographic proof of where or how the image was built.)
Correct (SBOM and provenance attestations enabled):
docker buildx build \
--sbom=true \
--provenance=true \
--push \
-t registry.example.com/myapp:latest .(The image is pushed with an SBOM listing all OS packages and application dependencies, plus a provenance attestation recording the build source, builder identity, and build parameters. Both are attached to the image index as OCI referrers.)
Correct (inspecting attestations after push):
# View the SBOM for a pushed image
docker buildx imagetools inspect \
registry.example.com/myapp:latest \
--format '{{ json .SBOM }}'
# View provenance data
docker buildx imagetools inspect \
registry.example.com/myapp:latest \
--format '{{ json .Provenance }}'(Attestation data is stored alongside the image manifest in the registry. It can be queried without pulling the full image layers.)
Correct (CI pipeline with attestations and Docker Scout scanning):
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER nobody
CMD ["gunicorn", "app.wsgi:application"]# Build, attest, and push
docker buildx build \
--sbom=true \
--provenance=mode=max \
--push \
-t registry.example.com/myapp:v1.2.3 .
# Scan the image using the attached SBOM
docker scout cves registry.example.com/myapp:v1.2.3(The mode=max provenance option captures the full build definition including the Dockerfile source. Docker Scout uses the SBOM to identify known CVEs without needing to unpack every layer.)
Key Details
- Attestations require `--push`: They are stored as additional manifests in the image index and cannot be attached when using
--load(which produces a single-platform image without an index). - SBOM generators: BuildKit uses Syft by default to scan the filesystem and generate a CycloneDX or SPDX SBOM.
- Provenance levels:
mode=minrecords basic build metadata;mode=maxadditionally captures the full build source and reproducibility information. - Registry support: Attestations are stored as OCI artifacts. Most modern registries (Docker Hub, GitHub Container Registry, Amazon ECR, Google Artifact Registry) support them.
Reference: Build attestations
Design Ephemeral, Stateless Containers
Containers that store application state, user uploads, or logs on their local filesystem cannot be safely stopped, replaced, or horizontally scaled. If the container crashes or is rescheduled, the data is lost. If multiple replicas are running, each has a different view of the state. This creates data loss risks, prevents zero-downtime deployments, and blocks horizontal scaling. Following the Twelve-Factor App methodology, containers should be disposable processes that can be started and stopped at any moment.
Incorrect (state written to the container filesystem):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
# Application writes uploads, sessions, and logs to local directories
# All data is lost when the container stops or is replaced
RUN mkdir -p /app/uploads /app/sessions /app/logs
EXPOSE 3000
CMD ["node", "server.js"](Uploads, sessions, and logs directories are created inside the container filesystem. Scaling to two replicas means half the requests see different uploads and different sessions. A container restart loses all accumulated data.)
Correct (state externalized, container is disposable):
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
# /data/uploads is expected to be provided as an external mount
# at runtime (docker run -v or compose volumes). Do not use
# the VOLUME instruction — it creates anonymous volumes that
# are hard to track and prevents downstream images from
# modifying this directory.
RUN mkdir -p /data/uploads && chown node:node /data/uploads
USER node
EXPOSE 3000
CMD ["node", "server.js"](The /data/uploads directory is created and owned by the node user. It is expected to be mounted at runtime via docker run -v or compose volumes — never via the VOLUME Dockerfile instruction, which creates untracked anonymous volumes. The application should store uploads in object storage (S3, GCS, MinIO), sessions in Redis or a database, and write logs to stdout for collection by the container runtime.)
Correct (Docker Compose with externalized state):
services:
app:
build: .
environment:
- S3_BUCKET=myapp-uploads
- REDIS_URL=redis://cache:6379
- DATABASE_URL=postgres://db:5432/myapp
volumes:
# Named volume for data that must persist locally (e.g., processing queue)
- upload-queue:/data/uploads
deploy:
replicas: 3
cache:
image: redis:7-alpine
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
upload-queue:
pgdata:(The application service can scale to 3 replicas because no state lives inside the containers. PostgreSQL and Redis handle persistence, and the named volume provides a shared staging area for upload processing.)
Ephemeral Container Checklist
| Pattern | Incorrect | Correct |
|---|---|---|
| File uploads | Write to container filesystem | Object storage (S3, GCS, MinIO) |
| Sessions | In-memory or filesystem store | Redis, Memcached, or database |
| Application logs | Write to file inside container | Stdout/stderr (12-factor logging) |
| Cache | Local filesystem | Redis, Memcached, or CDN |
| Scheduled state | Cron job writing to local file | Database or message queue |
Reference: Building best practices
Never Pass Secrets via ARG or ENV
Both ARG and ENV persist secret values in the image. ARG values are recorded in the build history and can be read with docker history --no-trunc. ENV values are baked into the image configuration and are visible in every running container via /proc/1/environ or docker inspect. Docker's built-in build check SecretsUsedInArgOrEnv specifically flags this pattern as a security violation.
Incorrect (secret passed as build argument):
FROM python:3.12-slim
# ARG value is recorded in build history in plain text
ARG AWS_SECRET_ACCESS_KEY
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# The secret is visible via: docker history --no-trunc <image>
RUN AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
python manage.py collectstatic --noinput
CMD ["gunicorn", "app.wsgi:application"](Running docker history --no-trunc on the built image displays the full ARG AWS_SECRET_ACCESS_KEY=AKIA... value. Anyone who pulls this image can extract the credential.)
Incorrect (secret persisted via ENV):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
# ENV persists in every layer and in the running container
ENV ADMIN_PASS=s3cret-p@ssw0rd
# Even unsetting it in a later layer does NOT remove it --
# the value is already committed in the ENV instruction's layer
RUN unset ADMIN_PASS
CMD ["node", "server.js"](The ENV ADMIN_PASS=s3cret-p@ssw0rd instruction is permanently recorded in the image metadata. Running docker inspect on any container from this image reveals the password. The unset in a subsequent RUN layer has no effect on the image configuration.)
Correct (secret mount with environment variable injection):
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Secret is injected as an env var for this RUN instruction only
# It is never written to any layer or image metadata
RUN --mount=type=secret,id=aws-key,env=AWS_SECRET_ACCESS_KEY \
python manage.py collectstatic --noinput
CMD ["gunicorn", "app.wsgi:application"]Build command (passing secret from host environment variable):
docker build \
--secret id=aws-key,env=AWS_SECRET_ACCESS_KEY \
-t myapp .(The AWS_SECRET_ACCESS_KEY environment variable exists only for the duration of the RUN instruction. It does not appear in docker history, docker inspect, or any image layer.)
Correct (secret mount from file):
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
# Secret mounted as a file, read at build time, never persisted
RUN --mount=type=secret,id=admin-pass \
ADMIN_PASS=$(cat /run/secrets/admin-pass) \
node scripts/seed-admin.js
CMD ["node", "server.js"]Build command (passing secret from a file):
docker build \
--secret id=admin-pass,src=./admin-password.txt \
-t myapp .(The secret file is mounted into the build container at /run/secrets/admin-pass for a single RUN instruction. It is never committed to the image filesystem.)
Runtime Secrets
For secrets needed at runtime (not just build time), use orchestrator-level secret management instead of ENV:
- Docker Swarm:
docker service create --secret db_password ...mounts secrets at/run/secrets/inside running containers. - Kubernetes: Use
Secretresources mounted as volumes or injected via the Secrets Store CSI driver. - Docker Compose: Use the
secretstop-level key to mount secrets from files or environment into containers at runtime.
Reference: SecretsUsedInArgOrEnv
Avoid Installing Unnecessary Packages
Every package installed in a container is a potential vulnerability vector. Debug utilities, editors, network diagnostic tools, and documentation packages are useful during development but serve no purpose in a production image. They increase the image size, extend build times, and -- critically -- widen the attack surface by introducing binaries an attacker could leverage after gaining initial access.
Incorrect (debug and convenience tools in a production image):
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
vim \
curl \
wget \
net-tools \
iputils-ping \
htop \
strace \
tcpdump \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "app.wsgi:application"](None of these tools are needed to serve the application. tcpdump and strace give an attacker network sniffing and process tracing capabilities. curl and wget enable downloading additional payloads after initial compromise. Installing them adds ~80MB and dozens of additional CVE-trackable packages.)
Correct (only runtime dependencies, no recommends):
FROM python:3.12-slim
WORKDIR /app
# Install only the runtime libraries the application actually needs
# --no-install-recommends prevents apt from pulling suggested packages
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER nobody
CMD ["gunicorn", "app.wsgi:application"](Only libpq5 is installed -- the runtime library needed by psycopg2 for PostgreSQL connectivity. The --no-install-recommends flag prevents apt from pulling in additional suggested packages that are not strict dependencies.)
Correct (debug tools isolated in a development stage):
# syntax=docker/dockerfile:1
# -- Base stage: shared runtime configuration --
FROM python:3.12-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# -- Development stage: includes debug tools --
FROM base AS development
RUN apt-get update && apt-get install -y --no-install-recommends \
vim \
curl \
net-tools \
strace \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir debugpy ipdb
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "manage.py", "runserver"]
# -- Production stage: minimal runtime only --
FROM base AS production
USER nobody
CMD ["gunicorn", "app.wsgi:application"]Build commands:
# Development (includes debug tools)
docker build --target development -t myapp:dev .
# Production (no debug tools, minimal attack surface)
docker build --target production -t myapp:prod .(Debug tools exist only in the development stage. The production target inherits from base and contains nothing beyond the application and its runtime dependencies.)
Audit Checklist
Before shipping an image to production, verify there are no unnecessary packages:
# List all installed packages in the image
docker run --rm myapp:prod dpkg -l
# Check for common debug tools that should not be present
docker run --rm myapp:prod which curl wget vim strace tcpdump 2>&1Reference: Building best practices
Copy Only Final Artifacts Between Stages
Copying entire directories between stages defeats the purpose of multi-stage builds by pulling in source files, build caches, dev dependencies, and test fixtures. Be explicit about exactly which output files the runtime stage needs.
Incorrect (copies entire /app including source, tests, and dev deps):
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY src/ src/
RUN npm ci
RUN npm run build
FROM node:22-alpine
WORKDIR /app
# Brings along src/, node_modules (with devDependencies), tsconfig.json, .cache
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "dist/server.js"]Correct (copies only compiled output and production dependencies):
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY src/ src/
RUN npm ci
RUN npm run build
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-alpine
WORKDIR /app
# Only the compiled JavaScript
COPY --from=build /app/dist /app/dist
# Only production dependencies — no devDependencies
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/package.json /app/package.json
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Final image has no TypeScript source, no tsconfig, no build cache, no dev depsReference: Multi-stage builds
Related skills
FAQ
What does dockerfile-optimise do?
dockerfile-optimise: A skill for development. This provides functionality for development workflows.
When should I use dockerfile-optimise?
When you need to use dockerfile-optimise for development tasks, or when dockerfile-optimise: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
dockerfile-optimise.