The brief
The notes API is going to production eventually. This step produces its image. Done means:
Try it yourself first. Each step below has the answer folded away under "Stuck?".
Get the code
$ git clone https://github.com/sehajmakkar/learn.git$ cd learn$ git checkout docker-step-01$ cd project/docker/api$ lsDockerfile README.md package-lock.json package.json src tsconfig.json
docker-step-01 is the finished state of this step, so delete Dockerfile and .dockerignore now for a clean start.
Later steps change these files, which is why you check out the git tag instead of using main.
Step 1: the build context
Create a .dockerignore. When you build later, the "transferring context" line should be a few kilobytes, not megabytes.
STUCK? ▸
Ignore at least node_modules, dist, .git, .env and *.log. Adding Dockerfile and .dockerignore keeps edits to them from busting the COPY cache.
Step 2: the Dockerfile
Two stages on node:22-slim. The build stage installs everything and runs npm run build. The final stage sets NODE_ENV=production, installs production dependencies only, copies dist from the build stage, switches to the node user and starts node dist/server.js.
STUCK? ▸
Compare with the finished Dockerfile in project/docker/api (tag docker-step-01). The key moves: name the first stage AS build; copy package.json and the lockfile before npm ci; in the final stage run npm ci --omit=dev, COPY --from=build /app/dist ./dist, then USER node and an exec-form CMD.
Step 3: build and inspect
$ docker build -t notes-api ....#7 transferring context: 3.82kB done...$ docker images notes-apiIMAGE ID DISK USAGE CONTENT SIZEnotes-api:latest cf94b518b7ef 349MB 80.4MB$ docker run --rm notes-api lsdistnode_modulespackage-lock.jsonpackage.json
Output trimmed; your ID will differ. No src, no tsconfig.json, no TypeScript compiler.
Now prove the cache works: change a message in src/server.ts and rebuild. Both npm ci steps should say CACHED;
only the source copy, the compile and the final COPY --from should run.
Step 4: run it like production would
$ docker run -d --name notes -p 8080:3000 notes-api$ curl localhost:8080/healthz{"status":"ok"}$ docker exec notes whoaminode$ docker stop notesnotes$ docker logs notesnotes-api listening on :3000SIGTERM received, closing
docker stop returns almost immediately, because the app handles SIGTERM and runs as PID 1 with the exec-form CMD. If
yours waits for the whole stop timeout and the logs don't show SIGTERM received, check you used CMD ["node", "dist/server.js"] and not the shell form.
Step 5: tag and push
Tag with the version from package.json and the short commit hash, so any running container can be traced back to
its code:
$ VERSION=$(node -p "require('./package.json').version")$ SHA=$(git rev-parse --short HEAD)$ docker run -d --name registry -p 5050:5000 registry:2$ docker tag notes-api localhost:5050/notes-api:$VERSION$ docker tag notes-api localhost:5050/notes-api:sha-$SHA$ docker push --all-tags localhost:5050/notes-api
Both tags point at the same image, so each layer uploads once and the second tag only adds a name. To publish for real, swap
localhost:5050 for ghcr.io/YOUR_USERNAME after docker login ghcr.io (02.06 Tags, digests and registries).
You edit src/server.ts and rebuild your finished multi-stage image. Which steps run again?
Compare with the reference
$ git diff docker-step-01 -- project/docker/api
No output means your files match the reference exactly. Differences aren't necessarily wrong. If yours builds, the
image contains only what it needs, runs as node and stops cleanly, it meets the brief.
What's next
The image works, but it has no idea whether the app inside is healthy, takes its config from code, and keeps notes in memory. Module 03 adds configuration, limits and a healthcheck (03.07 Healthchecks (project step 2)), and Module 04 moves the notes into Postgres (04.05 Project: persist Postgres).
Interview questions
Walk me through how you'd write a production Dockerfile for a Node.js service.