Putting a Node.js app in a container takes five lines; making it production-ready takes a few more. This guide builds a Dockerfile that’s small, cache-friendly, secure and shuts down cleanly — then runs it with Docker Compose next to PostgreSQL.

The app

Assume an Express (or Fastify, Nest…) app with package.json, package-lock.json, and a server listening on process.env.PORT. If it has a build step (TypeScript, bundling), the multi-stage version below handles it.

.dockerignore

node_modules
npm-debug.log
dist
.git
.env
Dockerfile
compose.yaml

Never send your local node_modules into the build — it may contain binaries for the wrong platform.

The Dockerfile

# syntax=docker/dockerfile:1

# ---- Dependencies (including dev, for building) ----
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

# ---- Build ----
FROM deps AS build
COPY . .
RUN npm run build --if-present

# ---- Production dependencies only ----
FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# ---- Runtime ----
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
  CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]

Without a build step, drop the build stage and copy your source (COPY --chown=node:node . .) instead of dist.

Why each part matters:

  • npm ci installs exactly what’s in the lockfile — reproducible builds.
  • Copying package*.json first keeps the dependency layer cached until dependencies change — see Docker build cache.
  • Separate production dependencies keep TypeScript, test frameworks and bundlers out of the final image.
  • USER node — the official image’s unprivileged user. Don’t run as root.
  • CMD ["node", …] in exec form — Node runs as PID 1 and receives SIGTERM. Avoid CMD npm start: npm doesn’t reliably forward signals, so docker stop waits 10 seconds and then kills your app.
  • The health check uses Node’s built-in fetch (Node 18+), so no curl is needed in the image.

-slim (Debian) avoids most native-module surprises. Alpine images are smaller but use musl libc, which some packages with prebuilt binaries don’t support.

Graceful shutdown

Handle SIGTERM so in-flight requests finish and connections close:

const server = app.listen(process.env.PORT || 3000);

process.on('SIGTERM', () => {
  server.close(() => {
    db.end?.();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();
});

If your app spawns child processes or you can’t control signal handling, add --init to docker run (or init: true in Compose) to use a tiny init process as PID 1.

Configuration

Read config from environment variables (process.env.DATABASE_URL), not baked-in files. Bind to 0.0.0.0 inside the container — 127.0.0.1 would make the app unreachable from outside the container.

Build and run

docker build -t myapp:1.0 .
docker run -d --name myapp -p 127.0.0.1:3000:3000 --env-file .env myapp:1.0
curl http://127.0.0.1:3000/health

Compose with PostgreSQL

services:
  app:
    build: .
    image: myapp:local
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      PORT: "3000"
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

volumes:
  db-data:

Put Nginx or Caddy in front for HTTPS — see Caddy with Docker.

Development vs production

For local development, mount the source and run a watcher in a compose.override.yaml; keep the production Dockerfile lean. Don’t use nodemon in production images.

Checklist

  • .dockerignore excludes node_modules, .env, .git
  • npm ci, lockfile committed
  • Dev dependencies not in the final image
  • Runs as node, not root
  • CMD in exec form; SIGTERM handled
  • Listens on 0.0.0.0; config via env vars
  • Health check
  • Pinned base image major version

Related: Dockerfile tutorial, multi-stage builds and PM2 vs containers.

  • Docker
  • Node.js
  • Deployment