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.
- 1The app you’re packaging
- 2A first Dockerfile
- 3Build and run
- 4Exec form, and why it matters
- 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:
notes-api/
package.json
package-lock.json
tsconfig.json
src/
server.ts
db.tsnpm 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:
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:
# 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:1pins the Dockerfile frontend to the latest 1.x, which gives you current features such as build secrets and cache mounts.FROM node:24-slimstarts from the official Node.js 24 image on a trimmed Debian base. Every image starts from another image.WORKDIR /appcreates/appif 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 ciinstalls exactly whatpackage-lock.jsonsays. Useci, notinstall, in images: it fails if the lockfile andpackage.jsondisagree instead of quietly rewriting the lockfile.RUN npm run buildcompiles TypeScript todist/.EXPOSE 3000records that the app listens on 3000. It publishes nothing.CMDis 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
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:
curl localhost:3000/healthzYou 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
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:
docker run --rm notes-api:dev node --version
docker run --rm notes-api:dev ls distThere’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,COPYandRUNproduces a filesystem layer in the image. - The build context is the directory you pass to
docker build, andCOPYcan only see files inside it. EXPOSEonly documents a port;-p host:containerondocker runis what makes it reachable.- Write
CMDin 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, not127.0.0.1, or published ports can’t reach it.