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.


