Ship and operate

Build a production image

Use a multi-stage build to separate compilation tools from the smaller runtime filesystem delivered to production.

The image that builds your app and the image that runs it don’t need the same contents. Building may need TypeScript, a test runner, and devDependencies. Running needs Node.js, the production dependencies, and the compiled output. Nothing else.

A multi-stage build lets you use one Dockerfile for both, and ship only the second part.

Two stages, one file

Imagine a TypeScript version of the Notes API, with the source in src/ and npm run build compiling it to dist/:

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm test && npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Every FROM starts a new stage, and AS gives it a name. The build stage installs everything, runs the tests, and compiles. The runtime stage starts again from a clean node:22-alpine, installs only production dependencies, and copies dist/ from the first stage with COPY --from=build.

The final image is the last stage. Everything in build is thrown away: the TypeScript compiler, the tests, the source files, the devDependencies. Nothing crosses into runtime unless a COPY --from names it. Treat that as an allowlist.

Build it with a different tag:

docker build -t notes-api:prod .
docker image ls notes-api

Compare the sizes of dev and prod. And notice that if the tests fail, the build fails. There’s no way to ship an image whose tests didn’t pass.

Verify the boundary

Don’t assume the allowlist worked. Check:

docker run --rm notes-api:prod ls /app
docker history notes-api:prod

The first command should list dist, node_modules, and the package*.json files, and no src. docker history shows every layer of the final image with its size. Then send the same curl request you used in development against a container from this image.

Portability traps

Multi-stage builds also expose a mistake that’s easy to make: native modules. A dependency compiled in the build stage (think sharp or bcrypt) is compiled for that stage’s operating system and CPU. If your build stage is Debian-based and your runtime is Alpine, or you build on an Apple Silicon Mac for an amd64 server, the module won’t load. Keep both stages on the same base family, build for the production platform with --platform, and test the exact image you’ll deploy.

A smaller image has fewer tools for an attacker to use. It still needs a vulnerability scan, a non-root user (next lesson), and a repeatable way to rebuild when the base image gets a security update.

Try this: adapt the pattern to a TypeScript version of the Notes API and read its docker history.

Lesson completed