../

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

InstructionDoesNotes
FROM img[:tag][@digest] [AS name]start a stage--platform=$BUILDPLATFORM for cross-builds
RUN cmdexecute at build time, commit a layer--mount, --network=none, heredocs
COPY src… destfiles from the context or --from=stage--chown, --chmod, --link, --exclude, --parents
ADD src… destlike COPY, plus URLs, git repos, auto-extracting local tars--checksum=sha256:…; prefer COPY otherwise
WORKDIR /appset (and create) the cwduse absolute paths
ENV K=venv var for later steps and runtimeinherited by child images
ARG K[=default]build-time variableper stage; not in the final env
EXPOSE 3000[/udp]document a portpublishes nothing
USER app[:group]user for later RUN, CMD, ENTRYPOINTname or UID
ENTRYPOINT ["bin"]the executableoverride with --entrypoint
CMD ["arg"]default command or default args to ENTRYPOINTreplaced by docker run img … args
HEALTHCHECK CMD …container health probeHEALTHCHECK NONE disables an inherited one
LABEL k=vimage metadataOCI keys below
VOLUME /datadeclare a mount pointanonymous volume per container; avoid in app images
SHELL ["bash", "-o", "pipefail", "-c"]shell for shell-form commandsdefault ["/bin/sh", "-c"]
STOPSIGNAL SIGQUITsignal sent by docker stopdefault SIGTERM
ONBUILD COPY . /apptrigger run when this image is used as a basefor builder base images only; surprising elsewhere
MAINTAINERdeprecateduse LABEL org.opencontainers.image.authors

Exec form vs shell form

FormExampleRuns asUse
Exec (JSON)CMD ["bun", "server.ts"]the process itself is PID 1, gets signalsCMD, ENTRYPOINT
ShellCMD bun server.ts/bin/sh -c "…": sh is PID 1 and swallows SIGTERMRUN, 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.

ENTRYPOINTCMDdocker run img runsdocker run img x runs
none["bun", "a.ts"]bun a.tsx
["bun"]["a.ts"]bun a.tsbun x
["tini", "--", "bun"]["a.ts"]tini -- bun a.tstini -- bun x
bun a.ts (shell)anythingsh -c "bun a.ts", CMD ignoredsame

Syntax directive & BuildKit

# syntax=docker/dockerfile:1
# check=error=true;skip=JSONArgsRecommended
# escape=\
FROM oven/bun:1
DirectiveEffect
# syntax=docker/dockerfile:1pull the latest stable 1.x frontend at build time, so new features work on older daemons
# syntax=docker/dockerfile:1.2xpin a minor version for reproducible parsing
# check=error=trueturn build-check warnings into failures
# check=skip=RuleA,RuleBsilence specific checks
# escape= backtickchange 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.

build context: docker build api/
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
.dockerignore
**/node_modules
**/dist
**/.next
.git
.env*
!.env.example
*.log
coverage
Dockerfile*
compose*.yaml
RuleMeaning
node_modulesonly at the context root (unlike .gitignore)
**/node_modulesat any depth
!patternre-include; later lines win
*.md then !README.mdexclude 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

RuleConsequence
Each step's cache key = parent layer + instruction textedit a line and that step reruns
COPY / ADD also hash file contents and metadatatouching an unrelated file in a copied dir busts it
RUN keys on the command string onlyapt-get update or curl …/latest stays stale until the line changes
A miss invalidates every later step in that stageput rarely-changing steps first
Changing an ARG value busts steps that use itdeclare ARGs as late as possible
--mount=type=cache survives missesre-installs reuse downloaded packages
COPY --link makes a layer independent of what's belowbase 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-lockfile

Order: 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
PatternHow
Build tools stay behindcopy only artifacts into the last stage
Pick a stagedocker build --target test .
Unused stagesskipped entirely by BuildKit
Parallelismindependent stages build concurrently
Copy from any imageCOPY --from=ghcr.io/astral-sh/uv:0.12.19 /uv /bin/
Named contexts--build-context shared=../shared, then COPY --from=shared
Base stageFROM node:24-slim AS base, then FROM base AS deps, …
Last stage is the defaultput the release stage last

RUN mounts

MountSyntaxUse
cache--mount=type=cache,target=/root/.npmpersistent package-manager cache, never in the image
secret--mount=type=secret,id=npmrc,target=/root/.npmrctokens for one step
secret as env--mount=type=secret,id=token,env=GH_TOKENsame, exposed as an env var
ssh--mount=type=sshprivate git clones through the forwarded agent
bind--mount=type=bind,source=go.mod,target=go.modread files without a COPY layer
tmpfs--mount=type=tmpfs,target=/tmpscratch space

Cache options: id (share between stages or Dockerfiles), sharing=shared\|private\|locked (locked for apt), uid/gid/mode for non-root steps.

ToolCache 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 /src
docker 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 curl

Without 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"
EOF

A heredoc RUN is one script: without set -e, only the last command's exit code counts.

ARG vs ENV

