By Akshay Singh
How to Dockerize a Node.js and Express Application for Production
Deploying a Node.js application directly onto a virtual server often leads to environment discrepancies. Differences in Node runtime versions, global dependencies, or operating system packages between your local machine and the production server can cause unexpected runtime failures.
Docker solves this by packaging the application, runtime, dependencies, and configuration into an immutable, self-contained container image.
However, writing a production-grade Dockerfile requires more than running FROM node and COPY . .. A poorly configured container image can expose security vulnerabilities by running as root, bloat image sizes by shipping build tooling to production, and fail to shut down gracefully under orchestrators like Kubernetes or Docker Swarm.
This guide walks through containerizing a TypeScript and Express application following official Node.js Docker best practices—including multi-stage builds, non-root users, signal forwarding with dumb-init, layer caching, health checks, and Docker Compose.
Anatomy of a Naive vs. Production Dockerfile
Before building the production setup, consider what goes wrong in a typical "naive" Dockerfile:
# ❌ NAIVE DOCKERFILE (DO NOT USE IN PRODUCTION)
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Why This Fails in Production:
- Runs as Root: By default, containers execute as the
rootuser (UID 0). If an attacker exploits an application vulnerability, they gain root-level permissions inside the container, increasing the risk of a container breakout. - Busts Layer Caching: Copying all files (
COPY . .) before runningnpm installinvalidates Docker's build cache every time any source code file changes, forcing a slownpm installon every single build. - Bloats Image Size: Build dependencies (TypeScript compiler, test runners, linter packages) remain inside the final production image.
- Uses
npm startas PID 1:npmdoes not forward kernel signals (such asSIGTERMorSIGINT) to the underlying Node.js process, preventing graceful shutdowns and orphan process reaping.
The Production Multi-Stage Dockerfile
A multi-stage build separates the environment used to compile TypeScript from the lean runtime environment that runs in production.
Here is the production-ready Dockerfile for a Node.js Express application:
# ==========================================
# Stage 1: Dependencies Cache
# ==========================================
FROM node:24-alpine AS deps
WORKDIR /usr/src/app
# Install dependencies required for native builds if needed
RUN apk add --no-cache libc6-compat
# Copy package manifests first for optimal layer caching
COPY package.json package-lock.json ./
# Install ALL dependencies (including devDependencies for building)
RUN npm ci
# ==========================================
# Stage 2: Application Builder
# ==========================================
FROM node:24-alpine AS builder
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
# Compile TypeScript into production JavaScript in dist/
ENV NODE_ENV=production
RUN npm run build
# Prune devDependencies to keep only production packages
RUN npm prune --production
# ==========================================
# Stage 3: Production Runtime
# ==========================================
FROM node:24-alpine AS runner
WORKDIR /usr/src/app
# 1. Install dumb-init for proper PID 1 signal forwarding
RUN apk add --no-cache dumb-init
# 2. Set production environment
ENV NODE_ENV=production
ENV PORT=3000
# 3. Copy production dependencies and compiled build artifacts
COPY --from=builder --chown=node:node /usr/src/app/node_modules ./node_modules
COPY --from=builder --chown=node:node /usr/src/app/dist ./dist
COPY --from=builder --chown=node:node /usr/src/app/package.json ./package.json
# 4. Switch to non-root user provided by the official Node Alpine image
USER node
# 5. Expose application port
EXPOSE 3000
# 6. Container Health Check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# 7. Use dumb-init as entrypoint to manage process signals
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# 8. Start the application directly with node (not npm)
CMD ["node", "dist/index.js"]
Step-by-Step Breakdown of Key Optimizations
1. Layer Caching with package.json First
Docker builds images in layers. If a layer and all preceding layers haven't changed, Docker reuses the cached layer:
COPY package.json package-lock.json ./
RUN npm ci
By copying only dependency manifests before copying the rest of your source code (COPY . .), Docker caches the node_modules layer. Edits to your application code will build in seconds because npm ci is skipped entirely unless package-lock.json changes.
2. Solving the PID 1 Problem with dumb-init
In Unix systems, the process with PID 1 is the init system. PID 1 has special responsibilities:
- Forwarding signals (like
SIGTERMwhen Docker stops a container) to child processes. - Reaping "zombie" processes when child processes terminate.
Node.js was not designed to run as an init system. When run as PID 1, it ignores default signal handlers. If you issue docker stop, Docker sends SIGTERM, Node ignores it, and after 10 seconds Docker forcefully terminates your app with SIGKILL—instantly dropping active database queries and HTTP connections.
Using dumb-init as the ENTRYPOINT acts as a lightweight process supervisor that properly forwards SIGTERM to your Node.js process and reaps orphaned processes.
3. Non-Root Security (USER node)
The official Node.js Docker images include a pre-configured unprivileged user named node (UID 1000, GID 1000). Switching to USER node ensures that even if an attacker finds an arbitrary file upload or code execution vulnerability in an application route, they cannot modify system packages or access host resources.
COPY --from=builder --chown=node:node /usr/src/app/dist ./dist
USER node
The .dockerignore File
To keep build contexts fast and prevent sensitive secrets from being baked into the Docker image, always create a .dockerignore file in the root of your project:
# .dockerignore
node_modules
npm-debug.log
dist
build
.git
.gitignore
.env
.env.*
.DS_Store
coverage
*.md
docker-compose*.yml
Dockerfile*
.vscode
.idea
Implementing Graceful Shutdown in Express
Even with dumb-init, your Node.js application must explicitly listen for SIGTERM and SIGINT signals to close open HTTP connections and release database connection pools gracefully:
// src/index.ts
import express, { Request, Response } from "express";
import http from "http";
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
// Health check endpoint for Docker HEALTHCHECK
app.get("/health", (req: Request, res: Response) => {
res.status(200).json({ status: "healthy", timestamp: new Date().toISOString() });
});
app.get("/api/v1/data", (req: Request, res: Response) => {
res.json({ message: "Hello from production Docker container!" });
});
const server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
// ==========================================
// Graceful Shutdown Handler
// ==========================================
let isShuttingDown = false;
function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
if (isShuttingDown) return;
isShuttingDown = true;
// Set timeout to force exit if hanging requests don't finish within 10 seconds
const forceExit = setTimeout(() => {
console.error("Forceful shutdown triggered after timeout.");
process.exit(1);
}, 10000);
forceExit.unref();
// Stop accepting new connections and finish in-flight requests
server.close(async (err) => {
if (err) {
console.error("Error while closing HTTP server:", err);
process.exit(1);
}
try {
// Close database connection pools (e.g., pg.Pool or Prisma)
// await pool.end();
// await prisma.$disconnect();
console.log("Database connections closed cleanly.");
console.log("Graceful shutdown complete. Exiting process.");
process.exit(0);
} catch (dbErr) {
console.error("Error closing database connections:", dbErr);
process.exit(1);
}
});
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
If your application connects to a database, follow our guide on How to Connect PostgreSQL to Node.js Using pg and Prisma to ensure your connection pool handles draining properly during shutdown.
Multi-Container Setup with Docker Compose
In local development and staging environments, use Docker Compose to coordinate your Express application with services like PostgreSQL and Redis:
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: express_api
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://app_user:app_password@db:5432/production_db
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- backend_network
db:
image: postgres:16-alpine
container_name: postgres_db
restart: unless-stopped
environment:
POSTGRES_DB: production_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d production_db"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend_network
cache:
image: redis:7-alpine
container_name: redis_cache
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- backend_network
networks:
backend_network:
driver: bridge
volumes:
postgres_data:
redis_data:
Running the Stack:
# Build and start all services in the background
docker compose up -d --build
# View container logs
docker compose logs -f app
# Stop the stack cleanly
docker compose down
Production Security & Deployment Checklist
When deploying containerized Node.js applications to a Linux VPS (such as behind an Nginx reverse proxy with SSL or Apache):
- Pin Specific Image Digests: Use exact version tags (e.g.,
node:24-alpineor SHA digests) rather thanlatestto ensure reproducible builds. - Never Store Secrets in the Dockerfile: Pass credentials via environment variables at runtime (
docker run -e DATABASE_URL=...or Docker Compose secrets). - Run as
USER node: Ensure your files are owned bynode:nodeand the user is switched beforeCMD. - Enforce Port Security: Do not expose the container directly to the public internet on raw ports. Bind the container to
127.0.0.1:3000and route external traffic through Nginx with a Linux UFW firewall. - Scan Images for Vulnerabilities: Run vulnerability scanners regularly during CI/CD:
docker scout cves express-api:latest
Common Errors and Troubleshooting
1. Error: Cannot find module '/usr/src/app/dist/index.js'
- Cause: The
WORKDIRin the runner stage does not match the paths used inCOPY --from=builder. - Fix: Ensure
WORKDIR /usr/src/appis declared consistently across all stages, or verify that your TypeScript build command outputs todist/.
2. connect ECONNREFUSED 127.0.0.1:5432 Inside Docker
- Cause: Inside a container,
127.0.0.1points to the container itself, not the host machine or other containers. - Fix: When using Docker Compose, use the service name as the hostname (e.g.,
DATABASE_URL=postgresql://app_user:pass@db:5432/production_db).
3. Container Exiting with Code 137
- Cause: Out of Memory (OOM) error where the host kernel killed the container for exceeding its allocated memory limit.
- Fix: Inspect memory limits in Docker Compose, configure Node.js max old space size (
NODE_OPTIONS="--max-old-space-size=512"), and avoid memory leaks.
Keep Learning
- Deploying your container to a Linux server? Follow our step-by-step tutorial on How to Host an Express App on a VPS with Nginx.
- Need a database for your containerized app? Read How to Connect PostgreSQL to Node.js Using pg and Prisma.
- Securing your VPS firewall? Check our Linux UFW Firewall Setup Guide.

Written by Akshay Singh
Full-stack engineer and technical writer at TheDailyDevs. Specializing in practical, production-ready guides for JavaScript, React, Next.js, Node.js, and DevOps infrastructure.
