Skip to content
Knowledge Hub

Dockerfiles for Node.js: multi-stage, layer cache and running non-root Basic

Estimated reading time: 11 min read

The order-api service (TypeScript) uses a single-stage Dockerfile: FROM node, COPY . ., npm install, tsc. The image weighs 1.2 GB. Change one line in src/ and the CI rebuild takes 4 minutes because the package install step runs from scratch. On top of that:

  • The container runs as root (uid 0).
  • The image contains typescript, @types/*, all the TS source and even the .env file from the dev machine.
  • The npm token passed via --build-arg is readable with docker history.

This article answers: how should a Dockerfile be arranged so the image is small, rebuilds fast, does not run as root and carries no secrets?

Situation Choose Condition
Default base image for a Node service node:<ver>-bookworm-slim glibc like full Debian, few packages; lacks curl and compilers
Smallest image, pure-JS dependencies node:<ver>-alpine Uses musl instead of glibc; native modules need a musl build or must be compiled
Smallest attack surface distroless (gcr.io/distroless/nodejs24-debian13) No shell, no npm; debugging needs the :debug tag or a sidecar container
TS code that needs compiling Multi-stage: build → runtime Runtime only receives dist/ and production node_modules
Slow rebuilds when editing code COPY package*.json → npm ci → COPY src The lockfile must be committed
Slow package installs whenever dependencies change RUN --mount=type=cache,target=/root/.npm Needs BuildKit (default since Docker Engine 23)
Need a token at build time RUN --mount=type=secret Do not use ARG/ENV for secrets
Running in production USER node, NODE_ENV=production, base pinned by digest Directories that need writing must be chowned individually

This article uses Docker 29.5.3 (buildx 0.34.1, BuildKit) and node:24.22.1.

Term Meaning
Layer Each RUN, COPY, ADD instruction creates a filesystem layer. An image is a stack of layers.
Layer cache Docker reuses a layer if the instruction and its inputs (the files being COPYed) are unchanged. When one layer misses, every layer after it is rebuilt.
Stage A block starting with FROM. Only the last stage becomes the image; other stages only contribute files via COPY --from.
Build context The directory sent to the builder. .dockerignore decides which files are excluded.

Image size is decided by the last stage; rebuild speed is decided by the first layer that misses. Choose the base image by dependencies and debugging needs:

Loading diagram…

Multi-stage separates building from running. The diagram below follows order-api through three stages, then shows how the cache reacts when you edit src and when you edit package.json.

The build and deps stages are discarded; the runtime image only receives dist and production node_modules
The build and deps stages are discarded; the runtime image only receives dist and production node_modulesStep 1/6: .dockerignore excludes node_modules, .git, and .env before the context is sent. Local secrets cannot end up in any layer.Temporary stages(discarded afterbuild)Build contextfiltered by .dockerignoreno .envBuild stagenpm ci + tscDeps stagenpm ci --omit=devdist/compiled JSnode_modulesproduction onlyRuntime imagebookworm-slim, USER node
Build context
Step 1/6

.dockerignore excludes node_modules, .git, and .env before the context is sent. Local secrets cannot end up in any layer.

  1. The context has already filtered out .env, so COPY cannot bring it in.
  2. Build has devDependencies to run tsc; deps has only 65/77 packages.
  3. Runtime receives two directories via COPY --from; no typescript, no TS source.
  4. Edit src: npm ci is a cache hit. Edit package.json: misses from npm ci onward, and the cache mount keeps the downloaded tarballs.

The full Dockerfile (secure.Dockerfile in the demo):

ARG NODE_IMAGE=node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY tsconfig.build.json ./
COPY src ./src
RUN npm run build
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
FROM ${NODE_IMAGE} AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/tmp && chown node:node /app/tmp
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/main.js"]

CMD uses exec form so Node receives SIGTERM directly; the detailed reasons are in SIGTERM during deploys.

  • npm ci installs exactly per package-lock.json and fails if the lock is out of sync with package.json.
  • NODE_ENV and --omit: per the npm docs, when NODE_ENV=production the default omit is dev. So only set ENV NODE_ENV=production in the runtime stage; set it in the build stage and tsc disappears.
  • File permissions: per the Dockerfile reference, COPY without --chown creates files with UID/GID 0. The node user (uid 1000, built into the node image) can read but not modify the code, which is usually what you want. Use COPY --chown=node:node or chown only for app directories that genuinely need writing.
  • The cache mount lives in the builder, not in the image; ephemeral CI runners need a separate cache backend.
  • A secret mount defaults to /run/secrets/<id> and exists only during that RUN; it supports target= or env=.
  • A <Dockerfile name>.dockerignore file next to the Dockerfile takes precedence over the root .dockerignore.
  • HEALTHCHECK: defaults are interval 30s, timeout 30s, retries 3; exit 0 means healthy, 1 means unhealthy. Slim images have no curl, so call it with node. On Kubernetes, container status is determined by probes.
  • Pin the base image: tags can be re-pushed, digests are immutable. Record both: the tag for readers, the digest for reproducible builds.

Size after building (docker image ls; the containerd image store shows two columns):

IMAGE DISK USAGE CONTENT SIZE
demo-dms:naive 1.68GB 413MB
demo-dms:secure 354MB 81.6MB
demo-dms:slim 354MB 81.6MB

CONTENT SIZE is the compressed size when pulling. The difference comes from the slim base and dropping devDependencies (node_modules 31 MB → 4.3 MB); the cache mount and USER do not change size.

Rebuild time after adding one comment line to src/main.ts (three runs, with cache):

Dockerfile Run 1 Run 2 Run 3 Package install step
naive (COPY . . before npm install) 3.1s 5.4s 3.1s Reruns (npm install + build 1.9s)
slim (correct layer order) 2.0s 1.0s 0.9s CACHED

The app has only 77 packages, so the difference is measured in seconds; the more dependencies, the larger the share of time the reinstall takes. Measurements vary by network and machine.

Cache mount: after editing package.json, npm ci in both slim and secure took 0.8–1s, so there was no measurable difference with few packages and a fast registry.

Checking the user, contents and write permissions:

$ docker run --rm demo-dms:naive sh -c 'id; cat /app/.env'
uid=0(root) gid=0(root) groups=0(root)
PAYMENT_API_SECRET_KEY=sk_test_demo_only
$ docker run --rm demo-dms:secure sh -c 'id; ls -A /app; touch /app/dist/x; touch /app/tmp/x && echo tmp-ok'
uid=1000(node) gid=1000(node) groups=1000(node)
dist
node_modules
package.json
tmp
touch: cannot touch '/app/dist/x': Permission denied
tmp-ok

A token passed via ARG sits verbatim in the metadata:

$ docker history --no-trunc --format '{{.CreatedBy}}' demo-dms:naive
...
RUN |1 NPM_TOKEN=npm_SECRET_demo_123 /bin/sh -c echo "//npm.example.internal/:_authToken=${NPM_TOKEN}" > .npmrc ...
ARG NPM_TOKEN=npm_SECRET_demo_123
$ docker history --no-trunc demo-dms:secure | grep -c npm_SECRET
0

rm .npmrc deletes the file but does not remove the ARG value from history. The secure container reaches (healthy) after 5 seconds.

Build, run and cleanup commands: see the *.Dockerfile files in examples/src/docker-multi-stage/; create a fake .env before building the naive version.

Benefits

  • A many-times-smaller image, so new Pods pull and start faster.
  • Rebuilds after code edits skip the package install step.
  • Non-root limits what an RCE vulnerability can do inside the container.

Limits

  • Slim/distroless lack debugging tools (curl, ps, and a shell for distroless).
  • Pinning digests needs a process to update the base image regularly to receive patches.
  1. Commit package-lock.json and use npm ci; COPY package*.json before COPY src.
  2. Create a .dockerignore that excludes node_modules, dist, .git, .env*.
  3. Split build and runtime stages; runtime only receives dist/ and production node_modules.
  4. Set ENV NODE_ENV=production in the runtime stage, and USER node before CMD.
  5. Run docker run --rm <image> id -u in CI and fail if the result is 0.
  6. Build-time secrets go only through --mount=type=secret; scan docker history to confirm no token remains.
  7. Pin the base image by tag + digest, updated regularly by a bot.
  8. Build once and promote the same digest across environments (build once, deploy many).

The most common mistake is in layer order:

COPY everything before installing packages

Common

COPY . .
RUN npm install && npm run build

Prefer

COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
RUN npm run build

Why

Changing any file misses COPY . . and reinstalls every package. Copy the manifests first so code edits only touch the build layer.

Secrets are the second mistake, and hard to spot because the image still runs normally:

Misconception Consequence What to do
ENV NODE_ENV=production at the top of the Dockerfile npm ci drops devDeps, tsc does not exist in the build stage Set it in the runtime stage
USER node is enough chown -R of the whole app to node, so the app can modify its own code Code owned by root, chown only directories that need writing
.env is in .gitignore, so it is safe .dockerignore is what decides the context; COPY . . still brings .env in Add .env* to .dockerignore
  1. Edit tsconfig.build.json in secure.Dockerfile: which layers rebuild, and does the npm ci layer miss?
  2. Why is rm .npmrc in the same RUN not enough to hide a token passed via ARG?
  3. The container runs as uid 1000 but the app needs to write temporary upload files. Where should you change permissions, and why not chown -R /app?