Writing a Dockerfile
Objectives
By the end of this lesson, you should be able to:
- Write a Dockerfile that builds a Node.js application into a container image
- Explain what a build stage is, and why a multi-stage build keeps a production image smaller
- Explain what
.dockerignoredoes, and why it matters
💡 Why this matters: A Dockerfile is the actual recipe, step by step, for building the container image Lesson 1 described, this lesson writes one for a real Node.js API, the Notes API from Authentication, Security & Testing for Node.js.
⚠️ A note on verification: Docker can’t run inside this course’s own sandboxed tooling, no container runtime is available in this environment. The Dockerfile and commands below reflect Docker’s stable, current, documented behavior. Build and run this yourself, on your own machine, with Docker installed, to see it work end to end.
A Simple Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
Reading it top to bottom: FROM node:20-alpine starts from an official Node.js image, alpine is a minimal Linux distribution, chosen specifically to keep the final image small, WORKDIR /app sets the working directory inside the container, COPY package*.json ./ copies just the dependency manifests first, RUN npm ci --omit=dev installs production dependencies only, COPY . . copies the rest of the application code, EXPOSE 3000 documents which port the container listens on, CMD is the command that runs when a container starts.
Why Dependencies Are Copied Before the Rest of the Code
Docker builds in layers, caching each one, if package.json hasn’t changed since the last build, Docker reuses the cached npm ci layer entirely, skipping a potentially slow reinstall, only re-running it when dependencies actually change. Copying package*.json first, installing, and then copying the rest of the application code is what makes that caching actually work, copying everything at once would invalidate the dependency-install cache on every single code change, even ones that never touched a dependency.
A Multi-Stage Build
# Stage 1: install dependencies and prepare the app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Stage 2: the actual production image
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/src ./src
COPY --from=builder /app/app.js ./
EXPOSE 3000
CMD ["node", "app.js"]
Two FROM lines, two stages, builder installs every dependency, including dev-only tools that might be needed for a build step (bundling, compiling TypeScript, and so on), the final stage starts fresh, and copies over only what’s actually needed to run in production, COPY --from=builder pulling specific files across stage boundaries. The builder stage, and everything only it needed, never becomes part of the final image, keeping it smaller, and reducing what’s exposed if the image is ever compromised.
.dockerignore
node_modules
.env
.env.*
.git
*.test.js
npm-debug.log
Exactly like .gitignore, but for what gets copied into a container image during a build, node_modules doesn’t belong, it gets installed fresh inside the container itself, .env files (Module 1) absolutely don’t belong, an image is often pushed to a shared registry, and a .env baked into it would leak every secret it contains to anyone with access to that image.
Building and Running It
docker build -t notes-api .
docker run -p 3000:3000 --env-file .env.production notes-api
docker build -t notes-api . builds an image, tagged notes-api, from the Dockerfile in the current directory, docker run -p 3000:3000 starts a container from it, mapping the container’s port 3000 to the same port on the host machine, --env-file passes environment variables in, exactly the mechanism Module 1 anticipated, real secrets are never baked into the image itself.
Try It
- Write a Dockerfile for a small Express app, following the layer-ordering pattern above, and build it with
docker build. - Convert it into a multi-stage build, and compare the resulting image size (
docker images) against the single-stage version. - Write a
.dockerignorefile for the same project, and explain, in one or two sentences, what would go wrong if.envwere accidentally included in a built image. - Run the built image with
docker run, passing in environment variables with--env-file, and confirm the application starts correctly inside the container.
Recap
- A Dockerfile defines, layer by layer, how a container image is built, ordering matters, dependencies before application code, for effective build caching.
- A multi-stage build keeps a final production image smaller, discarding anything only needed during the build itself.
.dockerignorekeepsnode_modules,.envfiles, and other unwanted content out of a built image, exactly as.gitignorekeeps them out of version control.
Next lesson: Docker Compose, running an application together with a database or cache locally, with a single command.