You will build a small TypeScript API into a multi-stage Docker image with a distroless runtime that runs as a non-root user, then compare its size with a single-stage baseline. The guide is for developers who ship Node.js services in containers.
Prerequisites#
- Docker Engine 29 (29.9.2 is current) or a current Docker Desktop. BuildKit is the default builder for both.
- Node.js 24 and npm on your machine, to create the lockfile.
curlfor testing the API.
Check that the Buildx plugin is installed:
docker buildx versionWhy images get big#
Most oversized images come from a few habits:
- The base image is a full OS.
node:24includes compilers, headers, and many Debian packages your app never calls. - Build tools ship to production. TypeScript, type definitions, and test runners end up next to your compiled code.
- The build context holds more than the app needs.
COPY . .pulls innode_modulesfrom your laptop,.git, logs, and sometimes.envfiles. - Deleting a file does not shrink the image. A file created in one
RUNstep still takes space, even if a later step deletes it.
A .dockerignore file trims the build context. Multi-stage builds deal with the other three. Each FROM starts a new stage, and only the files you copy into the last stage end up in the image.
1. Create the sample app#
Scaffold the project#
mkdir tiny-api && cd tiny-api
npm init -y
npm install express@5
npm install --save-dev typescript@7 @types/node@24 @types/express@5
npm pkg set type=module scripts.build=tsc scripts.start="node dist/server.js"
mkdir srcAdd the server#
Create src/server.ts:
1import express from "express";
2
3const app = express();
4const port = Number(process.env.PORT ?? 3000);
5
6app.get("/health", (_req, res) => {
7 res.json({ status: "ok" });
8});
9
10const server = app.listen(port, () => {
11 console.log(`listening on port ${port}`);
12});
13
14// Node.js runs as PID 1 in the container, so handle stop signals yourself.
15function shutdown() {
16 server.close(() => process.exit(0));
17}
18process.on("SIGTERM", shutdown);
19process.on("SIGINT", shutdown);Add the TypeScript config#
Create tsconfig.json. TypeScript 7 defaults types to an empty list and rootDir to the project root, so set both:
1{
2 "compilerOptions": {
3 "target": "es2024",
4 "module": "nodenext",
5 "rootDir": "src",
6 "outDir": "dist",
7 "types": ["node"],
8 "strict": true,
9 "skipLibCheck": true
10 },
11 "include": ["src"]
12}Build and run it once on your host:
npm run build
npm start2. Build a single-stage baseline#
Create Dockerfile.single. This is a common starting point:
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]Build it:
docker build -f Dockerfile.single -t tiny-api:single .This image contains the full node:24 base, every devDependency, the npm cache, and your host node_modules. It also starts the app through npm. The Node.js Docker team recommends running node directly, so SIGTERM and SIGINT reach your process instead of npm.
3. Add a .dockerignore file#
Create .dockerignore in the project root. The build client removes matching files from the context before it sends the context to the builder:
node_modules
dist
.git
*.log
.env*Never copy host node_modules into a Linux image. Packages with native or platform-specific binaries, such as TypeScript 7, install different files on macOS and Linux.
4. Split the build into stages#
Create Dockerfile next to Dockerfile.single:
1# syntax=docker/dockerfile:1
2
3FROM node:24-trixie-slim AS build
4WORKDIR /app
5COPY package.json package-lock.json ./
6RUN \
7 npm ci
8COPY tsconfig.json ./
9COPY src ./src
10RUN npm run build
11
12FROM node:24-trixie-slim AS prod-deps
13WORKDIR /app
14COPY package.json package-lock.json ./
15RUN \
16 npm ci --omit=dev
17
18FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime
19WORKDIR /app
20ENV NODE_ENV=production
21COPY /app/node_modules ./node_modules
22COPY /app/dist ./dist
23COPY package.json ./
24USER 65532:65532
25EXPOSE 3000
26CMD ["dist/server.js"]The build stage#
FROM node:24-trixie-slim AS build names the stage. It installs all dependencies, including TypeScript, and compiles src to dist. Nothing from this stage reaches the final image unless a later stage copies it.
The production dependencies stage#
prod-deps runs npm ci --omit=dev. It produces a node_modules folder with only Express and its dependencies. Because this is a separate stage, devDependencies stay out of the image without any step that deletes files afterward.
The runtime stage#
COPY --from=<stage> copies files from an earlier stage. The runtime stage copies three things: production node_modules, compiled dist, and package.json. Node.js reads the type field in package.json to load dist/server.js as an ES module.
The distroless Node.js image sets its entrypoint to /nodejs/bin/node. That is why CMD holds only the script path. CMD uses exec form because the image has no shell.
To debug one stage, build only up to it with --target:
docker build --target build -t tiny-api:build .
docker run --rm -it tiny-api:build bashWith BuildKit, --target skips stages the target does not depend on.
5. Choose a runtime base#
The table lists compressed linux/amd64 sizes from Docker Hub and the gcr.io registry, checked on October 22, 2026. Images on disk are larger.
| Image | Compressed size | Shell | Package manager |
|---|---|---|---|
node:24 | about 410 MB | yes | apt, npm |
node:24-trixie-slim | about 83 MB | yes | apt, npm |
node:24-alpine | about 63 MB | yes | apk, npm |
gcr.io/distroless/nodejs24-debian13:nonroot | about 56 MB | no | none |
gcr.io/distroless/static-debian13:nonroot | under 1 MB | no | none |
Each runtime base has a cost:
- Distroless is only a little smaller than Alpine. Its main advantage is what it leaves out. It has no shell, no npm, and no package manager for an attacker or a scanner to find. The cost is harder debugging. You cannot
docker execinto it withsh, andHEALTHCHECKscripts that callcurlwill not work. - Alpine uses musl libc instead of glibc. Pure JavaScript packages work. Native addons and prebuilt binaries need musl builds. If you pick Alpine, use
node:24-alpinefor every stage, not only the runtime. - Slim is the most compatible choice. It keeps glibc, a shell, and apt, but the image is larger.
Match the Debian release across stages. trixie is Debian 13, which matches nodejs24-debian13. Native modules compiled in the build stage then link against the same glibc at runtime. For Node.js 24, the plain node:24 and node:24-slim tags still point to Debian 12 (bookworm), so name trixie in the tag.
For an Alpine build, change the build and prod-deps stages to FROM node:24-alpine too. The official image ships a node user with UID 1000. The runtime stage below sets that user by number, as step 7 recommends:
1FROM node:24-alpine AS runtime
2WORKDIR /app
3ENV NODE_ENV=production
4COPY /app/node_modules ./node_modules
5COPY /app/dist ./dist
6COPY package.json ./
7USER 1000:1000
8EXPOSE 3000
9CMD ["node", "dist/server.js"]6. Order layers for the cache#
When a file that a layer uses changes, Docker rebuilds that layer and every layer after it. Put slow steps whose inputs rarely change first, and steps whose inputs change often last.
The build stage copies package.json and package-lock.json before src. When you edit src/server.ts, Docker reuses the cached npm ci layer and reruns only npm run build.
The RUN --mount=type=cache,target=/root/.npm flag adds a second cache. When the lockfile changes, the layer still rebuilds, but npm reads packages it already downloaded from the mounted cache. The cache directory never becomes part of the image. npm caches to ~/.npm by default, and the build stages run as root, so the target is /root/.npm.
The # syntax=docker/dockerfile:1 line tells BuildKit to fetch the latest stable release of the version 1 Dockerfile syntax.
7. Run as a non-root user#
The :nonroot distroless tag already sets the user to UID 65532. The USER 65532:65532 line makes that explicit and keeps the setting if you change the base image. Kubernetes can only verify runAsNonRoot against a numeric user, so prefer numbers over names.
Files copied without --chown belong to UID 0. The non-root process can read them but cannot change its own code or dependencies. Leave that ownership in place. If the app must write files, mount a volume for that path at runtime. If a copied path must belong to the app user, add --chown=65532:65532 to that COPY line.
Verify it works#
Build the multi-stage image and start it:
docker build -t tiny-api:multi .
docker run --rm -d -p 3000:3000 --name tiny-api tiny-api:multiCall the health endpoint:
curl http://localhost:3000/healthYou should see:
{"status":"ok"}Stop the container. It exits right away because the app handles SIGTERM:
docker stop tiny-apiCompare the two images:
docker images tiny-apiDocker 29 shows a collapsed tree view by default, with DISK USAGE and CONTENT SIZE columns. Compare the same column across tags. To get a single size column instead, pass a format:
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" tiny-apiShow the size of each layer:
docker history tiny-api:single
docker history --format "table {{.Size}}\t{{.CreatedBy}}" tiny-api:multiIn the single-stage image, the base layers and the npm install layer take most of the space. In the multi-stage image, the largest layer you add should be node_modules.
Confirm the image user:
docker image inspect --format '{{.Config.User}}' tiny-api:multiThe output is 65532:65532.
Troubleshooting#
npm ci fails without a lockfile#
npm ci requires package-lock.json and exits with an error if the lockfile does not match package.json. Commit the lockfile. Check that .dockerignore does not exclude it. Run npm install on the host after changing dependencies.
No shell in the distroless image#
docker exec -it tiny-api sh fails with executable file not found in $PATH, because distroless has no shell. For a debugging session, change the runtime tag to :debug-nonroot, which adds a BusyBox shell. Rebuild, then override the entrypoint, because it points at Node.js:
docker build -t tiny-api:debug .
docker run --rm -it --entrypoint=sh tiny-api:debugSwitch the tag back before you deploy. You can also inspect the build stage with --target build.
Native modules crash at startup#
Errors about missing shared libraries or an invalid ELF header mean the binary was built for another libc or platform. Keep the build stage and the runtime on the same family: trixie with debian13, or Alpine everywhere. Never copy host node_modules.
Cache mounts do not help in CI#
BuildKit keeps cache mounts in the builder's local storage. Exported build cache does not include their contents, so on a fresh CI runner the npm cache mount starts empty. Exported layer cache still saves the npm ci layer when the lockfile has not changed. For GitHub Actions, the Docker docs on cache management (opens in a new tab) describe a workaround for cache mounts.
Next steps#
The same pattern works for Go. There the runtime can be the static distroless image, which is under 1 MB compressed. With CGO_ENABLED=0, the binary has no libc dependency:
1# syntax=docker/dockerfile:1
2
3FROM golang:1.27-trixie AS build
4WORKDIR /src
5COPY . .
6RUN 7 \
8 CGO_ENABLED=0 go build -o /out/app .
9
10FROM gcr.io/distroless/static-debian13:nonroot
11COPY /out/app /app
12USER 65532:65532
13ENTRYPOINT ["/app"]- Node.js 24 moved to maintenance LTS on October 20, 2026. Node.js 26 is the active LTS line from October 28, 2026. To upgrade, change
24to26innode:24-trixie-slimandnodejs24-debian13, and install@types/node@26. - Read the Docker guides on multi-stage builds (opens in a new tab) and build cache optimization (opens in a new tab).
- Check the distroless image list (opens in a new tab) for other runtimes and the
debugtags. - Review the Node.js Docker best practices (opens in a new tab) for memory limits and
--init.