← Back to DevBytes

How to Containerize a Node.js Application with Docker

What is Containerization?

Containerization is the process of packaging an application along with its entire runtime environment—dependencies, libraries, configuration files, and binaries—into a single lightweight, executable unit called a container. For Node.js developers, this means bundling your JavaScript application, the Node.js runtime, all node_modules, and any system-level dependencies into a portable image that runs identically on any machine with Docker installed.

Unlike virtual machines, containers share the host operating system's kernel, making them incredibly fast to start (often in milliseconds), memory-efficient, and highly portable across environments—from your development laptop to staging servers and production clusters.

Why Containerizing Your Node.js Application Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Developers often encounter the infamous "it works on my machine" problem. Containerization eliminates this friction entirely. Here's why it matters for Node.js applications specifically:

Step-by-Step: Containerizing a Node.js Application

Let's walk through containerizing a real Node.js application from scratch. We'll build a simple Express.js API, create a Dockerfile, build the image, run the container, and optimize along the way.

1. Setting Up a Sample Node.js Application

First, create a basic Express.js application that serves as our target for containerization. Create a new project directory and initialize it:

mkdir node-docker-demo
cd node-docker-demo
npm init -y

Install Express as our web framework:

npm install express

Create the main application file server.js:

// server.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    status: 'healthy',
    message: 'Node.js app running inside Docker!',
    timestamp: new Date().toISOString(),
    environment: process.env.NODE_ENV || 'development'
  });
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
  console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

Update package.json to include a start script:

