Dockerfiles for Node and TypeScript: Slim Containers with Distroless
The full node:22 image is well over a gigabyte uncompressed and ships with the entire Debian userspace — bash, apt, perl, a dozen utilities your app will never touch. Most of that is attack surface, not code. A multi-stage build with a Distroless runtime image cuts both: smaller, faster to pull, fewer CVEs to track.
Here's the full Dockerfile, then a walk through each choice.
1ARG BUILD_IMAGE=node:22-bookworm-slim
2ARG RUN_IMAGE=gcr.io/distroless/nodejs22-debian12:nonroot
3
4# Build stage — full toolchain, compiles TypeScript
5FROM $BUILD_IMAGE AS build
6WORKDIR /app
7COPY package.json package-lock.json ./
8RUN npm ci
9COPY . .
10RUN npm run build
11
12# Deps stage — same image, runtime dependencies only
13FROM $BUILD_IMAGE AS deps
14WORKDIR /app
15COPY package.json package-lock.json ./
16RUN npm ci --omit=dev
17
18# Runtime — distroless, non-root, only the compiled output
19FROM $RUN_IMAGE
20WORKDIR /app
21COPY /app/node_modules ./node_modules
22COPY /app/build ./build
23COPY package.json ./
24
25ENV NODE_ENV=production
26EXPOSE 8080
27CMD ["build/index.js"]Why each piece
Three stages, not two. The build stage installs everything — including dev deps and the TypeScript compiler. The deps stage gets a clean npm ci --omit=dev. The runtime image only copies node_modules from deps and build/ from build. devDependencies never get the chance to leak into the final image.
Lockfile copy before code copy. Lockfile changes invalidate the npm install layer; code changes don't. Copying package.json and package-lock.json first means a code-only edit doesn't reinstall the world. Pair it with a .dockerignore (node_modules, .git, build/) so the later COPY . . doesn't drag the host's node_modules into the build.
RUN --mount=type=cache,target=/root/.npm. BuildKit cache mount. The npm cache directory lives outside the image but persists across builds, so npm ci skips re-downloading packages on a second pass.
Distroless :nonroot. The :nonroot tag runs as UID 65532 by default — no explicit USER directive needed. Distroless images contain only the Node runtime: no shell, no package manager, no apt. If anyone gets RCE, there isn't even a sh to pivot from.
CMD ["build/index.js"]. Distroless Node images set node as the entrypoint, so CMD is just the script path. ["build"] works only if package.json's main points there and fails silently otherwise — be explicit.
In a 2026 stack
A few things worth knowing now:
- Chainguard's
cgr.dev/chainguard/nodeis the other distroless game in town — same minimalism, daily-rebuilt against fresh CVE data, signed with sigstore. Worth comparing if you care about supply-chain provenance. - Bun and Deno ship their own minimal containers (
oven/bun:slim,denoland/deno:distroless) and skip the multi-stage dance entirely for projects already using them. - Image scanning and SBOMs (
docker scout, Trivy, Grype,docker sbom) are table stakes now. Wire them into CI before you push to a registry. - Source maps: build with
--source-mapsand run withnode --enable-source-maps build/index.jsif you want readable stack traces — distroless passes Node flags through fine.
The exact base image matters less than the discipline: multi-stage, copy only what runs, ship as a non-root user.