Learning paths / Containers / Containerize a real app

Recipe: containerize a Node.js app

Reading · 6 min · Module 8, lesson 2 of 653 min left in this module

Module 8 · Containerize a real appLesson 2 of 6

Goal: Containerize a Node.js app with a multi-stage, non-root Dockerfile and a .dockerignore, then prove it serves /healthz.

Key idea

Every recipe in this module follows the same checklist: a .dockerignore, a build stage that installs what the app needs, a small runtime stage that runs as a non-root user, then build, run and call /healthz. For Node, the build stage runs npm ci, and the runtime stage is Node with nothing else.

Get the app

git clone https://github.com/computesphere-samples/learn.git
cd learn/labs/containerize-node

A small Express app on port 3000, with / and /healthz. Its one dependency is pinned in package-lock.json.

.dockerignore

node_modules
npm-debug.log
.env*
.git
Dockerfile
.dockerignore

Your local node_modules was built for your machine, and .env files hold secrets (lesson 3.4.4). The image installs its own dependencies instead.

Dockerfile

# Stage 1: install production dependencies with npm.
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Stage 2: only Node itself, the dependencies and the code. No npm, no shell.
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY server.js ./
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
USER nonroot
CMD ["server.js"]

What each choice does:

  • npm ci --omit=dev installs exactly what the lockfile lists, without development tools.
  • The package files are copied before the code, so the install layer stays cached until dependencies change (lesson 3.2.1).
  • The runtime stage is distroless. It has Node and nothing else, so there's no shell or package manager for an attacker to use. It already starts node, which is why CMD is only the file name.
  • USER nonroot runs the app as an unprivileged user (lesson 3.4.3).

Build, run and check

docker build -t containerize-node:1.0.0 .
docker run -d --rm --name containerize-node -p 3000:3000 containerize-node:1.0.0
curl -i localhost:3000/healthz
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
...
{"status":"ok"}

Then confirm the user, and stop the container:

docker image inspect containerize-node:1.0.0 --format '{{.Config.User}}'
docker stop containerize-node

The first command prints nonroot. docker image ls containerize-node shows the image at a little over 200 MB, most of it Node itself.

Check yourself

You add a new package to package.json and package-lock.json, then rebuild. Which steps run again?
If the container exits straight away

--rm deletes it, and its logs, as it exits. Run it again without --rm, then docker logs containerize-node, and docker rm containerize-node when you're done. Cannot find module means a file wasn't copied into the runtime stage: every file the app loads needs its own COPY. There's no shell in this image, so docker exec … sh won't work; debug from the logs.