{
  "name": "node-docker-demo",
  "version": "1.0.0",
  "description": "A demo Node.js app for Docker containerization",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

Test locally to verify everything works:

npm start
# In another terminal:
curl http://localhost:3000/

2. Creating the Dockerfile

The Dockerfile is a text document containing all the commands a user would execute on the command line to assemble the container image. Create a file named exactly Dockerfile (no extension) in the project root:

# ---- Stage 1: Build ----
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first for better layer caching
COPY package*.json ./

# Install ALL dependencies (including devDependencies) needed for build
RUN npm ci

# Copy the rest of the application source code
COPY server.js ./

# If you have a build step (like TypeScript compilation), run it here
# RUN npm run build

# ---- Stage 2: Production Runtime ----
FROM node:20-alpine AS production

# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only production dependencies from the builder stage
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules

# Copy application code
COPY --from=builder --chown=appuser:appgroup /app/server.js ./server.js
COPY --from=builder --chown=appuser:appgroup /app/package*.json ./

# Switch to non-root user
USER appuser

# Expose the port the app runs on
EXPOSE 3000

# Set environment variables
ENV NODE_ENV=production

# Health check to ensure the container is actually running properly
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Start the application
CMD ["node", "server.js"]

Let's break down what this Dockerfile does:

3. Creating a .dockerignore File

Just like .gitignore, a .dockerignore file prevents unnecessary files from being copied into the Docker build context, speeding up builds and reducing image size. Create .dockerignore in the project root:

node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
.env.*.local
*.md
.DS_Store
.vscode
.idea
coverage
*.log
docker-compose*.yml
Dockerfile
.dockerignore
README.md

4. Building the Docker Image

With the Dockerfile ready, build the image using the docker build command. The -t flag tags the image with a name and optional version:

# Build from the current directory (where Dockerfile resides)
docker build -t node-docker-demo:1.0.0 .

# Verify the image was created
docker images | grep node-docker-demo

You'll see output showing each step of the build process. Docker executes each instruction in order, creating intermediate layers. Thanks to our multi-stage setup, the final image will be lean.

5. Running the Container

Run the container from your newly built image. The -p flag maps host port 3000 to container port 3000, and -d runs it in detached (background) mode:

# Run in detached mode with port mapping
docker run -d --name my-node-app -p 3000:3000 node-docker-demo:1.0.0

# Check running containers
docker ps

# View logs
docker logs my-node-app

# Test the application
curl http://localhost:3000/

# Check the health status
docker inspect --format='{{json .State.Health}}' my-node-app

You should see the JSON response confirming the application is running inside the container:

{
  "status": "healthy",
  "message": "Node.js app running inside Docker!",
  "timestamp": "2025-03-15T10:30:00.000Z",
  "environment": "production"
}

6. Passing Environment Variables

Containers can receive configuration via environment variables, keeping images environment-agnostic:

# Pass environment variables at runtime
docker run -d --name my-node-app-prod \
  -p 3000:3000 \
  -e NODE_ENV=staging \
  -e PORT=3000 \
  -e DATABASE_URL=postgres://user:pass@host:5432/db \
  node-docker-demo:1.0.0

# Or use an env file
docker run -d --name my-node-app-env \
  -p 3000:3000 \
  --env-file .env.production \
  node-docker-demo:1.0.0

7. Container Lifecycle Management

Here are essential commands for managing your running containers day-to-day:

# List all running containers
docker ps

# List all containers (including stopped)
docker ps -a

# Stop a running container
docker stop my-node-app

# Start an existing (stopped) container
docker start my-node-app

# Restart a container
docker restart my-node-app

# Remove a stopped container
docker rm my-node-app

# Stop and remove in one command (force if running)
docker rm -f my-node-app

# View real-time logs
docker logs -f my-node-app

# Execute a command inside a running container (useful for debugging)
docker exec -it my-node-app /bin/sh

# Check resource usage of all containers
docker stats

Using Docker Compose for Multi-Service Setups

Most real-world Node.js applications require companion services like databases, caches, or message brokers. Docker Compose lets you define and run multi-container applications with a single YAML file. Create docker-compose.yml:

version: '3.8'

services:
  # Node.js application service
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: node-app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DB_HOST=postgres-db
      - DB_PORT=5432
      - DB_USER=appuser
      - DB_PASSWORD=securepassword
      - DB_NAME=appdb
      - REDIS_HOST=redis-cache
      - REDIS_PORT=6379
    depends_on:
      postgres-db:
        condition: service_healthy
      redis-cache:
        condition: service_started
    restart: unless-stopped
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  # PostgreSQL database service
  postgres-db:
    image: postgres:16-alpine
    container_name: postgres-db
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=securepassword
      - POSTGRES_DB=appdb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Redis cache service
  redis-cache:
    image: redis:7-alpine
    container_name: redis-cache
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

networks:
  app-network:
    driver: bridge

volumes:
  postgres-data:
    driver: local
  redis-data:
    driver: local

With Docker Compose, the entire stack starts with a single command:

# Build and start all services in detached mode
docker compose up -d

# View logs for all services
docker compose logs -f

# View logs for a specific service
docker compose logs -f app

# Stop all services
docker compose down

# Stop all services and remove volumes (destroys data!)
docker compose down -v

# Rebuild and restart only the app service
docker compose up -d --build app

Best Practices for Containerizing Node.js Applications

1. Use Multi-Stage Builds

Multi-stage builds are perhaps the single most impactful optimization. They allow you to use a full-featured build environment with compilers, TypeScript, and dev dependencies during the build phase, while shipping only the compiled artifacts and production dependencies in the final image. This can reduce image size by 40-70%.

# Example: TypeScript Node.js app with multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
# Compiles TypeScript to JavaScript in /app/dist

FROM node:20-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

2. Use Specific Base Image Tags

Never use the latest tag in production. Always pin to a specific version to ensure reproducible builds:

# Good: pinned to a specific minor version
FROM node:20.11.1-alpine

# Bad: floating tag that can change unexpectedly
FROM node:latest

3. Leverage Docker Layer Caching

Order your Dockerfile instructions from least frequently changed to most frequently changed. Copying package.json and running npm install before copying source code ensures that dependency installation is cached and only re-runs when packages actually change:

# Optimal ordering for caching
COPY package*.json ./
RUN npm ci --only=production
COPY server.js ./

4. Run as a Non-Root User

By default, Docker containers run as the root user. If an attacker escapes the container process, they gain root access on the host. Always switch to a non-privileged user:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

5. Use npm ci Instead of npm install

In CI/CD and Docker builds, always use npm ci. It's faster, respects the exact versions in package-lock.json, and fails immediately if there are discrepancies, ensuring reproducible dependency trees:

RUN npm ci --only=production

6. Implement Proper Signal Handling

Node.js by default doesn't handle OS signals like SIGTERM gracefully. Docker sends SIGTERM when stopping containers, and after a grace period (default 10s), it sends SIGKILL. Ensure your application handles graceful shutdown to complete in-flight requests:

// server.js with graceful shutdown
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// ... route definitions ...

const server = app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

// Graceful shutdown handler
let shuttingDown = false;

process.on('SIGTERM', () => {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log('SIGTERM received. Shutting down gracefully...');
  
  server.close(() => {
    console.log('HTTP server closed. All connections drained.');
    // Close database connections, flush logs, etc.
    process.exit(0);
  });
  
  // Force shutdown after 15 seconds if still running
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 15000);
});

