Dockerising a Node Service
Package your service into a container image with a multi-stage Dockerfile, and order the steps so npm ci stays cached.
docker build -t orders-api:1.0.0 . && docker run --rm -p 3000:3000 -e PORT=3000 orders-api:1.0.0A container image is a packaged filesystem: your code, the Node binary, and nothing you did not ask for. A container is one running copy of that image. It is an ordinary operating-system process that can see only the files in the image. A Dockerfile is the recipe, and docker build turns it into an image.
Every instruction is a layer
Docker stores the result of each instruction as a layer and reuses it on the next build if that instruction and the files it reads have not changed. Once one layer misses, every layer after it is rebuilt. That is why COPY package*.json ./ and RUN npm ci come before COPY . .. Editing a route file changes your source but not your lock file, so the install layer is reused and the rebuild takes seconds instead of minutes.
Two stages in one file
A multi-stage build puts several FROM lines in one Dockerfile. The first stage installs everything and runs the build. The second starts from a clean base and copies across only the finished files with COPY --from=builder. Compilers, dev dependencies and npm caches stay behind in the first stage and never ship.
Write the start command as an array
CMD ["node", "server.js"] runs Node directly. CMD node server.js is the same command handed to a shell, so the shell becomes the container's main process. The graceful shutdown lesson explains why that difference decides whether your service ever gets told to stop.
Example
When to use it
- Hand a reviewer one command that runs your API with the exact Node version you tested against.
- Stop a one-line source edit from triggering a full dependency install on every CI build.
- Keep TypeScript, test fixtures and build caches out of the image you deploy.
- Produce an image tagged with a release version that a deploy can roll back to.
More examples
A two-stage Dockerfile
The shape to reach for once a project has a build step. npm ci is run twice on purpose: once with dev dependencies so the build can run, once with --omit=dev so only runtime packages land in the shipped image. Reach for this whenever your repository contains TypeScript, a bundler, or any generated output.
# Stage one: install everything and build.
FROM node:24-alpine AS builder
WORKDIR /app
# Only the manifest and lock file, so this layer survives source edits.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage two: a clean base that never saw the build tools.
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# EXPOSE documents the port; it does not publish it. -p does that.
EXPOSE 3000
CMD ["node", "dist/server.js"].dockerignore
This file sits next to the Dockerfile and works like .gitignore, but for the build. It keeps the build fast, keeps host-built binaries out of the image, and stops a local .env file with real credentials from being baked into a layer that anyone who pulls the image can read.
# .dockerignore - files COPY must never pick up.
node_modules
npm-debug.log
.git
.env
.env.*
coverage
dist
Dockerfile
compose.yamlBuild, run, and look inside
The loop you run while getting a Dockerfile right. docker history is the quickest way to find the instruction that added 300 MB, because it prints the size next to the command that produced it.
# Build an image and give it a name and a version tag.
docker build -t orders-api:1.0.0 .
# Run it. -p maps host port 3000 to container port 3000.
# --rm deletes the container when it stops. -e sets an environment variable.
docker run --rm -p 3000:3000 -e PORT=3000 orders-api:1.0.0
# Open a shell in a running container to see what actually got copied.
docker exec -it <container-id> sh
# See how big each layer is, and which instruction created it.
docker history orders-api:1.0.0
Discussion