Express.js Application Deployment on Linux

Express.js is the most widely used Node.js web framework, and deploying it in production on Linux requires PM2 for process management, Nginx as a reverse proxy, and proper environment configuration. This guide covers PM2 setup, cluster mode, Nginx reverse proxy configuration, SSL, environment variable management, and zero-downtime deployments for Express.js applications.

Prerequisites

  • Ubuntu 20.04+ or CentOS/Rocky 8+ with root access
  • Your Express.js application code
  • Domain name pointed to your server

Installing Node.js and Express Dependencies

# Install Node.js via NodeSource (recommended for current LTS versions)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -   # Ubuntu
sudo apt install -y nodejs

# CentOS/Rocky
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
sudo dnf install -y nodejs

# Verify
node --version
npm --version

# Install PM2 globally
sudo npm install -g pm2
pm2 --version

Create application user and directories:

sudo useradd -r -m -d /opt/myapp -s /bin/bash nodeapp
sudo mkdir -p /opt/myapp/app
sudo mkdir -p /var/log/myapp
sudo chown -R nodeapp:nodeapp /opt/myapp /var/log/myapp

Application Structure and Environment

# Copy your application to the server
# Or deploy via git:
sudo -u nodeapp git clone https://github.com/yourorg/yourapp.git /opt/myapp/app
cd /opt/myapp/app

# Install production dependencies only
sudo -u nodeapp bash -c "cd /opt/myapp/app && npm ci --omit=dev"

Sample Express app structure:

sudo tee /opt/myapp/app/server.js << 'EOF'
const express = require('express');
const app = express();

const PORT = process.env.PORT || 3000;
const ENV = process.env.NODE_ENV || 'development';

app.use(express.json());

app.get('/', (req, res) => {
    res.json({ message: 'Hello World', env: ENV });
});

app.get('/health', (req, res) => {
    res.json({ status: 'healthy', pid: process.pid });
});

// Graceful shutdown
process.on('SIGTERM', () => {
    console.log('SIGTERM received, shutting down gracefully...');
    server.close(() => {
        console.log('HTTP server closed');
        process.exit(0);
    });
});

const server = app.listen(PORT, '127.0.0.1', () => {
    console.log(`Server running on port ${PORT} (PID: ${process.pid})`);
});

module.exports = { app, server };
EOF

Create the environment file:

sudo tee /etc/myapp/.env << 'EOF'
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@localhost/mydb
SECRET_KEY=your-secret-key
REDIS_URL=redis://localhost:6379
EOF

sudo chmod 600 /etc/myapp/.env
sudo chown nodeapp:nodeapp /etc/myapp/.env

PM2 Process Management

# Start application with PM2
sudo -u nodeapp pm2 start /opt/myapp/app/server.js \
    --name myapp \
    --env production

# View running processes
pm2 list
pm2 show myapp

# View real-time logs
pm2 logs myapp
pm2 logs myapp --lines 100

# Monitor resources
pm2 monit

Create a PM2 ecosystem configuration file for reproducible deployments:

sudo tee /opt/myapp/ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'myapp',
    script: './app/server.js',
    
    // Environment
    env: {
      NODE_ENV: 'development',
      PORT: 3000,
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },

    // Logging
    log_file: '/var/log/myapp/combined.log',
    out_file: '/var/log/myapp/out.log',
    error_file: '/var/log/myapp/error.log',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',

    // Process management
    max_restarts: 10,
    min_uptime: '5s',
    restart_delay: 3000,

    // Memory limit (auto-restart if exceeded)
    max_memory_restart: '512M',

    // Environment variables file
    env_file: '/etc/myapp/.env',
  }]
};
EOF

sudo chown nodeapp:nodeapp /opt/myapp/ecosystem.config.js
# Start using ecosystem config
sudo -u nodeapp bash -c "cd /opt/myapp && pm2 start ecosystem.config.js --env production"

# Save PM2 process list for auto-start after reboot
sudo -u nodeapp pm2 save

# Generate and enable systemd startup script
sudo -u nodeapp pm2 startup systemd -u nodeapp --hp /opt/myapp
# Run the command output by pm2 startup (it will look like:)
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u nodeapp --hp /opt/myapp

PM2 Cluster Mode

Cluster mode runs one process per CPU core for maximum performance:

sudo tee /opt/myapp/ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'myapp',
    script: './app/server.js',
    
    // Cluster mode: -1 = max instances, 'max' = same as -1, or a number
    instances: 'max',      // Use all CPU cores
    exec_mode: 'cluster',  // Enable cluster mode
    
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },

    env_file: '/etc/myapp/.env',
    log_file: '/var/log/myapp/combined.log',
    out_file: '/var/log/myapp/out.log',
    error_file: '/var/log/myapp/error.log',
    max_memory_restart: '512M',

    // Zero-downtime reload settings
    wait_ready: true,         // Wait for process.send('ready')
    listen_timeout: 5000,     // Timeout waiting for ready signal
    kill_timeout: 5000,       // Time to wait for graceful shutdown
  }]
};
EOF