process.on('SIGINT', () => {
  console.log('SIGINT received. Shutting down...');
  process.exit(0);
});

And in the Dockerfile, use the exec form of CMD so the Node.js process receives signals directly (not through a shell):

CMD ["node", "server.js"]  # exec form: PID 1 gets signals
# NOT: CMD node server.js   # shell form: sh gets signals, Node.js doesn't

7. Keep Images Small

Smaller images mean faster pulls, reduced attack surface, and lower storage costs. Key strategies:

# After installing packages, clean cache in the same layer
RUN npm ci --only=production && npm cache clean --force

# Remove temporary files
RUN rm -rf /tmp/* /var/tmp/*

8. Add HEALTHCHECK Instructions

Health checks allow orchestrators to automatically detect and recover from failures. Without them, Docker can only detect if the main process exits, not if it's stuck or corrupted:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "const http = require('http'); \
    const opts = { hostname: 'localhost', port: 3000, path: '/health', timeout: 3000 }; \
    http.get(opts, (res) => process.exit(res.statusCode === 200 ? 0 : 1)) \
      .on('error', () => process.exit(1));"

9. Use Build Arguments for Configurable Builds

Build arguments (ARG) let you parameterize your Dockerfile without hardcoding values:

ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder

ARG APP_HOME=/app
WORKDIR ${APP_HOME}

# Build with: docker build --build-arg NODE_VERSION=18 --build-arg APP_HOME=/opt/app -t my-app .

10. Scan Images for Vulnerabilities

Regularly scan your images for known vulnerabilities. Docker Desktop includes docker scan, or use tools like Trivy, Clair, or Snyk:

# Scan with Docker's built-in scanner (uses Snyk)
docker scan node-docker-demo:1.0.0

# Or with Trivy (open source)
trivy image node-docker-demo:1.0.0

Publishing Your Image to a Registry

To deploy your containerized application to servers or share it with your team, push it to a container registry:

# Tag the image for Docker Hub
docker tag node-docker-demo:1.0.0 yourusername/node-docker-demo:1.0.0
docker tag node-docker-demo:1.0.0 yourusername/node-docker-demo:latest

# Log in to Docker Hub
docker login

# Push both tags
docker push yourusername/node-docker-demo:1.0.0
docker push yourusername/node-docker-demo:latest

# For AWS Elastic Container Registry (ECR)
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com

docker tag node-docker-demo:1.0.0 123456789012.dkr.ecr.us-east-1.amazonaws.com/node-docker-demo:1.0.0
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/node-docker-demo:1.0.0

Common Pitfalls and Troubleshooting

Issue: Container Exits Immediately

If your container starts and exits immediately, check that your CMD process isn't forking to the background. Node.js applications should run in the foreground:

# Correct: Node.js runs in foreground, keeping container alive
CMD ["node", "server.js"]

# Incorrect: Using a process manager that daemonizes and exits
# CMD ["pm2", "start", "server.js", "--no-daemon"]  # Must use --no-daemon flag

Issue: Port Not Reachable

Ensure your application listens on 0.0.0.0 (all interfaces), not 127.0.0.1 (localhost). Express listens on all interfaces by default, but verify:

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server listening on 0.0.0.0:${PORT}`);
});

Issue: node_modules Not Found or Incorrect Version

This often happens when .dockerignore isn't excluding node_modules and they get copied from the host (potentially wrong OS binaries for Alpine). Always let Docker install dependencies fresh inside the build:

# .dockerignore MUST include:
node_modules

Issue: Image is Too Large

Check what's contributing to the size:

# Analyze image layers
docker history node-docker-demo:1.0.0

# Use dive to explore layers interactively
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest node-docker-demo:1.0.0

Conclusion

Containerizing a Node.js application with Docker transforms it from a fragile, environment-dependent codebase into a robust, portable, and scalable deployment artifact. The process begins with a well-crafted Dockerfile that leverages multi-stage builds, Alpine base images, and proper layer ordering for optimal caching. It extends through Docker Compose for multi-service orchestration, and culminates in publishing to a container registry for distribution across environments.

The practices covered here—non-root users, graceful signal handling, health checks, pinned base images, and vulnerability scanning—form the foundation of production-grade containerization. By adopting these techniques, you eliminate environment drift, accelerate deployment pipelines, and position your Node.js applications for seamless orchestration on platforms like Kubernetes. Start with a single Dockerfile today, and watch as the "it works on my machine" problem becomes a distant memory.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles