shouldenough

DevOpsDocker for Developers

Lesson 6 of 6

Lesson 06/16 minutes/1 graded

Writing a Dockerfile that stays fast

Instruction order, multi-stage builds, and the layer that invalidates everything.

Everything so far, applied to one file. The target is an image that rebuilds quickly after a source edit and starts correctly on a machine that is not yours.

The sample project

Every command in this lesson runs against the course sample, examples/docker-for-developers-api: a small HTTP API with one runtime dependency (nanoid) and one build-only dependency (typescript). That split is what the Dockerfile below is built around. Put it at ~/projects/api and the Dockerfile it ships with is the one shown here.

A complete example

# syntax=docker/dockerfile:1
ARG NODE_VERSION=24-slim

FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund

FROM node:${NODE_VERSION} AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:${NODE_VERSION} AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund

FROM node:${NODE_VERSION} AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=prod-deps /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The deps stage exists so a source edit does not reinstall anything, and prod-deps exists so the image ships runtime dependencies only. The build stage needs devDependencies to compile; the runtime stage must not inherit them, which is why they come from different stages.

The remaining lines that are not self-explanatory:

.dockerignore is not optional

Before the daemon runs a single instruction, the CLI sends it the build context, the directory you pointed at. Without a .dockerignore, that includes node_modules, .git, and .next. On a normal project that is hundreds of megabytes uploaded before the build even starts.

node_modules
.git
.next
.env*
*.log

USER node: do not run as root

Containers run as root by default. A process that is compromised inside the container is then root inside the container, which is a much better starting position for an attacker than it needs to be. Official images usually ship a non-root user; switch to it after the last step that needs to write.

CMD in exec form

CMD ["node", "dist/server.js"]   # exec form — the process is PID 1
CMD node dist/server.js          # shell form — wrapped in /bin/sh -c

Shell form means your process is a child of sh, and sh does not forward SIGTERM. docker stop then always takes the full ten-second timeout and kills you ungracefully. Use the exec form.

ENV NODE_ENV=production

Express and many other Node frameworks read NODE_ENV and change how much they log and how much error detail they return. Setting it in the image means nobody has to remember it at deploy time.

Pin your base image

node:24-slim is better than node:latest. A digest is more specific still:

FROM node:24-slim@sha256:...

A tag can be repointed at a new image; a digest always refers to the same bytes. Pinning by digest means you never get a surprise patch, and also that you never get a security fix without editing the file. Which surprise you prefer is the actual decision.

Prove it runs

Build it, then run it with the port published, and check that the process actually answers. An image that has never been started tells you nothing about whether it works.

~/projects/api

Run the image you tagged shouldenough/api:1.0 in the background, named api, publishing container port 3000 on host port 3000.

docker ps tells you whether it is still up, and curl localhost:3000 tells you whether it is serving. If docker ps comes back empty the container exited already, and docker logs api will say why.

Where this stops being enough

The image you just ran is a single container started by hand. The next problems are the ones that hand-started containers cannot solve: restarting a crashed process, rolling out a new version without downtime, and running more than one copy behind something that balances load. Those belong to Compose in the small case and to an orchestrator in the large one.

Read the Dockerfile reference when you need an instruction this course did not cover. HEALTHCHECK and ARG-versus-ENV are the two most people reach for next.