ARGENV
Available inlater instructions of the same stagelater instructions and the running container
Set by--build-arg K=vthe Dockerfile; overridable with docker run -e
Survives into child imagesnoyes
Scopebefore first FROM: only FROM lines; redeclare ARG K inside a stagethe stage and stages built FROM it
Visible in docker historyyes, when used by a RUNyes
Secretsnever (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=$VERSION

Base images

BaseApprox. sizelibcTrade-off
debian:trixie-slim, *-slim~30 MBglibcsafe default; apt, shell, prebuilt binaries just work
alpine, *-alpine~5 MBmuslsmall; see caveats
gcr.io/distroless/*-debian13~2–60 MBglibcno shell or package manager; :nonroot and :debug tags
Docker Hardened Images (dhi.io/…)smallglibc or muslminimal, near-zero CVEs, free; -dev variants for build stages
scratch0nonestatic binaries only; bring CA certs, tzdata, /etc/passwd
Full (node:24, python:3.14)300 MB+glibcbuild stages only

musl caveats on Alpine:

  • Native Node addons and Python wheels need musl builds; many fall back to compiling (slow) or fail. bun has a separate oven/bun:1-alpine build.
  • DNS resolution differs (no parallel A/AAAA handling like glibc, search/ndots quirks), which shows up in Kubernetes.
  • The default allocator is slower for allocation-heavy workloads.
  • Tools copied from glibc images won't run.

Users & permissions

BaseBuilt-in non-root user
node:*node (UID 1000)
oven/bun:*bun (UID 1000)
distroless :nonrootnonroot (UID 65532)
nginxinc/nginx-unprivilegednginx (UID 101), listens on 8080
Debian / Ubuntuuseradd --system --uid 10001 app
Alpineadduser -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:10001

Use 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

ProblemWhyFix
docker stop waits 10 s then kills (exit 137)shell-form CMD makes sh PID 1 and it doesn't forward SIGTERMexec form
App ignores SIGTERM even in exec formPID 1 gets no default signal handlers from the kernelhandle the signal, or run an init
Zombie processes pile upPID 1 must reap orphaned children--init, Compose init: true, or tini
npm start / pnpm start as CMDthe package manager sits between Docker and the apprun node dist/server.js directly
Entrypoint scriptthe script stays PID 1end it with exec "$@"
App expects SIGINT / SIGQUITwrong default signalSTOPSIGNAL
# Debian: apt-get install -y tini
ENTRYPOINT ["tini", "--"]
CMD ["bun", "src/index.ts"]
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 optionDefault
--interval30s
--timeout30s
--start-period0s (failures don't count during it)
--start-interval5s (probe rate during the start period)
--retries3
COPY healthcheck.ts ./
HEALTHCHECK --interval=30s --timeout=3s \
  --start-period=10s --start-interval=2s \
  CMD ["bun", "healthcheck.ts"]
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
sourcerepo URL; GHCR uses it to link the package to the repo
revisiongit commit SHA
versionrelease version
createdRFC 3339 build time
title, description, licenses, authorshuman-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=MIT

Labels 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

TechniqueEffect
Multi-stage, copy only artifactsno compilers, dev deps or source in the image
Slim / distroless basetens of MB instead of hundreds
Production deps onlybun install --production, pnpm install --prod, uv sync --no-dev
Clean up in the same RUNdeleted files don't hide in lower layers
Cache mounts instead of in-image caches~/.npm, ~/.cache never shipped
Single binarybun build --compile, Go CGO_ENABLED=0, onto distroless or scratch
Next.js output: "standalone"traced node_modules subset
Inspectdocker history img, dive img
ReproducibilityHow
Pin base images by digestFROM 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 downloadsADD --checksum=sha256:… https://…
Stable timestamps--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
Provenance and SBOMdocker buildx build --provenance=mode=max --sbom=true
docker buildx imagetools inspect node:24-slim \
  --format '{{json .Manifest.Digest}}'

Linting

ToolCommandCatches
Build checksdocker build --check .JSONArgsRecommended, FromAsCasing, UndefinedVar, SecretsUsedInArgOrEnv, CopyIgnoredFile, …
Checks during a buildon by default; # check=error=true to failsame rules, as warnings
hadolintdocker run --rm -i hadolint/hadolint < Dockerfileshell issues via ShellCheck, unpinned apt packages, cd instead of WORKDIR
Scoutdocker scout cves imgvulnerable packages in the result
docker initinteractivegenerates a starter Dockerfile, compose.yaml and .dockerignore
.hadolint.yaml
ignored: [DL3008]  # unpinned apt versions
trustedRegistries: [docker.io, ghcr.io, dhi.io]
failure-threshold: warning

Recipes

Bun app

Multi-stage Bun service: a dev stage for Compose watch, production-only deps and a non-root runtime.

Dockerfile
# 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.

Dockerfile
# 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.

Dockerfile
# 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.

Dockerfile
# 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 8080
nginx.conf
server {
  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.

Dockerfile
# 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.

Dockerfile
# 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