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 ciinstalls exactly what’s in the lockfile — reproducible builds.- Copying
package*.jsonfirst 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 receivesSIGTERM. AvoidCMD npm start: npm doesn’t reliably forward signals, sodocker stopwaits 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
-
.dockerignoreexcludesnode_modules,.env,.git -
npm ci, lockfile committed - Dev dependencies not in the final image
- Runs as
node, not root -
CMDin 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.