Post 00 established what a container is at runtime. This post covers the artifact it runs from: the image. An image is not a tarball of a machine — it is an ordered stack of filesystem layers plus two small JSON documents, and almost every practical skill (fast rebuilds, small images, no leaked secrets, reproducible deploys) follows from understanding that structure. The Dockerfile is the build script that produces it, and its instructions have exact semantics that are easy to use wrongly: ADD where COPY was meant, ENV where ARG was meant, shell-form CMD that silently breaks signal handling. This post goes through all of it, then builds working multi-stage Node and Python images and measures the size difference.

Series map: 00 Containers from First Principles → 01 Images and Dockerfiles (this post) → 02 Networking, Volumes and Compose → 03 Registries, Supply Chain and Security → 04 Kubernetes Architecture and Core Objects → 05 Config, Storage and Stateful Workloads → 06 Scheduling, Resources, Probes and Rollouts → 07 Ingress, Observability, RBAC and GitOps.


Learning Outcome

  • Describe an image as layers + manifest + config, and use every meaningful Dockerfile instruction with correct semantics — including the COPY/ADD, ENV/ARG, and CMD/ENTRYPOINT distinctions.
  • Predict exactly which layers a change invalidates, and order instructions so dependency installs stay cached.
  • Write a .dockerignore that keeps the build context small and the cache stable.
  • Build multi-stage images for a Node service and a Python service, running as a non-root user.
  • Choose between distroless, alpine, slim, and full base images with the musl/glibc trade-offs in mind.
  • Use BuildKit cache mounts, secret mounts, and buildx multi-platform builds.
  • Measure your own before/after image sizes and pin base images by digest for reproducibility.

Chapter 1 — What an image is made of

Terminology

  • Layer — a tar archive of filesystem changes (added, modified, and whiteout entries), stored as a content-addressed blob. Its diff_id is the SHA-256 of the uncompressed tar.
  • Digest — the SHA-256 of a stored blob or manifest as the registry holds it. What image@sha256:… refers to.
  • Manifest — a small JSON listing the config blob and the layer blobs for one platform.
  • Index / manifest list — a JSON listing several manifests, one per platform (Post 03).
  • Image config — JSON holding Env, Cmd, Entrypoint, WorkingDir, User, ExposedPorts, Labels, the rootfs.diff_ids, and a history array.
  • Build context — the set of files sent to the builder, from which COPY may read.

Anatomy

1
2
3
4
5
6
image (tag: myapp:1.4.0)
└── manifest ─────► config blob (JSON): Env, Cmd, Entrypoint, User, rootfs.diff_ids, history
└──► layer blob 0 (base OS userspace)
└──► layer blob 1 (dependencies)
└──► layer blob 2 (application code)

Look at a real one:

1
2
3
4
5
docker pull nginx:1.27-alpine
docker inspect nginx:1.27-alpine --format '{{json .Config}}' | python3 -m json.tool # the runtime contract
docker inspect nginx:1.27-alpine --format '{{json .RootFS.Layers}}' | tr ',' '\n' # the layer chain
docker history nginx:1.27-alpine # how each layer was made

