# Docker images for Node in 2026: multi-stage, distroless, and the 1.1 GB image that became 140 MB

The first Dockerfile in most Node projects is eleven lines long, starts from `node:20`, copies the repository, runs `npm install` and `npm run build`, and ships. It works. It is also 1.1 GB, contains a C compiler, Python, git, every dev dependency, the `.git` directory, and the source of the app next to its build output. That is what we inherited on a Next.js platform we run for a US client, and it was costing real minutes on every deploy and real money in ECR storage and App Runner pull time.

Here is the Dockerfile we run now, at 140 MB, with the reasoning for each stage, the two things that broke when we went distroless, and the honest note about what you give up.

## Where the 1.1 GB came from

| Layer | Size | Why it was there |
|---|---|---|
| `node:20` base (Debian) | 1,000 MB | Full Debian with build toolchain, Python, git |
| `node_modules` incl. dev deps | 420 MB | TypeScript, ESLint, Jest, Playwright, all shipped to prod |
| Source tree + `.git` | 80 MB | Copied before `.dockerignore` existed |
| Build output | 60 MB | The only part production needs |

Four of those five rows are waste in the running container. The base image alone is seven times the app. And each layer is re-pushed to ECR and re-pulled by every App Runner instance on scale-out, which was adding twenty to forty seconds to every cold start.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two stacked bars comparing image composition. Before: 1.1 GB made of Debian base 1000 MB, node_modules with dev dependencies 420 MB, source and git 80 MB, build output 60 MB. After: 140 MB made of distroless base 20 MB, production node_modules 60 MB, standalone build output 60 MB.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#f1f3ff" font-size="14" font-weight="700">What is inside the image</text>
<text x="20" y="66" fill="#9aa3c7">before · 1.1 GB</text>
<rect x="140" y="52" width="480" height="22" rx="4" fill="#ff6b8a" opacity="0.85"/><text x="380" y="67" text-anchor="middle" fill="#0d1120" font-weight="700">node:20 Debian base · 1,000 MB</text>
<rect x="620" y="52" width="200" height="22" rx="4" fill="#ffd166" opacity="0.85"/><text x="720" y="67" text-anchor="middle" fill="#0d1120" font-size="10">dev deps · 420 MB</text>
<rect x="820" y="52" width="38" height="22" rx="4" fill="#9aa3c7" opacity="0.85"/>
<rect x="858" y="52" width="28" height="22" rx="4" fill="#4fffb0"/>
<text x="20" y="126" fill="#9aa3c7">after · 140 MB</text>
<rect x="140" y="112" width="20" height="22" rx="4" fill="#7b8cff"/>
<rect x="160" y="112" width="58" height="22" rx="4" fill="#ffd166" opacity="0.85"/>
<rect x="218" y="112" width="58" height="22" rx="4" fill="#4fffb0"/>
<text x="290" y="127" fill="#f1f3ff">distroless 20 MB · prod deps 60 MB · standalone output 60 MB</text>
<text x="140" y="158" fill="#9aa3c7" font-size="11">scale: 1 px ≈ 1.5 MB · the app itself (green) is the same size in both</text>
<g font-size="11"><rect x="140" y="184" width="12" height="12" fill="#ff6b8a"/><text x="158" y="194" fill="#9aa3c7">base OS</text><rect x="240" y="184" width="12" height="12" fill="#ffd166"/><text x="258" y="194" fill="#9aa3c7">node_modules</text><rect x="370" y="184" width="12" height="12" fill="#9aa3c7"/><text x="388" y="194" fill="#9aa3c7">source + .git</text><rect x="490" y="184" width="12" height="12" fill="#4fffb0"/><text x="508" y="194" fill="#9aa3c7">build output</text><rect x="620" y="184" width="12" height="12" fill="#7b8cff"/><text x="638" y="194" fill="#9aa3c7">distroless runtime</text></g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Seven eighths of the old image never ran. It was pushed, stored and pulled anyway.</text>
</g>
</svg>
</div>

## The Dockerfile

```dockerfile
# syntax=docker/dockerfile:1.7

# ---- 1. deps: install with the lockfile, cache the store ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/*/package.json packages/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

# ---- 2. build: compile, produce the standalone output ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# prune to production deps only, for the packages the standalone output needs
RUN pnpm --filter=web deploy --prod /out

# ---- 3. runtime: nothing but node and the output ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["apps/web/server.js"]
```

And the `.dockerignore` that does more work than any single line of the Dockerfile:

```
.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md
```

### Stage 1, deps

Only the manifests and the lockfile are copied before `pnpm install`, so this layer's cache key is the lockfile. Change a source file and the install layer is reused. Change the lockfile and it reruns, which is correct. The `--mount=type=cache` keeps pnpm's content-addressable store between builds on the same builder, so even a lockfile change mostly hits the local store instead of the registry. On CodeBuild with the [registry layer cache](/en/blog/monorepo-three-apps-build-only-what-changed) this stage is restored in about ten seconds.

`node:22-bookworm-slim` rather than the full image: 200 MB instead of a gigabyte, still has a shell and package manager for the few native modules that need to compile. We do not need Python or gcc for this app; if you do, install them in this stage only.

### Stage 2, build

Copies the source on top of the deps stage and runs the build. Next.js `output: 'standalone'` in `next.config.js` is the key setting: it traces which files in `node_modules` the server actually imports and copies only those into `.next/standalone`, with a minimal `server.js`. That is what makes 420 MB of `node_modules` turn into 60 MB.

`pnpm deploy --prod` is belt and braces: for anything the standalone tracer misses (we had one dynamic `require` in a PDF library), it produces a clean production-only install of the app's dependencies.

