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=devinstalls 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 whyCMDis only the file name. USER nonrootruns 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
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.