Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Deployment & Best Practices

You’ve built your Node.js application, and it works perfectly on your machine. But running in production is a completely different challenge. Production environments face real-world constraints: thousands of concurrent users, security threats, server crashes, and the need for zero-downtime updates. The gap between “it works on my machine” and “it works in production” is where most Node.js projects fail. A senior engineer would tell you that writing the application is only about 30% of the work—the other 70% is making it reliable, observable, secure, and deployable. This chapter covers that 70%.

Development vs. Production

Understanding the difference is crucial:
AspectDevelopmentProduction
Error messagesDetailed stack tracesGeneric user-friendly messages
PerformanceNot optimizedMaximized, gzip compression
SecurityRelaxed (localhost)Hardened, HTTPS required
LoggingConsole outputStructured logs, monitoring
RestartsManualAutomatic recovery on crash
ConfigurationHardcoded values OKEnvironment variables only

Why Production Best Practices Matter

Real-World Failures

Ignoring production best practices leads to:
  • Security breaches: Hardcoded API keys leaked in public repositories
  • Downtime: Single process crashes take down entire application
  • Data loss: No proper error handling or logging
  • Poor performance: Uncompressed responses, inefficient code
A single exposed API key or database password can compromise your entire application and user data. Never commit secrets to version control.
Let’s go through essential practices to make your application production-ready.

Environment Variables

Never hardcode sensitive information (API keys, database passwords, ports) in your code. Use environment variables.
  1. Install dotenv:
    npm install dotenv
    
  2. Create a .env file:
    PORT=5000
    DB_URI=mongodb://localhost:27017/myapp
    SECRET_KEY=mysecretkey
    
  3. Use it in your code:
    // Load .env file FIRST, before any other imports that might need env vars.
    // This must be the very first line in your entry file.
    require('dotenv').config();
    
    const PORT = process.env.PORT || 3000;
    console.log(process.env.DB_URI);
    
  4. Important: Add .env to your .gitignore file so it’s not committed to version control.

Production Best Practices

  1. Use Gzip Compression: Use the compression middleware to decrease the size of the response body.
    const compression = require('compression');
    app.use(compression());
    
  2. Security Headers: Use helmet to set various HTTP headers for security.
    const helmet = require('helmet');
    app.use(helmet());
    
  3. Logging: Use a logger like morgan or winston instead of console.log.
  4. Error Handling: Implement a global error handling middleware.
  5. Clustering: Use Node.js Cluster module or a process manager like PM2 to take advantage of multi-core systems.

Deployment Options

1. Heroku (PaaS)

Heroku is a popular platform for deploying Node.js apps.
  1. Create a Procfile in the root:
    web: node server.js
    
  2. Install Heroku CLI and login.
  3. heroku create
  4. git push heroku main

2. Vercel / Netlify (Serverless)

Great for static sites and serverless functions. Next.js apps deploy seamlessly on Vercel.

3. VPS (DigitalOcean, AWS EC2, Linode)

For full control, you can rent a Virtual Private Server.
  • Set up Linux (Ubuntu).
  • Install Node.js and Nginx (as a reverse proxy).
  • Use PM2 to keep your app running.

Using PM2

PM2 is a production process manager for Node.js. It solves two critical problems: (1) your Node.js process crashes and nobody restarts it, leaving users with a dead server, and (2) your single-threaded Node process only uses one CPU core on a multi-core machine. PM2 handles automatic restarts on crash and can run your app in cluster mode across all available cores.
npm install pm2 -g
pm2 start server.js

# Cluster mode -- run one process per CPU core for maximum throughput
pm2 start server.js -i max

# Generate a startup script so PM2 restarts your app after server reboot
pm2 startup
pm2 save
Commands:
  • pm2 list: List running processes.
  • pm2 stop <id>: Stop a process.
  • pm2 restart <id>: Restart a process.
  • pm2 logs: View logs.
  • pm2 monit: Real-time monitoring dashboard.

