DEV Community

Peon Sh
Peon Sh

Posted on Originally published at peon.sh

Deploying a Node.js App with Docker: A Production Checklist

From Dockerfile to health checks: everything you need to run Node.js in production containers on your own server.

The production-grade Dockerfile
Most Node Dockerfile problems come from copying a development setup into production. The production image should contain your code, production dependencies and nothing else, and it should not run as root:

  • npm ci, not npm install: reproducible installs from the lockfile, and it fails loudly when the lockfile is stale
  • NODE_ENV=production: many libraries (Express included) enable significant optimizations based on it
  • USER node: the official images ship a non-root user; using it limits the blast radius of any container escape
  • Add a .dockerignore with node_modules, .git, .env and build output, smaller context, faster builds, no leaked secrets

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]

Signals: the bug everyone ships once
When a deploy replaces your container, Docker sends SIGTERM and waits (10 seconds by default) before SIGKILL. Two things go wrong in default setups. First, CMD ["npm", "start"] makes npm PID 1, and npm does not forward signals to your process, so your app never hears SIGTERM and gets hard-killed mid-request. Always exec node directly.

Second, even when the signal arrives, the default behaviour is instant exit, dropping in-flight requests. Add a graceful shutdown handler:

const server = app.listen(3000);
process.on('SIGTERM', () => {
server.close(() => process.exit(0)); // stop accepting, finish in-flight
setTimeout(() => process.exit(1), 8000).unref(); // safety valve
});

Health checks that mean something
A health endpoint that returns 200 unconditionally only proves the process exists. A useful one verifies the app can serve: event loop responsive, critical dependencies reachable. Keep it cheap enough to call every ten seconds, and never put it behind auth.

Wire it into the container so the platform can gate rollouts and auto-restart wedged containers: with a health check defined, a zero-downtime deploy only switches traffic once the new container actually works, and a container that stops responding gets replaced instead of silently serving errors.

Logging and configuration
Log to stdout in JSON (pino is the standard choice) and let the runtime collect it; never write log files inside the container. Read configuration exclusively from environment variables, validate it at boot with a schema (envalid or zod), and crash immediately on missing values, a config error at deploy time is a footnote; the same error discovered at 3 a.m. is an incident.

The deployment pipeline
With the image solid, the pipeline is the platform’s job. Push to your branch and Peon builds on the server, injects environment variables (encrypted at rest), performs the health-checked container swap and streams logs to the dashboard. Workers deploy as a second service from the same repo with a different start command, and rollback re-points at the previous image in seconds.

Pre-launch checklist

  • CMD execs node directly (signals reach your process)
  • Graceful shutdown on SIGTERM tested locally with docker stop
  • Health endpoint checks dependencies, wired into the container healthcheck
  • NODE_ENV=production and config validated at boot
  • Logs are JSON on stdout; log rotation configured on the host daemon
  • Memory ceiling known (node --max-old-space-size set relative to container limit)

Top comments (0)