### Stage 3, runtime

Distroless. No shell, no package manager, no `apt`, no `curl`, no `sh`. Just the Node binary, its runtime libraries, CA certificates, and the files we copy in. Runs as the `nonroot` user by default. The whole base is 20 MB.

This is the stage that matters for security scanners: the Debian base carried around 180 CVEs in packages the app never called, most of them in tools like `perl` and `git`. Distroless carries a handful, all in Node itself, which you fix by bumping the tag.

## The two things that broke

**1. The health check used `curl`.** The old task definition had a container health check of `curl -f http://localhost:3000/api/health`. There is no `curl` in distroless. There is no shell either, so `CMD-SHELL` fails too. Two fixes, and we used the second: either use the platform's HTTP health check instead of a container one (App Runner and ALB target groups both do HTTP natively, and [the route should be designed for it anyway](/en/blog/health-checks-that-lie)), or ship a tiny Node health script and run it with the Node binary:

```dockerfile
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]
```

**2. Debugging in the container stopped working.** No shell means no `docker exec -it app sh`. The first time someone needed to inspect a running container in staging, they could not. The answer that stuck: `docker debug` (Docker Desktop) or a sidecar with a shell that shares the process namespace, for the rare occasion. In practice, what people wanted from `exec` was to read a config or check an env var, and both are better answered by the [health route's deep mode](/en/blog/health-checks-that-lie) and the [structured logs](/en/blog/your-logs-should-not-know-which-cloud). We have not needed a shell in a production container in five months.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three-stage build flow. Stage deps on node:22 slim: copy lockfile and manifests, pnpm install with a cache mount, cache key is the lockfile. Stage build: copy source, next build with standalone output, pnpm deploy prod. Stage runtime on distroless nodejs22 nonroot: copy only standalone output, static and public; no shell, no package manager, 20 MB base. Arrows show only the build output crossing into runtime.">
<defs><marker id="arrD" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="150" y="54" text-anchor="middle" fill="#ffd166" font-weight="700">1 · deps · node:22-slim</text><text x="150" y="80" text-anchor="middle" fill="#f1f3ff">copy lockfile + manifests only</text><text x="150" y="100" text-anchor="middle" fill="#f1f3ff">pnpm install --frozen-lockfile</text><text x="150" y="120" text-anchor="middle" fill="#f1f3ff">--mount=type=cache for the store</text><text x="150" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">cache key: the lockfile</text><text x="150" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">restored in ~10 s from registry cache</text>
<line x1="282" y1="115" x2="318" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="320" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="54" text-anchor="middle" fill="#7b8cff" font-weight="700">2 · build</text><text x="450" y="80" text-anchor="middle" fill="#f1f3ff">copy source</text><text x="450" y="100" text-anchor="middle" fill="#f1f3ff">next build · output: standalone</text><text x="450" y="120" text-anchor="middle" fill="#f1f3ff">pnpm deploy --prod /out</text><text x="450" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">tracer keeps only imported modules</text><text x="450" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">420 MB → 60 MB of node_modules</text>
<line x1="582" y1="115" x2="618" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="620" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="750" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">3 · runtime · distroless</text><text x="750" y="80" text-anchor="middle" fill="#f1f3ff">COPY --from=build standalone</text><text x="750" y="100" text-anchor="middle" fill="#f1f3ff">COPY static + public</text><text x="750" y="120" text-anchor="middle" fill="#f1f3ff">CMD ["apps/web/server.js"]</text><text x="750" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">no shell · no apt · nonroot · 20 MB base</text><text x="750" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">~5 CVEs instead of ~180</text>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Only the last stage ships. The first two exist to be cached.</text>
</g>
</svg>
</div>

## The numbers

| | Before | After |
|---|---|---|
| Image size | 1.1 GB | 140 MB |
| ECR storage, 30 tags kept | 33 GB, $3.30/month | 4 GB, $0.40/month |
| Push time from CodeBuild | 70 s | 9 s |
| App Runner cold pull on scale-out | 25–40 s | 4–6 s |
| CVEs reported by the scanner | ~180 | 5 |
| Build time, warm cache, one page changed | 4 min | 1 min 40 s |

The cold-pull number is the one users felt. Scale-out under a traffic spike used to mean half a minute before the new instance could serve; it is now under ten seconds, which is the difference between the spike being absorbed and the spike producing a page of 503s.

## What you give up

- **No shell in production.** Covered above. It is a feature until the day you want it, and then you want a sidecar.
- **Native modules must be prebuilt.** Anything with a `node-gyp` step compiles in stage 1 against Debian's glibc, which distroless also uses, so it works. If you go further to an Alpine or musl base, it will not. We stayed on Debian-based distroless for this reason.
- **`sharp` and friends need their shared libraries.** The standalone tracer copies the `.node` binary but not `libvips`. `sharp` bundles its own since version 0.33; older versions need the libs copied from the build stage. Check the first request that touches images after switching.
- **The `nonroot` user cannot bind to ports below 1024.** Use 3000 and let the platform map it. If something insists on 80, that is the platform's job, not the container's.

## The short version

Slim base for building, distroless for running, `output: 'standalone'` so the tracer does the pruning, a `.dockerignore` that stops the source and `.git` from ever entering the build context, and a lockfile-keyed install layer that the registry cache restores. From 1.1 GB to 140 MB, from 40 seconds of cold pull to 5, from 180 CVEs to 5.

If your Node image is over 500 MB, [we can bring it under 200 in a day](/contact), and the day usually pays for itself in the first month of pull time.
