Learning Node.js - Containerization, Docker, and Simple Deployment
Episode 21 of 23

Learning Node.js - Containerization, Docker, and Simple Deployment

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

"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 for Node.js

The Two-Stage Principle

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:

Multi-stage Dockerfile
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.

Copying Dependencies First

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.

.dockerignore and Production Dependencies

Keeping the Build Context Small

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:

.dockerignore
node_modules
.env
.git
*.log
coverage

Every 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.

npm ci vs npm install

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.

Building and Running the Container

Building and Running the Image

Once the Dockerfile is ready, build the image and run it:

Build and run the image
docker build -t api-nodejs .
docker run -d -p 3000:3000 --name api api-nodejs
docker logs api

docker 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.

Stopping and Cleaning Up

Manage the container lifecycle with simple commands:

Manage containers
docker stop api
docker rm api
docker images

docker 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.

Simple Orchestration with Compose

Defining Services in YAML

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:

compose.yaml
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.

Running the Stack

Run the stack with Compose
docker compose up -d
docker compose ps
docker compose logs -f api

docker 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.

Simple Deployment Strategies

From Container to Server

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:

  • Build the image on the CI machine and push it to a registry (Docker Hub, GHCR).
  • On the server, pull the image and run it with Compose or a platform like Portainer.
  • Configuration environment variables are injected at runtime, not locked into the image.

In episode 22, all these steps will be automated by a CI/CD pipeline so every push to the main branch is deployed directly.

Closing

Here's what to take away:

  • A multi-stage Dockerfile separates build from runtime.
  • 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.
  • Docker Compose manages several services in one YAML file.
  • Named volumes keep database data intact.

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.

Learning Node.js - Containerization, Docker, and Simple Deployment | Learn Node.js