This tutorial takes an actual small Express API and containerizes it properly — not just a "hello world" Dockerfile, but the kind of setup you would genuinely deploy.

1. The project being containerized

// package.json
{
  "name": "notes-api",
  "version": "1.0.0",
  "scripts": { "start": "node index.js" },
  "dependencies": { "express": "^4.19.2" }
}

// index.js
const express = require("express");
const app = express();
app.use(express.json());

let notes = [{ id: 1, text: "Buy milk" }];

app.get("/notes", (req, res) => res.json(notes));
app.post("/notes", (req, res) => {
  const note = { id: Date.now(), text: req.body.text };
  notes.push(note);
  res.status(201).json(note);
});
app.get("/health", (req, res) => res.send("ok"));

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Listening on ${port}`));

2. The Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s 
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "index.js"]

Choices worth explaining

  • npm ci instead of npm install — ci installs exact versions straight from package-lock.json and fails if the lockfile and package.json disagree, which is exactly the strict, reproducible behaviour you want for a build that might run on a CI server or a teammate's machine.
  • --omit=dev — skips devDependencies like test runners and linters, since none of that is needed to actually run the app in production, keeping the image smaller.
  • ENV NODE_ENV=production — many libraries, Express included, change their behaviour (better performance, less verbose errors) when this is set.
  • HEALTHCHECK — tells Docker how to actively check whether the app inside the container is actually healthy, not just "running." Orchestrators like Docker Swarm or a load balancer can use this to stop sending traffic to a container that is up but not responding correctly.

3. A .dockerignore that actually matters here

node_modules
npm-debug.log
.git
.env
*.md

Excluding node_modules matters twice over: it keeps the build context small and fast to send to the Docker daemon, and it guarantees the dependencies actually installed are the ones npm ci installs fresh inside the image for the correct Linux/alpine platform, not whatever happens to be sitting in your local folder from your own OS.

4. Passing real configuration in

docker build -t notes-api .
docker run -d -p 3000:3000 -e PORT=3000 --name notes notes-api

The app already reads process.env.PORT, so the exact same image can be told to listen on a different port in different environments without touching a single line of code — this is the same reason the Dockerfile in this very project reads PORT from the environment for Spring Boot, a pattern common to virtually every container platform (Cloud Run, Heroku, Render and friends all inject a PORT variable at runtime).

5. Verifying it for real

curl http://localhost:3000/health
curl http://localhost:3000/notes
curl -X POST http://localhost:3000/notes -H "Content-Type: application/json" -d "{"text":"Walk the dog"}"

This is a genuinely production-shaped Dockerfile: a locked dependency install, a smaller image via --omit=dev, externalized configuration, and a real health check — not just something that happens to boot on your own laptop.