Dockerizing a Node.js Application

intermediate
14 min

Dockerizing a Node.js Application

Time to put everything together. In this hands-on lesson you will take a small Express API from plain source code to a containerized app with a database, runnable anywhere with one command. Along the way we apply the Dockerfile, caching, environment, and Compose techniques from earlier lessons.

The Application

A minimal Express server, server.js:

js
const express = require("express"); const app = express(); const port = process.env.PORT || 3000; app.get("/health", (req, res) => res.json({ status: "ok" })); app.get("/", (req, res) => res.json({ message: "Hello from Docker!" })); app.listen(port, () => console.log(`Listening on ${port}`));

Note two container-friendly habits already present: the port comes from the environment, and logs go to stdout.

The Dockerfile

dockerfile
FROM node:20-alpine WORKDIR /app # Install dependencies first to maximize layer caching COPY package*.json ./ RUN npm ci --omit=dev # Then copy the source COPY . . ENV NODE_ENV=production EXPOSE 3000 USER node CMD ["node", "server.js"]

Decisions worth noting:

  • node:20-alpine keeps the base small (about 40 MB vs 400 MB for the full image).
  • npm ci installs exactly what the lockfile specifies - reproducible builds, unlike npm install.
  • COPY package.json first* means editing source code does not invalidate the dependency layer; rebuilds take seconds.
  • USER node drops root privileges using the account built into the official image.
  • CMD runs node directly, not npm start - npm swallows shutdown signals, while node receives SIGTERM properly for graceful stops.

Add a .dockerignore:

node_modules npm-debug.log .git .env

Excluding node_modules matters: dependencies must be installed inside the image for the right platform, not copied from your host.

Build and Run

bash
docker build -t node-api . docker run -d -p 3000:3000 --name api node-api curl http://localhost:3000/health

Adding a Database with Compose

Most APIs need persistence. Here is a compose.yaml pairing the API with Postgres:

yaml
services: api: build: . ports: - "3000:3000" environment: DATABASE_URL: postgres://app:devpass@db:5432/appdb depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: devpass POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s retries: 5 volumes: pgdata:
bash
docker compose up -d --build

The API connects to db by name, waits for the healthcheck to pass, and the data lives in a named volume.

A Development Setup with Live Reload

For development, bind-mount the source and run a file-watching dev server by overriding the command:

yaml
api: build: . command: npx nodemon server.js volumes: - ./:/app - /app/node_modules

The second, anonymous volume shields the image-installed node_modules from being hidden by the bind mount - a classic Node-on-Docker trick. Save a file on your host and nodemon restarts the server inside the container instantly.

Key Takeaways

  • Read PORT and secrets from the environment and log to stdout - containers expect it.
  • Copy lockfiles first and use npm ci for cached, reproducible dependency layers.
  • Run as the non-root node user and start with node, not npm, for proper signals.
  • Compose plus a healthcheck gives you an API + database stack in one command.
  • Bind mounts plus nodemon deliver instant reload in development without rebuilding.

Next up: Image Optimization and Multi-Stage Builds, where this image gets dramatically smaller and faster.