The core idea
A Dockerfile is a recipe for an image. Each line is an instruction, Docker runs them top to bottom, and the result of each one becomes part of the image. There are only a handful of instructions you'll use every day, and this lesson covers all of them.
The app
The notes API is one file, src/server.ts, with three routes: GET /healthz, GET /notes and POST /notes. To run it
without Docker you'd do three things: install dependencies, compile TypeScript, start Node. A Dockerfile writes those
same steps down, plus the environment they need.
package.json
package-lock.json
tsconfig.json
src/server.tsA first Dockerfile
Create Dockerfile (no extension) next to package.json:
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]Line by line:
| Instruction | What it does |
|---|---|
FROM node:22-slim | Start from an existing image: Debian with Node 22 installed. Every Dockerfile starts with FROM. |
WORKDIR /app | Create /app if needed and make it the current directory for every following step. |
COPY . . | Copy files from your project (the build context) into /app in the image. |
RUN npm ci | Run a command at build time, inside the image being built. Here: install dependencies. |
RUN npm run build | Compile TypeScript into dist/. |
EXPOSE 3000 | Document that the app listens on 3000. It doesn't publish anything; -p does that. |
CMD [...] | The command to run when a container starts. Nothing runs at build time. |
The distinction that matters most: RUN happens once, when you build. CMD happens every time a container starts.
Build it
$ docker build -t notes-api ....#5 transferring context: 26.18MB 0.2s done#6 [2/5] WORKDIR /app#7 [3/5] COPY . .#8 [4/5] RUN npm ci#8 0.661 added 3 packages, and audited 4 packages in 525ms#9 [5/5] RUN npm run build#9 0.135 > tsc#10 naming to docker.io/library/notes-api:latest done
Output trimmed. -t notes-api names the image, and . is the build context: the folder whose files COPY can
see. Notice that it sent 26 MB of context for a project with one source file. That's your local node_modules,
and fixing it is the whole of 02.03 Build context and .dockerignore.
Run it
$ docker run -d --name notes -p 8080:3000 notes-api$ curl localhost:8080/healthz{"status":"ok"}$ curl -X POST localhost:8080/notes -H 'content-type: application/json' -d '{"text":"hello from a container"}'{"id":"eea25c54-9642-4e70-a064-2b4a92007787","text":"hello from a container","createdAt":"2026-11-02T20:05:44.199Z"}$ docker logs notesnotes-api listening on :3000$ docker stop notes$ docker logs notesnotes-api listening on :3000SIGTERM received, closing
docker stop returns almost instantly here, because server.ts handles SIGTERM. You saw in 01.03 Under the hood: namespaces and cgroups why
that matters for PID 1.
Two forms of CMD
CMD comes in two forms, and the difference bites later:
# Exec form: Docker runs node directly. node is PID 1 and receives signals.
CMD ["node", "dist/server.js"]
# Shell form: Docker runs /bin/sh -c "node dist/server.js". The shell is PID 1.
CMD node dist/server.jsUse the exec form (the JSON array) unless you specifically need shell features like && or $VARS. With the shell
form, sh receives docker stop's SIGTERM and may not pass it on, so your app gets killed after the timeout instead
of shutting down cleanly.
CMD and ENTRYPOINT
ENTRYPOINT is the other half. When both are set, Docker runs ENTRYPOINT with CMD appended as arguments, and
anything you type after the image name in docker run replaces CMD:
| Dockerfile | docker run notes-api runs | docker run notes-api --inspect runs |
|---|---|---|
CMD ["node", "dist/server.js"] | node dist/server.js | --inspect (fails: not a command) |
ENTRYPOINT ["node"] + CMD ["dist/server.js"] | node dist/server.js | node --inspect |
Rule of thumb: use CMD alone for apps. Use ENTRYPOINT when the image is a command, like a CLI tool, and CMD
holds its default arguments.
You add RUN date > /build-time.txt and CMD cat /build-time.txt, build once, then start three containers an hour apart. What do they print?
Other instructions you'll meet
ENV NODE_ENV=productionsets an environment variable for later build steps and for running containers.ARG VERSIONdeclares a build-time variable you pass withdocker build --build-arg VERSION=1.2. It doesn't exist when the container runs.USER noderuns everything after it, including the container's process, as that user instead of root (07.01 Run as non-root).ADDis likeCOPYbut can also fetch URLs and unpack archives. PreferCOPYunless you need those.
Write the Dockerfile above, build it, run it on port 8080, create two notes with POST /notes and list them with GET /notes. Then stop and start the container again and list the notes once more. Where did they go, and why?
STUCK? ▸
port is already allocated: something else uses 8080, so pick another host port, like -p 8090:3000. Cannot find module '/app/dist/server.js': the build step didn't run or output somewhere else; check RUN npm run build is in the Dockerfile.
Recap
Interview questions
CMD vs ENTRYPOINT: what's the difference, and when would you use both?
What's the difference between RUN and CMD?