Summary

  • Use Environment Variables for configuration—never hardcode secrets, database URLs, or API keys
  • Follow security best practices (Helmet for headers, Compression for performance)
  • Use a process manager like PM2 for VPS deployments—it handles crash recovery and clustering
  • Platforms like Heroku, Vercel, and Railway offer easy deployment for getting started

Docker Deployment

Basic Dockerfile

# Dockerfile
FROM node:20-alpine

# Create app directory
WORKDIR /usr/src/app

# Copy package files FIRST -- this layer is cached as long as dependencies
# do not change, so rebuilds after code-only changes are much faster.
COPY package*.json ./

# Use npm ci (not npm install) for reproducible builds from lockfile.
# --only=production skips devDependencies, reducing image size.
RUN npm ci --only=production

# Copy source code AFTER installing deps to maximize Docker layer caching
COPY . .

# EXPOSE documents the port but does not publish it -- you still need
# -p 3000:3000 when running the container
EXPOSE 3000

# Use array syntax (exec form) so Node.js receives SIGTERM directly
# for graceful shutdown. String form runs via /bin/sh which swallows signals.
CMD ["node", "server.js"]

Optimized Multi-Stage Build

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build  # If you have a build step

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodeapp -u 1001

# Copy only production dependencies
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy built app
COPY --from=builder /app/dist ./dist

# Switch to non-root user
USER nodeapp

EXPOSE 3000
CMD ["node", "dist/server.js"]

Docker Compose for Development

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=mongodb://mongo:27017/myapp
    volumes:
      - .:/usr/src/app
      - /usr/src/app/node_modules
    depends_on:
      - mongo
      - redis

  mongo:
    image: mongo:6
    volumes:
      - mongo-data:/data/db
    ports:
      - "27017:27017"

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

volumes:
  mongo-data:
# Run with Docker Compose
docker-compose up -d
docker-compose logs -f app
docker-compose down

AWS Deployment Options

ServiceBest ForComplexity
Elastic BeanstalkQuick deployment, managedLow
ECS/FargateDocker containersMedium
EC2Full controlHigh
LambdaServerless, APIsLow
App RunnerContainers, simpleLow

AWS Elastic Beanstalk

# Install EB CLI
pip install awsebcli

# Initialize and deploy
eb init my-node-app --platform node.js
eb create production
eb deploy

# Set environment variables
eb setenv NODE_ENV=production DATABASE_URL=...

Health Checks

Always implement health endpoints:
// Basic health check
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

// Comprehensive health check
app.get('/health/detailed', async (req, res) => {
  const health = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: 'healthy',
    checks: {}
  };

  // Database check
  try {
    await mongoose.connection.db.admin().ping();
    health.checks.database = { status: 'healthy' };
  } catch (error) {
    health.status = 'unhealthy';
    health.checks.database = { status: 'unhealthy', error: error.message };
  }

  // Redis check
  try {
    await redis.ping();
    health.checks.redis = { status: 'healthy' };
  } catch (error) {
    health.status = 'unhealthy';
    health.checks.redis = { status: 'unhealthy', error: error.message };
  }

  // Memory check
  const memUsage = process.memoryUsage();
  health.checks.memory = {
    heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
    heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`
  };

  res.status(health.status === 'healthy' ? 200 : 503).json(health);
});

Graceful Shutdown

A graceful shutdown means your server stops accepting new connections but finishes processing all in-flight requests before exiting. Without graceful shutdown, deploying a new version kills active requests mid-response—users see errors, database transactions get interrupted, and files get partially written. Every production Node.js server should implement this pattern.
const server = app.listen(PORT);

const gracefulShutdown = async (signal) => {
  console.log(`Received ${signal}. Starting graceful shutdown...`);

  // Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed');

    try {
      // Close database connections
      await mongoose.connection.close();
      console.log('MongoDB connection closed');

      // Close Redis
      await redis.quit();
      console.log('Redis connection closed');

      console.log('Graceful shutdown completed');
      process.exit(0);
    } catch (error) {
      console.error('Error during shutdown:', error);
      process.exit(1);
    }
  });

  // Force shutdown after 30 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30000);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          # Add deployment steps
          echo "Deploying..."