Add ready signal to your Express app (for zero-downtime reloads):

// Add to server.js after app.listen():
server.listen(PORT, '127.0.0.1', () => {
    console.log(`Worker ${process.pid} listening on port ${PORT}`);
    // Signal PM2 that the process is ready
    if (process.send) {
        process.send('ready');
    }
});
# Reload all instances with zero downtime
pm2 reload myapp

# Restart one instance at a time
pm2 restart myapp --update-env

# Scale up/down
pm2 scale myapp 4    # Scale to 4 instances
pm2 scale myapp +2   # Add 2 more instances

Nginx Reverse Proxy

sudo apt install nginx   # Ubuntu
sudo dnf install nginx   # CentOS/Rocky

sudo tee /etc/nginx/sites-available/myapp << 'EOF'
upstream node_backend {
    least_conn;                     # Route to least-connected worker
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
    limit_req zone=api burst=20 nodelay;

    # Security headers
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;
    add_header X-XSS-Protection "1; mode=block";

    location / {
        proxy_pass http://node_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";   # Support WebSockets
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 10s;
        proxy_read_timeout 60s;
    }

    # Static files served directly by Nginx (much faster)
    location /static {
        alias /opt/myapp/app/public;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location /health {
        proxy_pass http://node_backend;
        access_log off;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

SSL with Let's Encrypt

sudo apt install certbot python3-certbot-nginx   # Ubuntu
sudo dnf install certbot python3-certbot-nginx   # CentOS/Rocky

# Get certificate (Nginx must be running on port 80)
sudo certbot --nginx -d app.example.com

# Verify auto-renewal
sudo certbot renew --dry-run

Zero-Downtime Deployments

sudo tee /usr/local/bin/deploy-express.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

APP_DIR="/opt/myapp/app"
APP_USER="nodeapp"
APP_NAME="myapp"

echo "Starting deployment..."

# 1. Pull latest code
sudo -u "$APP_USER" git -C "$APP_DIR" fetch origin
sudo -u "$APP_USER" git -C "$APP_DIR" reset --hard origin/main

# 2. Install dependencies
sudo -u "$APP_USER" npm ci --omit=dev --prefix "$APP_DIR"

# 3. Run database migrations (if applicable)
# sudo -u "$APP_USER" node "$APP_DIR/scripts/migrate.js"

# 4. Reload PM2 (zero-downtime - sends SIGINT to each worker one at a time)
sudo -u "$APP_USER" pm2 reload "$APP_NAME" --update-env

# 5. Verify deployment
sleep 3
if sudo -u "$APP_USER" pm2 show "$APP_NAME" | grep -q "online"; then
    echo "Deployment successful - all workers online"
else
    echo "Deployment may have failed - check: pm2 list"
    exit 1
fi
SCRIPT

sudo chmod +x /usr/local/bin/deploy-express.sh

Docker Deployment

FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

# Production image
FROM node:20-alpine

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

WORKDIR /app

COPY --from=builder --chown=appuser:appgroup /app .

USER appuser

EXPOSE 3000

CMD ["node", "server.js"]
docker build -t myapp:latest .

docker run -d \
  --name myapp \
  --restart unless-stopped \
  -p 127.0.0.1:3000:3000 \
  --env-file /etc/myapp/.env \
  --memory="512m" \
  --cpus="1.0" \
  myapp:latest

Troubleshooting

PM2 process restarts in a loop:

pm2 logs myapp --lines 100
# Look for startup errors
# Check that node_modules are installed
sudo -u nodeapp ls /opt/myapp/app/node_modules | wc -l

# Run manually to see the error
sudo -u nodeapp node /opt/myapp/app/server.js

502 Bad Gateway from Nginx:

# Check PM2 status
sudo -u nodeapp pm2 list
# Check if app is listening on right port
ss -tlnp | grep 3000
# Check Nginx error log
sudo tail -50 /var/log/nginx/error.log

App crashes with "listen EADDRINUSE":

# Port is already in use by another PM2 instance
pm2 list     # Check all running processes
pm2 delete all
pm2 start ecosystem.config.js --env production

High memory usage, workers not restarting:

# PM2 max_memory_restart should handle this
# Check current memory
pm2 show myapp | grep memory

# Manually trigger restart if needed
pm2 restart myapp

Conclusion

Express.js production deployment on Linux centers on three components: PM2 for process management and cluster mode (to use all CPU cores), Nginx for SSL termination and reverse proxying, and a zero-downtime reload strategy using pm2 reload. Always run as a non-root user, keep dependencies minimal with npm ci --omit=dev, and configure PM2's max_memory_restart to automatically recover from memory leaks before they impact users.