Dockerfiles for Node.js: multi-stage, layer cache and running non-root Basic
Estimated reading time: 11 min read
The problem
Section titled “The problem”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.envfile from the dev machine. - The npm token passed via
--build-argis readable withdocker 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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| 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.
Core concepts
Section titled “Core concepts”| 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…
How it works
Section titled “How it works”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.
.dockerignore excludes node_modules, .git, and .env before the context is sent. Local secrets cannot end up in any layer.
- The context has already filtered out
.env, soCOPYcannot bring it in. - Build has devDependencies to run
tsc; deps has only 65/77 packages. - Runtime receives two directories via
COPY --from; notypescript, no TS source. - Edit
src:npm ciis a cache hit. Editpackage.json: misses fromnpm cionward, 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 buildWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ciCOPY tsconfig.build.json ./COPY src ./srcRUN npm run build
FROM ${NODE_IMAGE} AS depsWORKDIR /appCOPY 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 runtimeENV NODE_ENV=productionWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY package.json ./RUN mkdir -p /app/tmp && chown node:node /app/tmpUSER nodeEXPOSE 3000HEALTHCHECK --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.
Operational details
Section titled “Operational details”npm ciinstalls exactly perpackage-lock.jsonand fails if the lock is out of sync withpackage.json.NODE_ENVand--omit: per the npm docs, whenNODE_ENV=productionthe defaultomitisdev. So only setENV NODE_ENV=productionin the runtime stage; set it in the build stage andtscdisappears.- File permissions: per the Dockerfile reference,
COPYwithout--chowncreates files with UID/GID 0. Thenodeuser (uid 1000, built into thenodeimage) can read but not modify the code, which is usually what you want. UseCOPY --chown=node:nodeorchownonly 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 thatRUN; it supportstarget=orenv=. - A
<Dockerfile name>.dockerignorefile 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 withnode. 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.
Real-world example
Section titled “Real-world example”Size after building (docker image ls; the containerd image store shows two columns):
IMAGE DISK USAGE CONTENT SIZEdemo-dms:naive 1.68GB 413MBdemo-dms:secure 354MB 81.6MBdemo-dms:slim 354MB 81.6MBCONTENT 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)distnode_modulespackage.jsontmptouch: cannot touch '/app/dist/x': Permission deniedtmp-okA 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_SECRET0rm .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.
Trade-offs
Section titled “Trade-offs”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.
Production checklist
Section titled “Production checklist”- Commit
package-lock.jsonand usenpm ci;COPY package*.jsonbeforeCOPY src. - Create a
.dockerignorethat excludesnode_modules,dist,.git,.env*. - Split build and runtime stages; runtime only receives
dist/and productionnode_modules. - Set
ENV NODE_ENV=productionin the runtime stage, andUSER nodebeforeCMD. - Run
docker run --rm <image> id -uin CI and fail if the result is0. - Build-time secrets go only through
--mount=type=secret; scandocker historyto confirm no token remains. - Pin the base image by tag + digest, updated regularly by a bot.
- Build once and promote the same digest across environments (build once, deploy many).
Common mistakes
Section titled “Common mistakes”The most common mistake is in layer order:
COPY everything before installing packages
Common
COPY . .RUN npm install && npm run buildPrefer
COPY package.json package-lock.json ./RUN npm ciCOPY src ./srcRUN npm run buildWhy
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 |
Self-check
Section titled “Self-check”- Edit
tsconfig.build.jsoninsecure.Dockerfile: which layers rebuild, and does thenpm cilayer miss? - Why is
rm .npmrcin the sameRUNnot enough to hide a token passed viaARG? - 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?
Summary
Section titled “Summary”References
Section titled “References”- Dockerfile reference — Docker:
COPY --chown,RUN --mount=type=cache,HEALTHCHECKdefaults. - Build secrets — Docker: why
ARG/ENVare not for secrets, secret mount syntax. - Build context — Docker:
.dockerignoreand per-Dockerfile ignore files. - Official image node — Docker Hub: differences between the default, slim and alpine (musl) variants.
- Docker and Node.js Best Practices — nodejs/docker-node: the
nodeuser with uid 1000,NODE_ENV,CMD ["node", ...]. - npm config: omit: how
NODE_ENV=productionrelates to dropping devDependencies. - distroless — GoogleContainerTools: shell-less images, the
:debugand:nonroottags.