EduFlow

Section 1 · Containers from First Principles

Your First Dockerfile

20 min4 min readSpot the bug

Lesson at a glance

Write a working Dockerfile for notes-api, build it into a tagged image, run it with a published port, and know what each instruction does.

  1. 1The app you’re packaging
  2. 2A first Dockerfile
  3. 3Build and run
  4. 4Exec form, and why it matters
  5. 5Look at what you built

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

You can describe notes-api’s runtime in one sentence: “Node.js 24, our compiled code, our dependencies, start with node dist/server.js on port 3000.” A Dockerfile is that sentence written so a machine can repeat it exactly, on any laptop or build server, forever.

The app you’re packaging

The repository looks like this:

Text
notes-api/
  package.json
  package-lock.json
  tsconfig.json
  src/
    server.ts
    db.ts

npm run build compiles src/ into dist/ with tsc, and npm start runs node dist/server.js. Here is the part of the server that matters for containers:

src/server.ts
import express from 'express';
import { pool } from './db.js';

const app = express();
app.use(express.json());

app.get('/healthz', (_req, res) => res.send('ok'));
app.get('/readyz', async (_req, res) => {
  try {
    await pool.query('SELECT 1');
    res.send('ready');
  } catch {
    res.status(503).send('database unreachable');
  }
});
// …notes routes

const port = Number(process.env.PORT ?? 3000);
app.listen(port, '0.0.0.0', () => console.log(`notes-api listening on ${port}`));

Note the '0.0.0.0'. Hold that thought.

A first Dockerfile

Create Dockerfile (capital D, no extension) at the repository root:

Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Line by line:

  • # syntax=docker/dockerfile:1 pins the Dockerfile frontend to the latest 1.x, which gives you current features such as build secrets and cache mounts.
  • FROM node:24-slim starts from the official Node.js 24 image on a trimmed Debian base. Every image starts from another image.
  • WORKDIR /app creates /app if needed and makes it the working directory for every instruction after it, and for the running container.
  • COPY . . copies the build context into /app.
  • RUN npm ci installs exactly what package-lock.json says. Use ci, not install, in images: it fails if the lockfile and package.json disagree instead of quietly rewriting the lockfile.
  • RUN npm run build compiles TypeScript to dist/.
  • EXPOSE 3000 records that the app listens on 3000. It publishes nothing.
  • CMD is the default command when a container starts.

Why node:24-slim? Node.js 24 is a long-term-support line with security fixes into 2028, and the official image is rebuilt whenever Node or Debian ships a patch. The -slim variant leaves out compilers, man pages and dozens of packages that the full node:24 image includes for building native modules. You don’t need them to run JavaScript, and every package you don’t ship is one the security scanner can’t complain about. If a dependency ever needs to compile native code, you’ll handle that in a separate build stage in the next section.

This file works, and it has a performance problem you’ll fix in the next lesson. Get it running first.

Build and run

Terminal
docker build -t notes-api:dev .
docker run --rm -p 3000:3000 notes-api:dev

-t notes-api:dev names the image (repository:tag). The trailing . is the build context: the directory Docker hands to the builder. COPY can only read files inside it, which is why COPY ../shared . fails.

docker run creates a container from the image. --rm deletes it when it stops, so you don’t collect dead containers. -p 3000:3000 maps port 3000 on your machine to port 3000 in the container. In another terminal:

Terminal
curl localhost:3000/healthz

You get ok. /readyz returns 503 because there’s no database yet; Compose fixes that in section 3.

Exec form, and why it matters

CMD has two forms. Exec form, CMD ["node", "dist/server.js"], runs node directly as PID 1. Shell form, CMD node dist/server.js, runs /bin/sh -c "node dist/server.js", and the Dockerfile reference warns that a command started that way won’t receive Unix signals. When Docker or Kubernetes asks your app to stop, it sends SIGTERM to PID 1. If PID 1 is a shell that doesn’t pass it on, your app never hears it, gets killed hard after the timeout, and drops whatever requests it was serving.

CMD npm start is the same problem with an extra process in the chain. Run node directly.

Look at what you built

Terminal
docker image ls notes-api
docker image inspect notes-api:dev --format '{{.Config.Cmd}} {{.Config.ExposedPorts}}'

CMD is only a default. Anything you type after the image name replaces it, which is handy for one-off checks:

Terminal
docker run --rm notes-api:dev node --version
docker run --rm notes-api:dev ls dist

There’s also ENTRYPOINT, which fixes the executable and treats CMD as its default arguments. Some images use it (you’ll meet one in the next section whose entrypoint is node itself). For your own app images, a plain exec-form CMD is the simplest thing that works, and it keeps overrides like the ones above easy.

The image is several hundred megabytes: Node.js, the Debian base, your dev dependencies (TypeScript included) and your source. Section 2 cuts that down. First, though, every change to server.ts currently reinstalls all your dependencies, and that’s the next lesson.

Key takeaways

  • A Dockerfile is a recipe of instructions; each FROM, COPY and RUN produces a filesystem layer in the image.
  • The build context is the directory you pass to docker build, and COPY can only see files inside it.
  • EXPOSE only documents a port; -p host:container on docker run is what makes it reachable.
  • Write CMD in exec form (["node", "dist/server.js"]) so your app is PID 1 and receives stop signals.
  • A server inside a container must listen on 0.0.0.0, not 127.0.0.1, or published ports can’t reach it.

Further reading

Check your understanding

4 questions

Question 1 of 4

You run docker build -t notes-api:dev . from the repository root. What does the final . mean?

Question 2 of 4

The Dockerfile has EXPOSE 3000. You run docker run notes-api:dev and curl localhost:3000 on your laptop fails. Why?

Question 3 of 4

Which CMD lets the Node.js process receive SIGTERM directly when the container is stopped?

Question 4 of 4

notes-api logs listening on 127.0.0.1:3000 inside the container. You published -p 3000:3000, but every request gets connection reset. What’s the fix?

Practice

Spot the bug

Review a teammate's first Dockerfile

This Dockerfile builds, but the files end up in the wrong place and the app is killed hard every time the container stops. Select the two lines that cause these problems.

Select the line(s) you think are wrong, then check.