Dockerfile
Writing small, cache-friendly, non-root images with BuildKit and the docker/dockerfile:1 frontend
(Dockerfile 1.2x, Buildx 0.15+ for build checks). Running and shipping images is in
Docker.
Instructions
| Instruction | Does | Notes |
|---|---|---|
FROM img[:tag][@digest] [AS name] | start a stage | --platform=$BUILDPLATFORM for cross-builds |
RUN cmd | execute at build time, commit a layer | --mount, --network=none, heredocs |
COPY src… dest | files from the context or --from=stage | --chown, --chmod, --link, --exclude, --parents |
ADD src… dest | like COPY, plus URLs, git repos, auto-extracting local tars | --checksum=sha256:…; prefer COPY otherwise |
WORKDIR /app | set (and create) the cwd | use absolute paths |
ENV K=v | env var for later steps and runtime | inherited by child images |
ARG K[=default] | build-time variable | per stage; not in the final env |
EXPOSE 3000[/udp] | document a port | publishes nothing |
USER app[:group] | user for later RUN, CMD, ENTRYPOINT | name or UID |
ENTRYPOINT ["bin"] | the executable | override with --entrypoint |
CMD ["arg"] | default command or default args to ENTRYPOINT | replaced by docker run img … args |
HEALTHCHECK CMD … | container health probe | HEALTHCHECK NONE disables an inherited one |
LABEL k=v | image metadata | OCI keys below |
VOLUME /data | declare a mount point | anonymous volume per container; avoid in app images |
SHELL ["bash", "-o", "pipefail", "-c"] | shell for shell-form commands | default ["/bin/sh", "-c"] |
STOPSIGNAL SIGQUIT | signal sent by docker stop | default SIGTERM |
ONBUILD COPY . /app | trigger run when this image is used as a base | for builder base images only; surprising elsewhere |
MAINTAINER | deprecated | use LABEL org.opencontainers.image.authors |
Exec form vs shell form
| Form | Example | Runs as | Use |
|---|---|---|---|
| Exec (JSON) | CMD ["bun", "server.ts"] | the process itself is PID 1, gets signals | CMD, ENTRYPOINT |
| Shell | CMD bun server.ts | /bin/sh -c "…": sh is PID 1 and swallows SIGTERM | RUN, when you need &&, pipes, $VAR |
Exec form does no variable expansion: ["sh", "-c", "exec bun \"$APP\""] if you need it. JSON needs
double quotes; single quotes silently fall back to shell form.
ENTRYPOINT | CMD | docker run img runs | docker run img x runs |
|---|---|---|---|
| none | ["bun", "a.ts"] | bun a.ts | x |
["bun"] | ["a.ts"] | bun a.ts | bun x |
["tini", "--", "bun"] | ["a.ts"] | tini -- bun a.ts | tini -- bun x |
bun a.ts (shell) | anything | sh -c "bun a.ts", CMD ignored | same |
Syntax directive & BuildKit
# syntax=docker/dockerfile:1
# check=error=true;skip=JSONArgsRecommended
# escape=\
FROM oven/bun:1| Directive | Effect |
|---|---|
# syntax=docker/dockerfile:1 | pull the latest stable 1.x frontend at build time, so new features work on older daemons |
# syntax=docker/dockerfile:1.2x | pin a minor version for reproducible parsing |
# check=error=true | turn build-check warnings into failures |
# check=skip=RuleA,RuleB | silence specific checks |
# escape= backtick | change the escape char (Windows paths) |
Directives must come before any other line, comments included. BuildKit is the only builder in
current Docker: parallel stages, skipped unused stages, RUN --mount, secrets and heredocs all rely on it.
Automatic platform ARGs: BUILDPLATFORM, BUILDOS, BUILDARCH (the machine running the build) and
TARGETPLATFORM, TARGETOS, TARGETARCH, TARGETVARIANT (what the image is for). Declare them with
ARG TARGETARCH inside a stage before using them.
Build context & .dockerignore
The context is the directory (or git URL / tarball) sent to the builder. COPY can only read from
it, and every file in it can affect cache keys, so exclude everything the image doesn't need.
api/.dockerignore # applies to whole contextDockerfileDockerfile.devDockerfile.dev.dockerignore # overrides .dockerignorepackage.jsonbun.locksrc/node_modules/ # ignored: image installs itdist/ # ignored: built in image.git/ # ignored: busts the cache.env # ignored: keep secrets out**/node_modules
**/dist
**/.next
.git
.env*
!.env.example
*.log
coverage
Dockerfile*
compose*.yaml| Rule | Meaning |
|---|---|
node_modules | only at the context root (unlike .gitignore) |
**/node_modules | at any depth |
!pattern | re-include; later lines win |
*.md then !README.md | exclude all but one |
A COPY . . of a context holding .git or a local node_modules is the most common cause of slow,
never-cached builds. The experimental CopyIgnoredFile build check flags COPY sources that are ignored.
Layer caching
| Rule | Consequence |
|---|---|
| Each step's cache key = parent layer + instruction text | edit a line and that step reruns |
COPY / ADD also hash file contents and metadata | touching an unrelated file in a copied dir busts it |
RUN keys on the command string only | apt-get update or curl …/latest stays stale until the line changes |
| A miss invalidates every later step in that stage | put rarely-changing steps first |
Changing an ARG value busts steps that use it | declare ARGs as late as possible |
--mount=type=cache survives misses | re-installs reuse downloaded packages |
COPY --link makes a layer independent of what's below | base image bumps don't rebuild it |
# ✅ lockfile first: source edits don't reinstall deps
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build# ❌ any source change reinstalls everything
COPY . .
RUN bun install --frozen-lockfileOrder: base image, system packages, lockfiles, dependency install, source, build. Share cache across
CI runs with --cache-to/--cache-from (type=registry or type=gha, mode=max to include
intermediate stages).
Multi-stage builds
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile && bun run build
FROM build AS test
RUN bun test
FROM nginx:stable-alpine AS release
COPY --from=build /app/dist /usr/share/nginx/html| Pattern | How |
|---|---|
| Build tools stay behind | copy only artifacts into the last stage |
| Pick a stage | docker build --target test . |
| Unused stages | skipped entirely by BuildKit |
| Parallelism | independent stages build concurrently |
| Copy from any image | COPY --from=ghcr.io/astral-sh/uv:0.12.19 /uv /bin/ |
| Named contexts | --build-context shared=../shared, then COPY --from=shared |
| Base stage | FROM node:24-slim AS base, then FROM base AS deps, … |
| Last stage is the default | put the release stage last |
RUN mounts
| Mount | Syntax | Use |
|---|---|---|
| cache | --mount=type=cache,target=/root/.npm | persistent package-manager cache, never in the image |
| secret | --mount=type=secret,id=npmrc,target=/root/.npmrc | tokens for one step |
| secret as env | --mount=type=secret,id=token,env=GH_TOKEN | same, exposed as an env var |
| ssh | --mount=type=ssh | private git clones through the forwarded agent |
| bind | --mount=type=bind,source=go.mod,target=go.mod | read files without a COPY layer |
| tmpfs | --mount=type=tmpfs,target=/tmp | scratch space |
Cache options: id (share between stages or Dockerfiles), sharing=shared\|private\|locked
(locked for apt), uid/gid/mode for non-root steps.
| Tool | Cache target |
|---|---|
| Bun | /root/.bun/install/cache |
| npm | /root/.npm |
| pnpm (official image) | /pnpm/store |
| uv / pip | /root/.cache/uv / /root/.cache/pip |
| Go | /go/pkg/mod and /root/.cache/go-build |
| Cargo | /usr/local/cargo/registry and /app/target |
| apt | /var/cache/apt and /var/lib/apt (sharing=locked) |
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm ci
RUN --mount=type=ssh \
git clone git@github.com:acme/private.git /srcdocker build --secret id=npmrc,src="$HOME/.npmrc" .
docker build --secret id=token,env=GH_TOKEN .
docker build --ssh default .apt with cache mounts
RUN rm -f /etc/apt/apt.conf.d/docker-clean
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 ca-certificates curlWithout cache mounts, clean up in the same RUN: && rm -rf /var/lib/apt/lists/*. A later
RUN rm only hides the files; the earlier layer still ships them.
Heredocs
RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF
COPY <<EOF /etc/app/config.toml
port = 3000
log_level = "info"
EOF
RUN <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "bash features here"
EOFA heredoc RUN is one script: without set -e, only the last command's exit code counts.
ARG vs ENV
ARG | ENV | |
|---|---|---|
| Available in | later instructions of the same stage | later instructions and the running container |
| Set by | --build-arg K=v | the Dockerfile; overridable with docker run -e |
| Survives into child images | no | yes |
| Scope | before first FROM: only FROM lines; redeclare ARG K inside a stage | the stage and stages built FROM it |
Visible in docker history | yes, when used by a RUN | yes |
| Secrets | never (SecretsUsedInArgOrEnv check warns) | never |
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim
ARG VERSION=dev
# promote a build arg to runtime
ENV APP_VERSION=$VERSIONBase images
| Base | Approx. size | libc | Trade-off |
|---|---|---|---|
debian:trixie-slim, *-slim | ~30 MB | glibc | safe default; apt, shell, prebuilt binaries just work |
alpine, *-alpine | ~5 MB | musl | small; see caveats |
gcr.io/distroless/*-debian13 | ~2–60 MB | glibc | no shell or package manager; :nonroot and :debug tags |
Docker Hardened Images (dhi.io/…) | small | glibc or musl | minimal, near-zero CVEs, free; -dev variants for build stages |
scratch | 0 | none | static binaries only; bring CA certs, tzdata, /etc/passwd |
Full (node:24, python:3.14) | 300 MB+ | glibc | build stages only |
musl caveats on Alpine:
- Native Node addons and Python wheels need
muslbuilds; many fall back to compiling (slow) or fail.bunhas a separateoven/bun:1-alpinebuild. - DNS resolution differs (no parallel A/AAAA handling like glibc,
search/ndotsquirks), which shows up in Kubernetes. - The default allocator is slower for allocation-heavy workloads.
- Tools copied from glibc images won't run.
Users & permissions
| Base | Built-in non-root user |
|---|---|
node:* | node (UID 1000) |
oven/bun:* | bun (UID 1000) |
distroless :nonroot | nonroot (UID 65532) |
nginxinc/nginx-unprivileged | nginx (UID 101), listens on 8080 |
| Debian / Ubuntu | useradd --system --uid 10001 app |
| Alpine | adduser -S -u 10001 app |
FROM debian:trixie-slim
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app \
--no-create-home app
WORKDIR /app
# root-owned code: the app can't rewrite itself
COPY --chmod=0755 bin/ ./bin/
# writable dir for the app user
RUN mkdir /app/data && chown app:app /app/data
USER 10001:10001Use a numeric USER so Kubernetes runAsNonRoot can verify it. Leave code owned by root and
read-only; chown only the directories the app writes. Prefer ports at or above 1024 so nothing
needs NET_BIND_SERVICE.
Signals & PID 1
| Problem | Why | Fix |
|---|---|---|
docker stop waits 10 s then kills (exit 137) | shell-form CMD makes sh PID 1 and it doesn't forward SIGTERM | exec form |
App ignores SIGTERM even in exec form | PID 1 gets no default signal handlers from the kernel | handle the signal, or run an init |
| Zombie processes pile up | PID 1 must reap orphaned children | --init, Compose init: true, or tini |
npm start / pnpm start as CMD | the package manager sits between Docker and the app | run node dist/server.js directly |
| Entrypoint script | the script stays PID 1 | end it with exec "$@" |
App expects SIGINT / SIGQUIT | wrong default signal | STOPSIGNAL |
# Debian: apt-get install -y tini
ENTRYPOINT ["tini", "--"]
CMD ["bun", "src/index.ts"]const server = Bun.serve({
port: Number(process.env.PORT ?? 3000),
fetch: () => new Response("ok"),
});
async function shutdown(signal: NodeJS.Signals) {
console.info(`${signal}: draining`);
await server.stop(); // finish in-flight requests
process.exit(0);
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);Healthchecks & labels
HEALTHCHECK option | Default |
|---|---|
--interval | 30s |
--timeout | 30s |
--start-period | 0s (failures don't count during it) |
--start-interval | 5s (probe rate during the start period) |
--retries | 3 |
COPY healthcheck.ts ./
HEALTHCHECK --interval=30s --timeout=3s \
--start-period=10s --start-interval=2s \
CMD ["bun", "healthcheck.ts"]const port = process.env.PORT ?? "3000";
const url = `http://127.0.0.1:${port}/health`;
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(2_000),
});
process.exit(res.ok ? 0 : 1);
} catch {
process.exit(1);
}Slim and distroless images lack curl, so probe with the runtime you already ship. Kubernetes
ignores HEALTHCHECK and uses its own probes.
OCI label (org.opencontainers.image.*) | Value |
|---|---|
source | repo URL; GHCR uses it to link the package to the repo |
revision | git commit SHA |
version | release version |
created | RFC 3339 build time |
title, description, licenses, authors | human-facing metadata |
ARG VERSION=dev REVISION=unknown SOURCE
LABEL org.opencontainers.image.source=$SOURCE \
org.opencontainers.image.revision=$REVISION \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.licenses=MITLabels live on the image config. For manifest or index annotations (shown by registries for
multi-platform images) use docker buildx build --annotation "index:org.opencontainers.image.…=…".
In GitHub Actions, docker/metadata-action generates tags, labels and annotations.
Size & reproducibility
| Technique | Effect |
|---|---|
| Multi-stage, copy only artifacts | no compilers, dev deps or source in the image |
| Slim / distroless base | tens of MB instead of hundreds |
| Production deps only | bun install --production, pnpm install --prod, uv sync --no-dev |
Clean up in the same RUN | deleted files don't hide in lower layers |
| Cache mounts instead of in-image caches | ~/.npm, ~/.cache never shipped |
| Single binary | bun build --compile, Go CGO_ENABLED=0, onto distroless or scratch |
Next.js output: "standalone" | traced node_modules subset |
| Inspect | docker history img, dive img |
| Reproducibility | How |
|---|---|
| Pin base images by digest | FROM node:24-slim@sha256:…; let Renovate or Dependabot bump it |
| Pin frontends and tools | # syntax=docker/dockerfile:1.2x, uv:0.12.19, exact package versions |
| Lockfiles, strictly | --frozen-lockfile, npm ci, uv sync --locked |
| Verify downloads | ADD --checksum=sha256:… https://… |
| Stable timestamps | --build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) |
| Provenance and SBOM | docker buildx build --provenance=mode=max --sbom=true |
docker buildx imagetools inspect node:24-slim \
--format '{{json .Manifest.Digest}}'Linting
| Tool | Command | Catches |
|---|---|---|
| Build checks | docker build --check . | JSONArgsRecommended, FromAsCasing, UndefinedVar, SecretsUsedInArgOrEnv, CopyIgnoredFile, … |
| Checks during a build | on by default; # check=error=true to fail | same rules, as warnings |
| hadolint | docker run --rm -i hadolint/hadolint < Dockerfile | shell issues via ShellCheck, unpinned apt packages, cd instead of WORKDIR |
| Scout | docker scout cves img | vulnerable packages in the result |
docker init | interactive | generates a starter Dockerfile, compose.yaml and .dockerignore |
ignored: [DL3008] # unpinned apt versions
trustedRegistries: [docker.io, ghcr.io, dhi.io]
failure-threshold: warningRecipes
Bun app
Multi-stage Bun service: a dev stage for Compose watch, production-only deps and a non-root runtime.
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS base
WORKDIR /app
FROM base AS deps
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
FROM deps AS dev
COPY . .
CMD ["bun", "--watch", "src/index.ts"]
FROM base AS prod-deps
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --production
FROM base AS release
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules node_modules
COPY package.json ./
COPY src ./src
USER bun
EXPOSE 3000
CMD ["bun", "src/index.ts"]Build with docker build -t api .; the last stage is the default. Handle SIGTERM in the app (see
Signals & PID 1) or run with --init.
Node app with pnpm
pnpm's official image for installs and the build, plain node:24 slim to run.
# syntax=docker/dockerfile:1
FROM ghcr.io/pnpm/pnpm:12 AS base
RUN pnpm runtime set node 24 -g
ENV CI=true
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod
FROM node:24-trixie-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]CI=true keeps pnpm non-interactive. In a monorepo, pnpm deploy --filter=api --prod /out produces a
self-contained folder to copy instead; see pnpm monorepos.
Next.js standalone
Set output: "standalone" in next.config.ts; install and build with Bun, run the traced server on Node.
# syntax=docker/dockerfile:1
FROM node:24-slim AS base
WORKDIR /app
FROM base AS build
COPY --from=oven/bun:1 /usr/local/bin/bun /usr/local/bin/
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
FROM base AS runner
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=node:node \
/app/.next/standalone ./
COPY --from=build --chown=node:node \
/app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]server.js doesn't serve public/ or .next/static unless they're copied next to it, as above.
HOSTNAME=0.0.0.0 makes it listen outside the container. NEXT_PUBLIC_* values are inlined at build
time, so pass them as build args. See Next.js.
Static site on nginx
Build a Vite/Astro-style dist/ with Bun and serve it from an unprivileged nginx on port 8080.
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM nginxinc/nginx-unprivileged:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080server {
listen 8080;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
location /assets/ {
add_header Cache-Control
"public, max-age=31536000, immutable";
}
}With Caddy instead: FROM caddy:2-alpine, copy to /srv, and
CMD ["caddy", "file-server", "--root", "/srv"].
Go static binary on distroless
Cross-compile natively for each target platform, then ship one static binary as a non-root user.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.27 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags='-s -w' \
-o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]For scratch, also copy /etc/ssl/certs/ca-certificates.crt from the build stage (and
/usr/share/zoneinfo if you use time zones), then USER 65532:65532. A bun build --compile
binary works the same way on gcr.io/distroless/cc-debian13.
Python app with uv
Dependencies in their own cached layer, the project installed non-editable, only the venv shipped.
# syntax=docker/dockerfile:1
FROM python:3.14-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.12.19 /uv /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0 UV_NO_DEV=1
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-editable
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable
FROM python:3.14-slim
RUN useradd --system --uid 10001 app
COPY --from=build /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]Both stages must use the same Python image, because the venv links to its interpreter.
References
- Docker Docs: Dockerfile reference (opens in a new tab), Build checks (opens in a new tab), Checks reference (opens in a new tab)
- Docker Docs: Build cache (opens in a new tab), Optimize cache usage (opens in a new tab), Multi-stage builds (opens in a new tab), Build context (opens in a new tab)
- Docker Docs: Build secrets (opens in a new tab), Multi-platform (opens in a new tab), Reproducible builds (opens in a new tab), Best practices (opens in a new tab)
- OCI image annotations (opens in a new tab)
- Official guides: Bun + Docker (opens in a new tab), pnpm + Docker (opens in a new tab), uv + Docker (opens in a new tab), Next.js
output(opens in a new tab) and with-docker example (opens in a new tab) - Base images: distroless (opens in a new tab), Docker Hardened Images (opens in a new tab), tini (opens in a new tab)
- Linters: hadolint (opens in a new tab), dive (opens in a new tab)