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 --mount=type=cache,target=/root/.npm 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 --mount=type=cache,target=/root/.npm npm ci --omit=dev 17 18# Runtime — distroless, non-root, only the compiled output 19FROM $RUN_IMAGE 20WORKDIR /app 21COPY --from=deps /app/node_modules ./node_modules 22COPY --from=build /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/node is 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-maps and run with node --enable-source-maps build/index.js if 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.