# Container images for Node.js

> What a production image should contain, and the recipes that get you there for plain Node.js, Bun, Next.js and TanStack Start.

- Canonical: https://js-on-k8s.dev/recipes/container-images
- Site: JavaScript on Kubernetes (https://js-on-k8s.dev)
- Updated: 2026-09-10
- Tags: dockerfile, docker, images, distroless, buildpacks
- Example: https://github.com/vojtechmares/js-on-k8s/tree/main/examples/full/Dockerfile
- Full example: https://github.com/vojtechmares/js-on-k8s/tree/main/examples/full

A production image is the runtime, your code, its production dependencies and
CA certificates. Nothing else. Everything below follows from that.

## What good looks like

```
FROM node:24-bookworm-slim AS build          # tools live here
  npm ci --omit=dev, build TypeScript, bundle
FROM gcr.io/distroless/nodejs24-debian12:nonroot   # only this ships
  COPY --from=build ... ; CMD ["src/server.js"]
```

- **Two stages.** Build tools, dev dependencies and source maps stay in the first stage.
- **Lockfile first.** Copy `package.json` and the lockfile before the source, so the dependency layer is cached between builds.
- **Distroless runtime.** No shell, no package manager, non-root user. Alpine and Debian slim are the fallbacks, not the goal.
- **Node as PID 1.** `CMD ["src/server.js"]` under the image's `node` entrypoint. Never `npm start`.
- **Secrets never touch a layer.** Private registry tokens go in as BuildKit secrets.
- **Build once.** The same image goes through every environment; configuration comes from the Pod spec.

## Checklist

```sh
docker history --no-trunc image | grep -ci token      # 0
docker inspect image --format '{{.Config.User}}'      # 65532 or nonroot
docker run --rm --entrypoint sh image                 # should fail: no shell
docker images image --format '{{.Size}}'              # a Node.js API is ~200 MB, mostly the runtime
```

## Notes

- Bun has the same shape: `oven/bun:1` to build, `oven/bun:1-distroless` to run.
- Meta-frameworks add a build step and their own output folder, but the runtime stage is the same distroless image.
- Buildpacks produce a reasonable image without a Dockerfile, at the cost of a bigger, shell-bearing base.

## In this section

- [A minimal multi-stage Dockerfile](https://js-on-k8s.dev/recipes/container-images/dockerfile) - Install with npm ci, build in one stage, copy only what runs into the final image.
- [Distroless instead of Alpine or Debian slim](https://js-on-k8s.dev/recipes/container-images/distroless-image) - Ship the Node.js runtime and your app, nothing else. No shell, no package manager, non-root.
- [Private packages with Docker build secrets](https://js-on-k8s.dev/recipes/container-images/build-secrets) - Pass NPM_TOKEN as a BuildKit secret. Never put it in an ARG, an ENV, or a copied .npmrc.
- [Build an image with Cloud Native Buildpacks](https://js-on-k8s.dev/recipes/container-images/buildpacks) - Get a production Node.js image without writing a Dockerfile, using pack and Paketo.
- [Containerize a Hono API](https://js-on-k8s.dev/recipes/container-images/hono) - A Hono service on the Node.js adapter, built in two stages into a distroless image, with the Bun variant alongside.
- [Containerize a Next.js app](https://js-on-k8s.dev/recipes/container-images/nextjs) - Build with output standalone, copy three folders into a distroless image, run server.js directly.
- [Containerize a TanStack Start app with Nitro](https://js-on-k8s.dev/recipes/container-images/tanstack-start) - Add the Nitro Vite plugin, build a node-server bundle into .output, and run the entry with node in a distroless image.
