Docker from Basics to Production · Containerize Real Applications

Containerize a Node.js Service

Package a Node.js service with npm ci, production dependencies only, the node user and proper signal handling.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A Node.js HTTP service in an image built from node:24-alpine, installing only production dependencies with a reproducible npm ci, running as the built-in node user and shutting down cleanly on SIGTERM.

The idea

  • npm ci, not npm install: it installs exactly what package-lock.json says and fails if the lockfile is out of date, so builds are reproducible.
  • --omit=dev skips test and build tooling in the runtime image. If you need a build step (TypeScript, bundlers), do it in a separate build stage.
  • The official Node images include a non-root node user (UID 1000).
  • Node doesn't install a SIGTERM handler by default; as PID 1 it simply ignores the signal. Handle it: stop accepting connections, finish in-flight requests, exit.
  • Set NODE_ENV=production: many libraries switch to faster, quieter behaviour.

The code

A Node service that stops in milliseconds, not 10 seconds
trap 'docker rm -f rc-node > /dev/null 2>&1; docker image rm -f rc-node:1 > /dev/null 2>&1' EXIT
cat > package.json <<'EOF'
{ "name": "orders", "version": "1.0.0", "private": true, "type": "module", "main": "server.js" }
EOF
cat > package-lock.json <<'EOF'
{ "name": "orders", "version": "1.0.0", "lockfileVersion": 3, "requires": true,
  "packages": { "": { "name": "orders", "version": "1.0.0" } } }
EOF
cat > server.js <<'EOF'
import http from "node:http";
const server = http.createServer((req, res) => res.end(JSON.stringify({ ok: true, env: process.env.NODE_ENV })));
server.listen(3000, () => console.log("listening on 3000"));
process.on("SIGTERM", () => {
  console.log("SIGTERM received: closing server");
  server.close(() => process.exit(0));
});
EOF
cat > Dockerfile <<'EOF'
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
USER node
CMD ["node", "server.js"]
EOF
docker build -q -t rc-node:1 . > /dev/null
docker run -d --name rc-node rc-node:1 > /dev/null
sleep 1
start=$(date +%s%N); docker stop rc-node > /dev/null; end=$(date +%s%N)
docker logs rc-node
ms=$(( (end - start) / 1000000 ))
[ "$ms" -lt 3000 ] && echo "docker stop finished in under 3 s (no 10 s timeout)"
echo "exit code: $(docker inspect rc-node --format '{{.State.ExitCode}}'), ran as: $(docker image inspect rc-node:1 --format '{{.Config.User}}')"

Output

listening on 3000
SIGTERM received: closing server
docker stop finished in under 3 s (no 10 s timeout)
exit code: 0, ran as: node

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • npm install in the image, which can update the lockfile and produce different trees on every build.
  • Copying node_modules from the host (wrong platform binaries); add it to .dockerignore.
  • No SIGTERM handler: every deploy and scale-down waits for the 10 to 30 second kill timeout and drops connections.
  • Using npm start as the command: npm becomes PID 1 and older versions don't forward signals; call node directly.

Key points

  • npm ci --omit=dev with a committed lockfile for reproducible, lean images.
  • Run as the node user, set NODE_ENV=production.
  • Handle SIGTERM and start node directly.

Further reading: npm ci · Node.js Docker best practices

Practice questions

Why use npm ci instead of npm install in a Dockerfile?

Answer

npm ci installs exactly the versions in package-lock.json and fails if it doesn't match package.json, giving reproducible builds; npm install may modify the lockfile and resolve different versions.

What happens if a Node.js PID 1 process has no SIGTERM handler?

Answer

Node ignores the signal as PID 1, so docker stop or Kubernetes waits for the grace period and then kills it with SIGKILL, dropping in-flight requests.

Why shouldn't node_modules be copied from the host into the image?

Answer

It may contain native binaries built for the host's OS and architecture, dev dependencies, and stale packages; install inside the image with npm ci and exclude node_modules via .dockerignore.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?