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.
A minimal Express server, server.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.
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:
npm install.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.
docker build -t node-api .
docker run -d -p 3000:3000 --name api node-api
curl http://localhost:3000/healthMost APIs need persistence. Here is a compose.yaml pairing the API with Postgres:
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:docker compose up -d --buildThe API connects to db by name, waits for the healthcheck to pass, and the data lives in a named volume.
For development, bind-mount the source and run a file-watching dev server by overriding the command:
api:
build: .
command: npx nodemon server.js
volumes:
- ./:/app
- /app/node_modulesThe 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.
npm ci for cached, reproducible dependency layers.node user and start with node, not npm, for proper signals.Next up: Image Optimization and Multi-Stage Builds, where this image gets dramatically smaller and faster.