Three facts worth internalising immediately:

  1. Layers are shared. Ten images built FROM python:3.12-slim store that base once. Pulls only fetch blobs the host lacks.
  2. Layers are additive. Deleting a file in layer 3 does not shrink layers 0–2; it writes a whiteout. RUN apt-get install … in one instruction and RUN rm -rf /var/lib/apt/lists/* in the next leaves the package lists inside the image.
  3. The config is the runtime contract. ENTRYPOINT, CMD, USER, ENV, and WORKDIR live there — not in the filesystem — which is why docker run --entrypoint can override them without touching a layer.

Chapter 2 — The Dockerfile instruction set

FROM

1
2
3
4
5
FROM python:3.12-slim              # tag: mutable, readable
FROM python:3.12-slim@sha256:... # tag + digest: reproducible (Post 03)
FROM node:22-alpine AS builder # names a stage for multi-stage builds
FROM scratch # the empty image: for static binaries

Every FROM starts a new stage and resets the filesystem; only ARGs declared before the first FROM survive across stage boundaries, and then only for use in FROM lines unless redeclared.

RUN

Runs a command in a new layer on top of the current image.

1
2
3
4
5
# shell form → /bin/sh -c "..."; pipes, &&, and variable expansion available
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

RUN ["/usr/local/bin/setup.sh", "--fast"] # exec form: no shell, no expansion

Chain related commands with && in one RUN so cleanup happens in the same layer as the thing it cleans up. A separate cleanup instruction removes nothing from the image.

COPY vs ADD

1
2
3
4
COPY package.json package-lock.json ./     # explicit, predictable
COPY --chown=10001:10001 src/ /app/src/ # set ownership as you copy
COPY --from=builder /app/dist /app/dist # copy from another stage

ADD does everything COPY does plus two magic behaviours: it auto-extracts local tar archives into the destination, and it can fetch remote URLs (and, with current BuildKit, Git repositories).

Situation Use
Copying files or directories from the context COPY
Copying from a previous build stage COPY --from=
Extracting a local .tar.gz you control ADD (this is its one clear win)
Downloading a remote file prefer RUN curl -fsSL … && checksum-verify, or ADD --checksum=sha256:…

Why ADD is usually wrong: the tar auto-extraction is implicit, so ADD release.tgz /opt/ behaves completely differently from ADD release.bin /opt/; a plain ADD <url> historically performed no integrity check and produced an un-cacheable layer that could not be validated. COPY has one behaviour and it is the one you expected. The Docker docs' own best-practices guide says the same: prefer COPY.

WORKDIR

WORKDIR /app creates the directory if missing and persists for later instructions and at run time. Always use it rather than RUN cd /app — a cd inside a RUN affects only that shell, which is a common source of "why is my file in /?".

ENV vs ARG

ARG ENV
Available during build yes yes
Present in the final image no yes
Set from the CLI --build-arg NAME=value docker run -e NAME=value (at run time)
Scope the current stage (plus pre-FROM globals in FROM lines) from the instruction to the end of the stage, and at run time
Visible in docker history yes yes
Safe for secrets no no
1
2
3
4
5
ARG NODE_VERSION=22            # declared before FROM: usable in FROM lines only
FROM node:${NODE_VERSION}-alpine
ARG BUILD_REV=dev # build-time only
ENV NODE_ENV=production # persists into the image and every container

If both are defined with the same name, ENV wins for the rest of the build. Neither is a secret store: docker history --no-trunc prints build args, and docker inspect prints env vars. Post 03 goes into the leak in detail; the fix is BuildKit secret mounts, in Chapter 6 below.

EXPOSE

EXPOSE 8080 publishes nothing. It is metadata: documentation for humans, a hint for tooling, and the list docker run -P uses when assigning random host ports. Actual publishing is -p 8080:8080 at run time (Post 02). Leaving EXPOSE out does not prevent anything from working.

CMD vs ENTRYPOINT

  • ENTRYPOINT — the executable. Not overridden by trailing docker run arguments.
  • CMD — default arguments (or the default command when there is no ENTRYPOINT). Replaced by trailing docker run arguments.

The canonical combination:

1
2
3
ENTRYPOINT ["/usr/local/bin/myapp"]
CMD ["--config", "/etc/myapp/prod.yaml"]
  • docker run myapp → myapp --config /etc/myapp/prod.yaml
  • docker run myapp --config /etc/myapp/dev.yaml → the CMD is replaced
  • docker run --entrypoint sh -it myapp → drops you into a shell

Exec form vs shell form

Exec form ["a","b"] Shell form a b
What runs as PID 1 your program /bin/sh
Variable expansion ($HOME) no yes
Pipes, &&, redirection no yes
Receives SIGTERM from docker stop yes usually not
Requires a shell in the image no (works on distroless/scratch) yes

The signal problem, precisely: with shell form, PID 1 is /bin/sh -c "myapp" and your app is a child. docker stop sends SIGTERM to PID 1. A minimal sh does not forward signals to children, so your app never learns it should shut down; after the grace period (10 s by default) the kernel SIGKILLs everything. In-flight requests are dropped, buffers are not flushed, and in Kubernetes this shows up as failed rolling updates (Post 06).

1
2
3
4
CMD ["node", "server.js"]        # correct: node is PID 1, gets SIGTERM
CMD node server.js # broken shutdowns
CMD ["sh", "-c", "exec node server.js"] # if you truly need shell expansion, `exec` replaces the shell

Common pitfall: applications that spawn children (a shell wrapper, a supervisor, a npm start chain) also need to reap zombies. Use docker run --init (or tini as the entrypoint) when your PID 1 is not a real init.

USER

1
2
3
RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app
USER 10001:10001 # numeric: Kubernetes `runAsNonRoot` can verify it

Applies to every subsequent RUN and to the container at run time. Prefer a numeric UID: a name resolves only if /etc/passwd in that image maps it, which distroless and scratch may not.

HEALTHCHECK

1
2
3
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1

Exit 0 = healthy, 1 = unhealthy; docker ps shows the state and Compose's condition: service_healthy uses it (Post 02). Kubernetes ignores HEALTHCHECK entirely — it has its own probes (Post 06). Keep the check cheap and local: one that queries a database turns a dependency outage into a self-inflicted restart loop.

LABEL

1
2
3
LABEL org.opencontainers.image.source="https://github.com/acme/myapp" \
org.opencontainers.image.revision="${BUILD_REV}"

Use the standard org.opencontainers.image.* keys — registries and scanners read them, and image.source links a package on GHCR back to its repository.

VOLUME

1
2
VOLUME /var/lib/postgresql/data

Declares that a path should be backed by a volume; without one mounted there, Docker creates an anonymous volume. Caveats: any RUN writing to that path after the VOLUME instruction is discarded, and every docker run leaves another anonymous volume behind. In application images, document the path instead and let Compose or Kubernetes mount it.

Chapter 3 — Build cache mechanics

How the cache decides

For each instruction the builder computes a cache key and looks for a matching existing layer:

  • RUN — the key is the command string (plus the parent layer). BuildKit does not know or care what the command actually did. RUN apt-get update will happily reuse a six-month-old package index.
  • COPY / ADD — the key includes a checksum of the copied file contents and metadata. Change one byte of one copied file and this layer and every layer after it rebuild.
  • Metadata instructions — the key is the instruction text.

A cache miss cascades. Once one instruction misses, everything below it rebuilds, regardless of whether it would have hit on its own.

The canonical ordering

Dependencies change rarely; source code changes constantly. So copy the dependency manifest first, install, and only then copy the source.

The Before and After Dockerfiles rebuilt after touching one source file: in Before, COPY . . misses and npm ci reruns; in After, the lockfile COPY and npm ci stay CACHED and only COPY . . misses.

Before — every code edit reinstalls every dependency:

1
2
3
4
5
6
FROM node:22-alpine
WORKDIR /app
COPY . . # ← any file change invalidates here
RUN npm ci
CMD ["node", "server.js"]

After — code edits reuse the install layer:

1
2
3
4
5
6
7
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # ← changes only when deps change
RUN npm ci
COPY . . # ← code changes land here, cheaply
CMD ["node", "server.js"]

Measure the difference on your own project:

1
2
3
4
5
docker build -t demo:before -f Dockerfile.before . && touch src/index.js
time docker build -t demo:before -f Dockerfile.before . # full reinstall
docker build -t demo:after -f Dockerfile.after . && touch src/index.js
time docker build -t demo:after -f Dockerfile.after . # install layer CACHED

Watch the build output: the reused steps print CACHED. On a dependency-heavy project the second rebuild typically drops from tens of seconds (or minutes) to a couple of seconds — the exact figures depend on your dependency tree, so record your own.

The same pattern elsewhere: copy requirements.txt before pip install -r; pyproject.toml + poetry.lock/uv.lock before poetry install --no-root or uv sync --frozen; go.mod + go.sum before go mod download; Cargo.toml + Cargo.lock before cargo fetch; pom.xml before mvn -B dependency:go-offline.

Trick: npm ci (not npm install) and pip install --require-hashes where possible. They fail loudly when the lockfile and the manifest disagree instead of silently resolving a different tree — which is what makes the cached layer trustworthy.


Chapter 4 — .dockerignore

The build context is everything the CLI hands to the builder. Without a .dockerignore, that includes .git, node_modules, build outputs, virtualenvs, and any stray database dump in the directory.

Three separate costs: transfer time (the context is packed and sent before instruction one runs), cache poisoning (COPY . . hashes everything in the context, so a .git/index that changes on every commit invalidates the copy layer and everything below it — the usual cause of builds that "randomly" stop hitting cache), and accidental inclusion (.env, *.pem, and full .git history shipped inside the image, readable by anyone who pulls it).

A workable starting point:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# .dockerignore
.git
node_modules
dist
build
__pycache__
*.pyc
.venv
.env
.env.*
*.log
coverage
.pytest_cache
Dockerfile*
compose*.yaml

Check the size of what you are actually sending:

1
2
3
du -sh .                                    # everything on disk
docker build -t ctx-test . 2>&1 | head -2 # BuildKit prints "transferring context: … B"

Watch out: .dockerignore is not .gitignore. Files that git ignores are still in the build context unless .dockerignore also lists them. Keeping the two in sync is worth a moment of attention every time you add a build output directory.


Chapter 5 — Multi-stage builds

One stage compiles; another, tiny, stage runs. Toolchains, headers, compilers, and source code never reach the shipped image.

Worked example — Node

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# syntax=docker/dockerfile:1
# ---------- build stage ----------
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # includes devDependencies: needed to build
COPY . .
RUN npm run build # produces /app/dist

# ---------- prune stage ----------
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # production dependencies only
# ---------- runtime stage ----------
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app
COPY --from=deps --chown=10001:10001 /app/node_modules ./node_modules
COPY --from=builder --chown=10001:10001 /app/dist ./dist
COPY --chown=10001:10001 package.json ./
USER 10001:10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1

CMD ["node", "dist/server.js"]

Worked example — Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# syntax=docker/dockerfile:1
# ---------- build stage ----------
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt


# ---------- runtime stage ----------
FROM python:3.12-slim AS runtime
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --uid 10001 --create-home --shell /usr/sbin/nologin app

WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app.main:app"]

The virtualenv carries installed packages across stages cleanly while keeping the runtime free of compilers: it installs only libpq5, not libpq-dev and gcc. And docker build --target builder -t myapp:builder . builds only up to a named stage — handy for debugging, and for a test stage (FROM builder AS test + RUN npm test) that fails the build when the suite fails.


Chapter 6 — Base images and BuildKit features

Choosing a base

Base Size class Package manager libc Good for
ubuntu / debian (full) large apt glibc build stages, debugging, exotic native deps
*-slim (e.g. python:3.12-slim) medium apt glibc most production apps — the sensible default
alpine small apk musl small tools, Go/Rust binaries, careful use elsewhere
gcr.io/distroless/* small none glibc (or static) production runtime with no shell and no package manager
scratch ~0 none none fully static binaries (Go/Rust with CGO_ENABLED=0)

The Alpine caveats

Alpine uses musl libc rather than glibc, which produces real differences:

  • Python wheels target manylinux (glibc), so on Alpine pip often compiles from source: slower builds, a toolchain dragged in, and frequently a larger image than python:3.12-slim. musllinux wheels exist but coverage is thinner.
  • DNS resolution differs: musl's resolver has diverged from glibc on search-domain handling and parallel A/AAAA queries, and TCP fallback for large responses only arrived in musl 1.2.4. In Kubernetes, where discovery leans on ndots and search domains (Post 07), this is the classic "works on Debian, flaky on Alpine" bug.
  • Native modules built against glibc will not run; anything with CGO_ENABLED=1 or prebuilt .so files must be rebuilt.

Alpine is excellent for statically linked Go and Rust binaries and for small utility images. For Python and for Node projects with native modules, -slim is usually the better default and distroless the better production endpoint.

Distroless

1
2
3
4
5
FROM gcr.io/distroless/nodejs22-debian12:nonroot
COPY --from=builder /app /app
WORKDIR /app
CMD ["dist/server.js"] # the image's entrypoint is node

No shell, no package manager, no busybox — which removes most of the tooling an attacker would use after a compromise, and also your ability to docker exec … sh. Debug with the :debug variants, kubectl debug ephemeral containers (Post 07), or a --target debug stage.

BuildKit

BuildKit is the default builder in current Docker Engine. Add the syntax directive at the top of the Dockerfile to guarantee a modern frontend:

1
2
# syntax=docker/dockerfile:1

Cache mounts

A persistent, build-only cache directory that is not part of any layer:

1
2
3
4
5
6
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt


RUN --mount=type=cache,target=/root/.npm \
npm ci

The same works for apt (target=/var/cache/apt,sharing=locked) — with an apt cache mount, do not also rm -rf /var/lib/apt/lists/*, since the mount is already outside the image.

Secret mounts

The right way to use a private registry token, an SSH key, or a licence file during a build without it ever entering a layer:

1
2
3
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci

1
2
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.0 .

The file exists only for the duration of that RUN. Compare with the broken approaches: ARG NPM_TOKEN lands in docker history; COPY .npmrc . && RUN npm ci && RUN rm .npmrc leaves the file in an earlier layer forever (Post 03 shows how to extract it).

Multi-platform builds

1
2
3
docker buildx create --name multi --use --bootstrap
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/myapp:1.4.0 --push .

One tag, backed by an OCI index with one manifest per architecture, so an Apple-silicon laptop and an x86 server pull the right variant from the same name. Non-native architectures build through QEMU emulation unless you attach a native builder node — emulated builds of compiled languages are markedly slower, so prefer native runners in CI. Post 03 covers the index format.


Chapter 7 — Size, measurement, and reproducibility

Measure, do not guess

1
2
3
docker image ls myapp                    # total size of each tag
docker history myapp:1.0 # size added by each instruction

Typical orders of magnitude for a small Node service — your figures will differ with dependency count and base version, which is exactly why you run the command:

Variant Typical size class What dominates
Full base + dev dependencies + COPY . . around 1 GB the full distro base and dev dependencies
-alpine/-slim base, production deps only a few hundred MB base + node_modules
Multi-stage runtime stage roughly 100–200 MB base runtime + production node_modules
Distroless or scratch (static Go/Rust) tens of MB or less just the binary

Where the savings actually come from, in order of impact: not shipping the build toolchain (multi-stage); not shipping dev dependencies, tests, and source when only a build artifact is needed; a smaller base image; cleaning package-manager caches within the same RUN; and never adding a large file in one layer and deleting it in another.

Trick: smaller images matter most for cold-start latency — node pulls in Kubernetes, autoscaling, and CI. If your image is 1 GB and your rollout pulls it onto 40 nodes, the size shows up directly as deployment time.

Reproducibility and digest pinning

A tag is a mutable pointer. python:3.12-slim today and next month are different images. For anything you must be able to rebuild identically:

1
2
3
4
docker pull python:3.12-slim
docker inspect --format '{{index .RepoDigests 0}}' python:3.12-slim
# python@sha256:1f2c…

FROM python:3.12-slim@sha256:1f2c... then pins the exact bytes, forever.

Pair digest pinning with an automated updater (Dependabot, Renovate) so the pin is refreshed deliberately rather than drifting silently, and add lockfiles for language dependencies. The rule Post 03 formalises: tags for humans, digests for machines.


Practice Exercises

Exercise 1 — Read an image

Pull nginx:1.27-alpine. Using only docker inspect and docker history, report its Entrypoint, Cmd, User, and exposed ports, count its layers, and explain why docker history shows more rows than there are layers.

Solution
1
2
3
4
docker inspect nginx:1.27-alpine --format '{{json .Config}}' | python3 -m json.tool   # Entrypoint, Cmd, User, ExposedPorts
docker inspect nginx:1.27-alpine --format '{{len .RootFS.Layers}}' # number of layers
docker history nginx:1.27-alpine # one row per instruction

Instructions that only change the config (CMD, ENV, EXPOSE, LABEL, …) get a row in history with size 0B but add no layer; only steps that change the filesystem (mainly RUN, COPY, ADD) add an entry to RootFS.Layers.

Exercise 2 — Fix the cache

Take the "before" Dockerfile from Chapter 3, rebuild after touching a source file, and record the time. Reorder it, rebuild the same way, and record again. Report both numbers and which steps printed CACHED.

Solution

Move COPY package.json package-lock.json ./ + RUN npm ci above COPY . ., then:

1
2
3
4
time docker build -t demo:after .          # first build: full
touch src/index.js && time docker build -t demo:after .
# the second build prints CACHED for COPY package*.json and RUN npm ci

Exercise 3 — Signal handling

Build two images from the same trivial Node/Python server, one with shell-form CMD and one with exec form. Start each, run docker stop, and time how long the stop takes. Explain the difference.

Solution
1
2
3
time docker stop shellform     # ≈ 10 s: SIGTERM never reaches the app, then SIGKILL
time docker stop execform # sub-second, clean shutdown; exit code 0 rather than 137

Fix: CMD ["node", "server.js"], or CMD ["sh","-c","exec node server.js"] if shell expansion is genuinely required.

Exercise 4 — Multi-stage conversion

Convert a single-stage image of your own to multi-stage and record docker images sizes before and after. Confirm the runtime image ships no compiler: docker run --rm <image> which gcc should fail.

Exercise 5 — Secret that does not leak

Build an image that needs a token during npm ci (or pip install from a private index). Do it once with ARG and once with a BuildKit secret mount. Then try to recover the token from each image.

Solution
1
2
3
4
5
6
docker build --build-arg TOKEN=hunter2 -t leaky .
docker history --no-trunc leaky | grep -i hunter2 # found

docker build --secret id=token,src=./token.txt -t safe .
docker history --no-trunc safe | grep -i hunter2 # nothing

Exercise 6 — Non-root and pinned

Rewrite one of your images to run as UID 10001 with COPY --chown, verify with docker run --rm <image> id, then pin its base by digest and confirm that docker build --pull no longer changes the base layer.


Cheat-sheet

What How
Build with a tag / one stage docker build -t app:1.0 . / --target builder
Verbose log, no cache, fresh base --progress=plain, --no-cache, --pull
Layer history and sizes docker history --no-trunc app:1.0
Image config (Cmd, Env, User) docker inspect app:1.0 --format '{{json .Config}}'
COPY vs ADD COPY always, except local tar extraction
ARG vs ENV ARG = build only (and visible in history); ENV = persists to run time
EXPOSE / ENTRYPOINT + CMD documentation only / executable + default args (trailing run args replace CMD)
Exec form CMD ["node","server.js"] — required for correct SIGTERM
PID 1 reaping docker run --init, or tini as entrypoint
Non-root USER 10001:10001, COPY --chown=10001:10001
Cache ordering / context size copy lockfile → install deps → copy source; keep a .dockerignore
BuildKit cache mount RUN --mount=type=cache,target=/root/.cache/pip …
BuildKit secret mount RUN --mount=type=secret,id=x,target=/path … + --secret id=x,src=…
Multi-platform docker buildx build --platform linux/amd64,linux/arm64 --push .
Reproducible base FROM image@sha256:…
Multi-stage copy COPY --from=builder /app/dist ./dist

What's next

Post 02 takes these images and connects them to the world: Networking, Volumes and Compose — bridge vs host vs none, why only a user-defined network gives DNS by container name, port publishing and the host.docker.internal caveat, named volumes vs bind mounts vs tmpfs with their permission traps, and a complete Compose stack (web app + PostgreSQL + Redis) with health-gated startup that you can run end to end.


References

Note: this post is in progress. Planned additions: a Go/scratch static-binary example; docker buildx bake for multi-image builds; a CI recipe for registry-backed cache export/import.