This episode packages a Node.js application into a container: a slim multi-stage Dockerfile, .dockerignore, production dependencies with npm ci, running the image with docker run, and simple orchestration with docker compose.

"It works on my machine" is every team's nightmare. Containers solve it: the application is packaged together with its runtime, dependencies, and configuration so it runs identically on a laptop, a staging server, and the cloud. Docker is the most common way to do this.
Episode 21 packages the Node.js application you've built into a container: writing a multi-stage Dockerfile, cleaning up the build context with .dockerignore, installing production dependencies with npm ci, running the image, and managing services with docker compose.
A multi-stage Dockerfile separates the build stage from the runtime stage. The build stage holds everything needed to install dependencies, while the runtime stage only carries the results — the image ends up smaller and safer:
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/app.js ./
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "app.js"]node:22-alpine is the official lightweight base image. The build stage installs dependencies with npm ci, then the runtime stage copies over the production node_modules. USER node runs the application as a non-root user — a security practice from episode 20. EXPOSE 3000 is documentary; the actual port mapping happens with docker run -p 3000:3000.
The order COPY package.json package-lock.json ./, then RUN npm ci, then COPY . . is no coincidence. Docker caches each layer: as long as package.json and the lockfile don't change, the npm ci layer is reused without a reinstall — subsequent builds become much faster.
The build context is the entire set of files sent to Docker. Without filtering, folders like node_modules and .env get sent along — large and risky. Restrict it with .dockerignore:
node_modules
.env
.git
*.log
coverageEvery line in .dockerignore excludes a file or folder from the build context. node_modules is excluded because it's reinstalled inside the image by npm ci, and .env is excluded so secrets aren't baked into the image.
Use npm ci inside the Dockerfile, not npm install. npm ci installs dependencies exactly per the lockfile without modifying it — deterministic, faster, and fails if the lockfile doesn't match package.json. This is the right choice for reproducible builds.
Once the Dockerfile is ready, build the image and run it:
docker build -t api-nodejs .
docker run -d -p 3000:3000 --name api api-nodejs
docker logs apidocker build -t api-nodejs . names the image api-nodejs. docker run -d -p 3000:3000 runs it in the background and maps host port 3000 to container port 3000. Verify with curl http://localhost:3000 and inspect the output via docker logs api.
Manage the container lifecycle with simple commands:
docker stop api
docker rm api
docker imagesdocker stop api sends a stop signal (which the application uses for graceful shutdown — episode 22), docker rm api removes the container, and docker images lists stored images. For unused images, docker image prune cleans them up.
Real applications don't run alone — they need a database, cache, and other services. Docker Compose describes all services in a single YAML file and runs them together:
services:
api:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgresql://app:rahasia@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: rahasia
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:The file above defines two services: api, built from the Dockerfile, and db, using the PostgreSQL image. DATABASE_URL points to the host db — Compose creates an internal network so services find each other by name. The named volume db-data keeps database data intact even when the container is recreated.
docker compose up -d
docker compose ps
docker compose logs -f apidocker compose up -d builds the images and starts all services in the background. docker compose ps shows the status, and docker compose logs -f api follows the API logs in real time. The stack is stopped with docker compose down.
Containers make deployment to any server easy: push the image to a registry, pull it on the server, then run it with Compose. In simple terms:
In episode 22, all these steps will be automated by a CI/CD pipeline so every push to the main branch is deployed directly.
Here's what to take away:
npm ci installs dependencies exactly per the lockfile..dockerignore excludes node_modules and .env from the build.USER node runs the application as a non-root user.In the next episode, episode 22 — the last episode of this series — we'll discuss CI/CD, monitoring, and observability — GitHub Actions pipelines, release automation, structured logs and metrics, health checks, and summarizing everything to build a production-ready, maintainable Node.js application.