sehaj/learn
Docker/02 Images

02.01 · with code · 14 min

Your first Dockerfile

Turn a real TypeScript API into an image, one instruction at a time, then run it and talk to it.

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.

project/docker/api
package.json
package-lock.json
tsconfig.json
src/server.ts

A first Dockerfile

Create Dockerfile (no extension) next to package.json:

Dockerfile
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Line by line:

InstructionWhat it does
FROM node:22-slimStart from an existing image: Debian with Node 22 installed. Every Dockerfile starts with FROM.
WORKDIR /appCreate /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 ciRun a command at build time, inside the image being built. Here: install dependencies.
RUN npm run buildCompile TypeScript into dist/.
EXPOSE 3000Document 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

❯terminal
$ 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

❯terminal
$ 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 notes
notes-api listening on :3000
$ docker stop notes
$ docker logs notes
notes-api listening on :3000
SIGTERM 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:

Dockerfile
# 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.js

Use 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:

Dockerfiledocker run notes-api runsdocker 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.jsnode --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.

Predict first

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?

Commit to an answer before you read on.

Other instructions you'll meet

  • ENV NODE_ENV=production sets an environment variable for later build steps and for running containers.
  • ARG VERSION declares a build-time variable you pass with docker build --build-arg VERSION=1.2. It doesn't exist when the container runs.
  • USER node runs everything after it, including the container's process, as that user instead of root (07.01 Run as non-root).
  • ADD is like COPY but can also fetch URLs and unpack archives. Prefer COPY unless you need those.
CheckpointBuild and talk to your image

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

MidfreqMust-knowConcept

CMD vs ENTRYPOINT: what's the difference, and when would you use both?

JuniorfreqMust-knowConcept

What's the difference between RUN and CMD?

Report a mistakeEdit this page

Used in

☰ Images02.01 · 7/44next →