Building Docker Images
Writing a Dockerfile that works is easy. Writing one that builds quickly, produces a small image, rebuilds efficiently when source changes, and does not leak credentials takes a few specific techniques. This page covers those techniques and the modern build tooling — BuildKit and buildx — that most older Docker material predates.
Table of Contents
- Dockerfile Fundamentals
- Layer Caching
- COPY vs ADD
- ARG vs ENV
- ENTRYPOINT vs CMD
- .dockerignore
- Multi-Stage Builds
- BuildKit
- Cache Mounts
- Build Secrets
- buildx and Multi-Arch Builds
- Image Size Optimization
- Metadata and Health Checks
- Inspecting Images
- Best Practices
Dockerfile Fundamentals
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
# Dependency manifests first — see Layer Caching
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Source last, because it changes most often
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
docker build -t myapp:1.0 .
docker build -t myapp:1.0 -f docker/Dockerfile.prod .
docker build -t myapp:1.0 --build-arg NODE_VERSION=20 .
The # syntax=docker/dockerfile:1 line on the first line opts into the current Dockerfile
frontend, which is what makes --mount, --link, and heredocs available. It is worth
including in every Dockerfile.
Instruction Reference
| Instruction | Purpose | Creates a layer |
|---|---|---|
FROM |
Base image, or start of a stage | Yes |
RUN |
Execute a command at build time | Yes |
COPY |
Copy files from context or another stage | Yes |
ADD |
Copy, plus tar extraction and URL fetching | Yes |
WORKDIR |
Set the working directory | Metadata |
ENV |
Environment variable, persists at runtime | Metadata |
ARG |
Build-time variable | Metadata |
EXPOSE |
Document a port (see networking) | Metadata |
USER |
Set the user for subsequent instructions and runtime | Metadata |
VOLUME |
Declare an anonymous volume mount point | Metadata |
ENTRYPOINT |
The executable | Metadata |
CMD |
Default arguments, or the command | Metadata |
HEALTHCHECK |
Liveness probe | Metadata |
LABEL |
Arbitrary metadata | Metadata |
Layer Caching
Each instruction produces a layer. On rebuild, Docker reuses a cached layer when the instruction and its inputs are unchanged — and once one layer misses, every subsequent layer is rebuilt. Ordering instructions by how often they change is therefore the single highest-leverage optimization available.
Order From Least to Most Volatile
# Wrong — any source change reinstalls all dependencies
COPY . .
RUN npm ci
# Right — dependencies reinstall only when the manifests change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
The same pattern applies to every ecosystem:
# Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Go
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release
COPY src ./src
RUN touch src/main.rs && cargo build --release
Combine Related RUN Commands
Each RUN is a layer, and a file deleted in a later layer still occupies space in the
earlier one:
# Wrong — the apt lists remain in layer 1 regardless of the rm in layer 2
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Right — single layer, cleanup included
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Warning
RUN apt-get update in its own layer is a classic cache trap. If the update layer is
cached from weeks ago and a later apt-get install runs fresh, you install against a stale
package index and may pull a version that no longer exists. Always chain update and
install in one RUN.
Inspecting Cache Behavior
# Show layer sizes and the instruction that created each
docker history myapp:1.0
# Build with no cache to confirm reproducibility
docker build --no-cache -t myapp:1.0 .
# Invalidate from a specific stage onward
docker build --no-cache-filter builder -t myapp:1.0 .
COPY vs ADD
Both copy files into the image. The difference is that ADD does extra things — some
useful, some surprising:
| Behavior | COPY |
ADD |
|---|---|---|
| Copy local files | Yes | Yes |
| Auto-extract local tar archives | No | Yes |
| Fetch remote URLs | No | Yes |
| Fetch a Git repository | No | Yes (current syntax) |
Use COPY by default. The automatic tar extraction is the problem: ADD archive.tar.gz /
silently unpacks, so a file that happens to be an archive behaves differently from every
other file, and the result is not obvious from reading the Dockerfile.
# Predictable
COPY app.jar /opt/app/
# Legitimate use of ADD — extracting a tarball you control
ADD rootfs.tar.gz /
# Downloading with ADD: verify the checksum
ADD --checksum=sha256:24454f830c... https://example.com/tool.tar.gz /tmp/
# Prefer this when you need control over the fetch
RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/tool.tar.gz \
&& echo "24454f830c... /tmp/tool.tar.gz" | sha256sum -c - \
&& tar xzf /tmp/tool.tar.gz -C /opt \
&& rm /tmp/tool.tar.gz
The RUN curl form has one further advantage: download, verify, extract, and delete happen
in a single layer, so the archive never persists in the image.
COPY --link
BuildKit's --link creates the layer independently of the previous filesystem state, so it
stays cached even when earlier layers change:
COPY --link package.json package-lock.json ./
COPY --link --from=builder /app/dist /app/dist
It also enables --chown and --chmod without an extra RUN:
COPY --chown=node:node --chmod=755 entrypoint.sh /usr/local/bin/
ARG vs ENV
# ARG — available during build only
ARG NODE_VERSION=20
ARG BUILD_DATE
# ENV — baked into the image, present at runtime
ENV NODE_ENV=production
ENV PATH="/opt/app/bin:${PATH}"
ARG |
ENV |
|
|---|---|---|
| Available during build | Yes | Yes |
| Present in the running container | No | Yes |
| Settable at build time | --build-arg |
No |
| Overridable at run time | No | -e / --env |
| Visible in image history | Yes | Yes |
ARG Before FROM
An ARG declared before the first FROM is global and usable in FROM lines, but is not
available inside build stages unless re-declared:
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
# Must re-declare to use it inside the stage
ARG NODE_VERSION
RUN echo "Building with Node ${NODE_VERSION}"
Never Put Secrets in ARG
# WRONG — recoverable from the image
ARG API_TOKEN
RUN curl -H "Authorization: Bearer ${API_TOKEN}" https://internal/artifact
# Anyone with the image can read it
docker history --no-trunc myapp:1.0 | grep -i token
Build arguments are recorded in image metadata. Use build secrets instead.
ENTRYPOINT vs CMD
# Exec form — no shell, signals reach the process directly. Use this.
ENTRYPOINT ["node", "server.js"]
# Shell form — wraps in /bin/sh -c, the process becomes a child of sh
ENTRYPOINT node server.js
The distinction matters for shutdown. In shell form, the shell is PID 1 and does not forward
SIGTERM to your application, so docker stop waits the full timeout and then kills the
container — losing in-flight work and skipping cleanup.
The two instructions compose: ENTRYPOINT is the executable, CMD supplies default
arguments that a docker run argument replaces.
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]
docker run myimage # nginx -g "daemon off;"
docker run myimage -t # nginx -t
PID 1 and Zombie Reaping
Applications that spawn child processes need a real init to reap them:
docker run --init myapp
# Or build one in
ENTRYPOINT ["/usr/bin/tini", "--", "node", "server.js"]
.dockerignore
The build context is everything Docker sends to the daemon before the build starts. Without
a .dockerignore, that includes .git, node_modules, build output, and any local secrets
— slowing every build and risking their inclusion in the image via COPY . ..
# Version control
.git
.gitignore
# Dependencies — reinstalled inside the image
node_modules
vendor
__pycache__
*.pyc
# Build output
dist
build
target
*.egg-info
# Local environment and secrets
.env
.env.*
*.pem
*.key
secrets/
.aws
.npmrc
# Editor and OS noise
.vscode
.idea
.DS_Store
# Docker files themselves
Dockerfile*
docker-compose*.yml
.dockerignore
# Documentation and CI
README.md
docs/
.github/
# See how large the context is
docker build --progress=plain . 2>&1 | head -3
Important
Excluding .env and key material here is a genuine security control, not just an
optimization. COPY . . with a careless context has put credentials into published images
many times over.
Negation works for allow-listing:
*
!src/
!package.json
!package-lock.json
Multi-Stage Builds
Build in one stage with the full toolchain, then copy only the artifacts into a minimal runtime image. This is the most effective size reduction available.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
A Go binary in distroless/static lands around 10–20 MB against roughly 900 MB for the
golang build image.
Node Example
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Targeting a Stage
# Build only up to a named stage — useful for a test image in CI
docker build --target build -t myapp:build .
FROM build AS test
RUN npm run test:ci
docker build --target test . # fails the build if tests fail
Copying From an External Image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
BuildKit
BuildKit is the current builder and is the default for docker build in modern releases. It
parallelizes independent stages, skips stages whose output is unused, and adds the --mount
family of features.
# Confirm it is active — output shows the structured step display
docker build .
# Force it on older versions
DOCKER_BUILDKIT=1 docker build .
To make it the default on an older daemon, set it in daemon.json:
{
"features": { "buildkit": true }
}
Build Output and Progress
# Full, non-collapsed log — essential for debugging a failing build
docker build --progress=plain .
# Export the built filesystem instead of an image
docker build --output type=local,dest=./out --target artifacts .
Cache Mounts
Package managers re-download everything on each build because their caches live in layers that get invalidated. A cache mount gives them a persistent directory that is not part of the image:
# syntax=docker/dockerfile:1
# apt
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
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o /out/app ./cmd/app
# Cargo
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release
Note that with an apt cache mount you must not delete /var/lib/apt/lists afterwards,
and Debian images ship a config that auto-cleans the cache — override it:
RUN rm -f /etc/apt/apt.conf.d/docker-clean
sharing=locked serializes concurrent builds against the same cache; sharing=private
gives each build its own.
Bind Mounts at Build Time
Use files from the context without copying them into a layer:
RUN --mount=type=bind,source=package-lock.json,target=/app/package-lock.json \
--mount=type=bind,source=package.json,target=/app/package.json \
npm ci
Build Secrets
Secrets mounted this way are available to a single RUN and never enter the image or its
history.
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
RUN --mount=type=secret,id=api_key \
curl -H "Authorization: Bearer $(cat /run/secrets/api_key)" \
-fsSL https://internal.example.com/artifact.tar.gz -o /tmp/artifact.tar.gz
docker build \
--secret id=npmrc,src=$HOME/.npmrc \
--secret id=api_key,env=API_KEY \
-t myapp .
Secrets default to /run/secrets/<id>; target= overrides that.
SSH Forwarding for Private Repositories
RUN --mount=type=ssh \
mkdir -p -m 0700 ~/.ssh \
&& ssh-keyscan github.com >> ~/.ssh/known_hosts \
&& git clone git@github.com:example/private-lib.git
docker build --ssh default -t myapp .
Verifying Nothing Leaked
docker history --no-trunc myapp:latest
docker save myapp:latest | tar -xO | grep -ri 'BEGIN PRIVATE KEY' || echo "clean"
For continuous checking, image scanners detect embedded credentials — see Image Security.
buildx and Multi-Arch Builds
docker buildx extends builds with multiple platforms, remote builders, and richer cache
export. It ships with current Docker installs.
docker buildx version
docker buildx ls
Creating a Builder
The default docker driver cannot do multi-platform builds. Create one that can:
docker buildx create --name multiarch --driver docker-container --bootstrap --use
docker buildx inspect
Emulation
Building for foreign architectures requires QEMU binfmt handlers:
docker run --privileged --rm tonistiigi/binfmt --install all
ls /proc/sys/fs/binfmt_misc/ | grep qemu
Building for Multiple Platforms
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/myapp:1.0 \
--push .
Important
Multi-platform builds must be pushed to a registry, not loaded locally. The local image
store holds one architecture per tag, so --load fails with more than one platform. Build
a single platform with --load for local testing, and use --push for the real artifact.
# Local testing, single platform
docker buildx build --platform linux/arm64 -t myapp:test --load .
# Verify the published manifest
docker buildx imagetools inspect registry.example.com/myapp:1.0
Native Builders Instead of Emulation
QEMU emulation is correct but slow — often 5–10× — and some toolchains fail under it. Where build time matters, attach a native node per architecture:
docker buildx create --name multiarch --driver docker-container \
--node amd64 --platform linux/amd64
docker buildx create --append --name multiarch \
--node arm64 --platform linux/arm64 ssh://user@arm-builder
Cross-compilation is the other answer, and the better one for Go and Rust:
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app .
BUILDPLATFORM is the builder's architecture; TARGETPLATFORM, TARGETOS, and
TARGETARCH describe the requested output. Pinning the build stage to $BUILDPLATFORM
keeps the compiler running natively while producing a foreign binary.
Registry Cache
Sharing cache across CI runners, where the local cache is always cold:
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:1.0 --push .
mode=max exports intermediate layers as well as the final ones — larger, but far more
likely to hit. GitHub Actions has a dedicated backend:
--cache-from type=gha --cache-to type=gha,mode=max
Image Size Optimization
Choosing a Base
| Base | Approx. size | Notes |
|---|---|---|
scratch |
0 | Static binaries only; no shell, no libc |
distroless |
2–20 MB | Runtime libraries, no shell or package manager |
alpine |
~7 MB | musl libc; small but watch for glibc incompatibilities |
*-slim |
30–80 MB | Debian, trimmed |
| Full distro | 100–1000 MB | Build stages only |
Note
Alpine uses musl rather than glibc. Most software is fine, but Python wheels frequently
lack musl builds and fall back to compiling from source, which can make an Alpine image
both slower to build and larger than the Debian -slim equivalent. Measure rather than
assuming Alpine is smaller for your stack.
Techniques That Actually Matter
# 1. Multi-stage — the single biggest win
# 2. Install only what is needed, and clean up in the same layer
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# 3. Skip pip's wheel cache
RUN pip install --no-cache-dir -r requirements.txt
# 4. Production dependencies only
RUN npm ci --omit=dev && npm cache clean --force
# 5. Strip debug symbols from compiled binaries
RUN go build -ldflags="-s -w" -o /out/app
Finding the Bloat
docker images myapp
docker history myapp:1.0 --human --format "table {{.Size}}\t{{.CreatedBy}}"
# Layer-by-layer exploration
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest myapp:1.0
Metadata and Health Checks
OCI Labels
LABEL org.opencontainers.image.title="myapp" \
org.opencontainers.image.description="Example service" \
org.opencontainers.image.source="https://github.com/example/myapp" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.vendor="Example Ltd"
ARG VERSION
ARG REVISION
ARG BUILD_DATE
LABEL org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}"
docker build \
--build-arg VERSION="$(git describe --tags --always)" \
--build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t myapp:latest .
docker inspect -f '{{json .Config.Labels}}' myapp:latest | jq
Populating image.source is what lets registries such as GHCR link a package back to its
repository.
HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:3000/health || exit 1
--start-period covers slow startup: failures during it do not count toward --retries.
For images without curl, ship a tiny purpose-built probe rather than adding a package:
HEALTHCHECK CMD ["/app", "healthcheck"]
Inspecting Images
# Configuration and metadata
docker inspect myapp:1.0
# Layer history with sizes
docker history myapp:1.0
# What is in the filesystem, without running it
docker create --name tmp myapp:1.0
docker export tmp | tar -tv | head -50
docker rm tmp
# Manifest and platforms of a remote image, no pull
docker buildx imagetools inspect registry.example.com/myapp:1.0 --raw
Best Practices
Correctness
- Pin base image tags —
node:20.11.1-alpine3.19, notnode:latest. For full reproducibility pin by digest:node:20-alpine@sha256:... - Chain
apt-get updatewithapt-get installin oneRUN - Use exec form for
ENTRYPOINTandCMDso signals are delivered - Commit a lockfile and install from it (
npm ci,pip install -r,go mod download)
Security
- Run as a non-root
USER - Never pass secrets through
ARGorENV; use--mount=type=secret - Keep
.env, keys, and.gitout of the build context - Scan images in CI — see Image Security
- Prefer minimal bases; a missing shell removes a whole class of exploitation
Speed and size
- Order instructions least- to most-volatile
- Use multi-stage builds
- Use cache mounts for package managers
- Maintain a real
.dockerignore - Share cache across CI with
--cache-from/--cache-to
Maintainability
- Add OCI labels, especially
image.sourceandimage.revision - Name build stages (
AS builder) rather than relying on indices - Add a
HEALTHCHECKfor anything long-running
Related Topics
- Registries — tagging, pushing, and signing what you build
- Docker Storage — how layers consume disk, and pruning the build cache
- Docker Networking —
EXPOSEversus published ports - Docker Compose —
build:configuration in a stack - Container Security — image scanning, SBOMs, and signing
- CI/CD Pipelines — automating builds