3D visualization of Docker containerizing a Node.js and Express application with multi-stage builds and security layers
DevOps14 min read

By Akshay Singh

Share this article:

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:

  1. Runs as Root: By default, containers execute as the root user (UID 0). If an attacker exploits an application vulnerability, they gain root-level permissions inside the container, increasing the risk of a container breakout.
  2. Busts Layer Caching: Copying all files (COPY . .) before running npm install invalidates Docker's build cache every time any source code file changes, forcing a slow npm install on every single build.
  3. Bloats Image Size: Build dependencies (TypeScript compiler, test runners, linter packages) remain inside the final production image.
  4. Uses npm start as PID 1: npm does not forward kernel signals (such as SIGTERM or SIGINT) 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 SIGTERM when 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):

  1. Pin Specific Image Digests: Use exact version tags (e.g., node:24-alpine or SHA digests) rather than latest to ensure reproducible builds.
  2. Never Store Secrets in the Dockerfile: Pass credentials via environment variables at runtime (docker run -e DATABASE_URL=... or Docker Compose secrets).
  3. Run as USER node: Ensure your files are owned by node:node and the user is switched before CMD.
  4. Enforce Port Security: Do not expose the container directly to the public internet on raw ports. Bind the container to 127.0.0.1:3000 and route external traffic through Nginx with a Linux UFW firewall.
  5. 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 WORKDIR in the runner stage does not match the paths used in COPY --from=builder.
  • Fix: Ensure WORKDIR /usr/src/app is declared consistently across all stages, or verify that your TypeScript build command outputs to dist/.

2. connect ECONNREFUSED 127.0.0.1:5432 Inside Docker

  • Cause: Inside a container, 127.0.0.1 points 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

Akshay Singh — Author

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.

TheDailyDevsTheDailyDevs
TheDailyDevs is a developer-first blog and knowledge hub created by passionate engineers to share real-world development tips, deep-dive tutorials, industry insights, and hands-on solutions to everyday coding challenges. Whether you're building apps, exploring new frameworks, or leveling up your dev game, you'll find practical, no-fluff content here, updated daily.