Set up automated Node.js application deployment using Git hooks for continuous integration, combined with PM2 clustering for high availability and load distribution across multiple CPU cores.
Prerequisites
- Server with sudo access
- Basic Git knowledge
- Node.js application to deploy
What this solves
This tutorial creates an automated deployment pipeline for Node.js applications using Git hooks combined with PM2 process management for production clustering. You'll set up Git-based continuous deployment where pushing code automatically restarts your application, plus PM2 clustering to distribute load across CPU cores for high availability.
Step-by-step installation
Install Node.js 20
Install the latest LTS version of Node.js using the NodeSource repository for better package management.
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs build-essential
node --version
npm --version
Install PM2 process manager
PM2 provides clustering, monitoring, and process management for Node.js applications in production environments.
sudo npm install -g pm2
pm2 --version
Create application directory structure
Set up the directory structure for your application with separate folders for the Git repository and the deployed application.
sudo mkdir -p /var/www/myapp
sudo mkdir -p /var/git/myapp.git
sudo useradd -r -s /bin/bash -d /var/www/myapp nodeapp
sudo chown -R nodeapp:nodeapp /var/www/myapp
sudo chown -R nodeapp:nodeapp /var/git/myapp.git
Initialize bare Git repository
Create a bare Git repository that will receive pushed code and trigger deployments via post-receive hooks.
sudo -u nodeapp git init --bare /var/git/myapp.git
sudo -u nodeapp git config --global user.name "Deployment System"
sudo -u nodeapp git config --global user.email "deploy@example.com"
Create post-receive Git hook
Set up the Git hook that automatically deploys your application when code is pushed to the repository.
#!/bin/bash
Configuration
APP_DIR="/var/www/myapp"
GIT_DIR="/var/git/myapp.git"
BRANCH="main"
PM2_APP_NAME="myapp"
Change to application directory
cd $APP_DIR
Check out the latest code
echo "Deploying application to $APP_DIR"
git --git-dir=$GIT_DIR --work-tree=$APP_DIR checkout -f $BRANCH
Install dependencies
echo "Installing dependencies..."
npm ci --only=production
Run any build steps if needed
if [ -f "package.json" ] && grep -q '"build":' package.json; then
echo "Running build..."
npm run build
fi
Restart PM2 application
echo "Restarting application with PM2..."
if pm2 describe $PM2_APP_NAME > /dev/null 2>&1; then
pm2 restart $PM2_APP_NAME
else
pm2 start ecosystem.config.js
fi
Save PM2 configuration
pm2 save
echo "Deployment completed successfully!"
Make Git hook executable
Set the correct permissions for the Git hook script and ensure proper ownership.
sudo chmod +x /var/git/myapp.git/hooks/post-receive
sudo chown nodeapp:nodeapp /var/git/myapp.git/hooks/post-receive
Create PM2 ecosystem configuration
Configure PM2 clustering settings to utilize all available CPU cores with automatic restart and monitoring.
module.exports = {
apps: [{
name: 'myapp',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: '/var/log/myapp/err.log',
out_file: '/var/log/myapp/out.log',
log_file: '/var/log/myapp/combined.log',
time: true,
max_restarts: 10,
min_uptime: '10s',
kill_timeout: 5000,
restart_delay: 1000
}]
};
Create log directory
Set up logging directory with proper permissions for PM2 to write application logs.
sudo mkdir -p /var/log/myapp
sudo chown nodeapp:nodeapp /var/log/myapp
sudo chmod 755 /var/log/myapp
Create sample Node.js application
Create a basic Express.js application to test the deployment pipeline and clustering setup.
const express = require('express');
const cluster = require('cluster');
const os = require('os');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'Hello from Node.js deployment!',
pid: process.pid,
hostname: os.hostname(),
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', pid: process.pid });
});
app.listen(port, () => {
console.log(Server running on port ${port}, PID: ${process.pid});
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
process.exit(0);
});
Create package.json
Define application dependencies and scripts for the deployment process.
{
"name": "myapp-deployment",
"version": "1.0.0",
"description": "Node.js application with automated deployment",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "echo \"No tests specified\""
},
"dependencies": {
"express": "^4.18.2"
},
"engines": {
"node": ">=18.0.0"
}
}
Install application dependencies
Install the required Node.js packages as the application user.
sudo -u nodeapp bash -c "cd /var/www/myapp && npm install"
Configure PM2 startup
Set up PM2 to automatically start your application on system boot with proper user permissions.
sudo -u nodeapp pm2 start /var/www/myapp/ecosystem.config.js
sudo -u nodeapp pm2 save
sudo pm2 startup systemd -u nodeapp --hp /var/www/myapp
sudo systemctl enable pm2-nodeapp
Create deployment rollback script
Add a rollback mechanism to quickly revert to the previous deployment if issues occur.
#!/bin/bash
APP_DIR="/var/www/myapp"
GIT_DIR="/var/git/myapp.git"
PM2_APP_NAME="myapp"
echo "Rolling back to previous commit..."
cd $APP_DIR
Get the previous commit hash
PREV_COMMIT=$(git --git-dir=$GIT_DIR log --format="%H" -n 2 | tail -1)
if [ -z "$PREV_COMMIT" ]; then
echo "Error: No previous commit found"
exit 1
fi
Checkout previous commit
git --git-dir=$GIT_DIR --work-tree=$APP_DIR checkout -f $PREV_COMMIT
Reinstall dependencies
npm ci --only=production
Restart application
pm2 restart $PM2_APP_NAME
echo "Rollback completed to commit: $PREV_COMMIT"
Make rollback script executable
Set proper permissions for the rollback script.
sudo chmod +x /var/www/myapp/rollback.sh
sudo chown nodeapp:nodeapp /var/www/myapp/rollback.sh
Configure reverse proxy with NGINX
Set up NGINX to proxy requests to your PM2-managed Node.js cluster for better performance and SSL termination.
sudo apt install -y nginx
upstream nodejs_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
access_log /var/log/nginx/myapp_access.log;
error_log /var/log/nginx/myapp_error.log;
location / {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_redirect off;
}
location /health {
proxy_pass http://nodejs_backend/health;
access_log off;
}
}
Enable NGINX configuration
Activate the NGINX configuration and restart the service.
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
sudo systemctl enable nginx
Set up local Git repository for deployment
Create a local development repository and configure it to push to your deployment server.
mkdir ~/myapp-dev
cd ~/myapp-dev
git init
cp /var/www/myapp/app.js .
cp /var/www/myapp/package.json .
cp /var/www/myapp/ecosystem.config.js .
git add .
git commit -m "Initial commit"
git remote add production nodeapp@your-server-ip:/var/git/myapp.git
Configure monitoring and health checks
Create health check script
Set up automated health monitoring to ensure your application cluster is responding correctly.
#!/bin/bash
APP_URL="http://localhost:3000/health"
PM2_APP_NAME="myapp"
MAX_RETRIES=3
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
if curl -f -s $APP_URL > /dev/null; then
echo "Health check passed"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Health check failed, retry $RETRY_COUNT/$MAX_RETRIES"
sleep 5
done
echo "Health check failed after $MAX_RETRIES attempts, restarting PM2"
pm2 restart $PM2_APP_NAME
exit 1
Schedule health checks with cron
Add automated health checks to run every 5 minutes to ensure high availability.
sudo chmod +x /var/www/myapp/healthcheck.sh
sudo chown nodeapp:nodeapp /var/www/myapp/healthcheck.sh
sudo -u nodeapp crontab -l > /tmp/nodeapp_cron 2>/dev/null || true
echo "/5 * /var/www/myapp/healthcheck.sh >> /var/log/myapp/healthcheck.log 2>&1" >> /tmp/nodeapp_cron
sudo -u nodeapp crontab /tmp/nodeapp_cron
rm /tmp/nodeapp_cron
Verify your setup
# Check PM2 processes
sudo -u nodeapp pm2 list
sudo -u nodeapp pm2 monit
Test application endpoint
curl http://localhost:3000/
curl http://localhost:3000/health
Check NGINX status
sudo systemctl status nginx
curl -I http://localhost/
View application logs
sudo -u nodeapp pm2 logs myapp --lines 50
Test deployment (from development directory)
cd ~/myapp-dev
echo "console.log('Deployment test');" >> app.js
git add .
git commit -m "Test deployment"
git push production main
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Git push fails with permission denied | SSH key not configured | Add your SSH key: ssh-copy-id nodeapp@server-ip |
| PM2 processes exit immediately | Application crashes on startup | Check logs: pm2 logs myapp and fix application errors |
| Deployment hook doesn't run | Hook not executable or wrong permissions | chmod +x /var/git/myapp.git/hooks/post-receive |
| npm install fails during deployment | Missing build tools or permissions | Install build-essential and check directory permissions |
| Application not accessible via NGINX | NGINX configuration or upstream issue | Check nginx -t and verify PM2 is listening on port 3000 |
| PM2 doesn't restart on boot | Startup script not configured | Run pm2 startup and follow the instructions |
Next steps
- Configure NGINX SSL certificate automation with Certbot for HTTPS support
- Monitor Node.js applications with Prometheus and Grafana for production observability
- Set up structured logging with Winston and log rotation
- Implement blue-green deployment strategies for zero downtime
- Configure database connection pooling for high-performance applications
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Default configuration
APP_NAME="${1:-myapp}"
GIT_BRANCH="${2:-main}"
APP_PORT="${3:-3000}"
NODE_VERSION="20"
# Usage message
usage() {
echo "Usage: $0 [app_name] [git_branch] [port]"
echo "Example: $0 myapp main 3000"
exit 1
}
# Print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Cleanup on error
cleanup() {
print_error "Installation failed. Cleaning up..."
if id "nodeapp" &>/dev/null; then
sudo userdel -r nodeapp 2>/dev/null || true
fi
sudo rm -rf "/var/www/${APP_NAME}" "/var/git/${APP_NAME}.git" "/var/log/${APP_NAME}" 2>/dev/null || true
exit 1
}
trap cleanup ERR
# Check if running as root or with sudo
check_root() {
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root or with sudo"
exit 1
fi
}
# Detect distribution and set package manager
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_INSTALL="apt install -y"
PKG_UPDATE="apt update"
BUILD_ESSENTIAL="build-essential"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"
PKG_INSTALL="dnf install -y"
PKG_UPDATE="dnf update -y"
BUILD_ESSENTIAL="gcc-c++ make"
;;
amzn)
PKG_MGR="yum"
PKG_INSTALL="yum install -y"
PKG_UPDATE="yum update -y"
BUILD_ESSENTIAL="gcc-c++ make"
;;
*)
print_error "Unsupported distribution: $ID"
exit 1
;;
esac
else
print_error "Cannot detect Linux distribution"
exit 1
fi
}
# Validate arguments
if [[ $# -gt 3 ]]; then
usage
fi
print_status "Starting Node.js deployment setup with PM2 clustering"
echo "[1/10] Checking prerequisites and detecting system..."
check_root
detect_distro
echo "[2/10] Updating package repositories..."
$PKG_UPDATE
echo "[3/10] Installing Node.js ${NODE_VERSION}..."
if command -v node &> /dev/null; then
print_warning "Node.js already installed, checking version..."
node --version
else
case "$PKG_MGR" in
apt)
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
$PKG_INSTALL nodejs $BUILD_ESSENTIAL
;;
dnf|yum)
curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash -
$PKG_INSTALL nodejs npm $BUILD_ESSENTIAL
;;
esac
fi
echo "[4/10] Installing PM2 process manager..."
npm install -g pm2
pm2 --version
echo "[5/10] Creating application directory structure..."
mkdir -p "/var/www/${APP_NAME}"
mkdir -p "/var/git/${APP_NAME}.git"
mkdir -p "/var/log/${APP_NAME}"
if ! id "nodeapp" &>/dev/null; then
useradd -r -s /bin/bash -d "/var/www/${APP_NAME}" nodeapp
fi
chown -R nodeapp:nodeapp "/var/www/${APP_NAME}"
chown -R nodeapp:nodeapp "/var/git/${APP_NAME}.git"
chown -R nodeapp:nodeapp "/var/log/${APP_NAME}"
chmod 755 "/var/www/${APP_NAME}" "/var/git/${APP_NAME}.git" "/var/log/${APP_NAME}"
echo "[6/10] Initializing bare Git repository..."
sudo -u nodeapp git init --bare "/var/git/${APP_NAME}.git"
sudo -u nodeapp git config --global --replace-all user.name "Deployment System"
sudo -u nodeapp git config --global --replace-all user.email "deploy@$(hostname -f)"
echo "[7/10] Creating post-receive Git hook..."
cat > "/var/git/${APP_NAME}.git/hooks/post-receive" << 'EOL'
#!/bin/bash
set -euo pipefail
APP_DIR="/var/www/APP_NAME_PLACEHOLDER"
GIT_DIR="/var/git/APP_NAME_PLACEHOLDER.git"
BRANCH="BRANCH_PLACEHOLDER"
PM2_APP_NAME="APP_NAME_PLACEHOLDER"
cd "$APP_DIR"
echo "Deploying application to $APP_DIR"
git --git-dir="$GIT_DIR" --work-tree="$APP_DIR" checkout -f "$BRANCH"
echo "Installing dependencies..."
export NODE_ENV=production
npm ci --only=production
if [ -f "package.json" ] && grep -q '"build":' package.json; then
echo "Running build..."
npm run build
fi
echo "Managing PM2 application..."
if pm2 describe "$PM2_APP_NAME" > /dev/null 2>&1; then
pm2 restart "$PM2_APP_NAME"
else
pm2 start ecosystem.config.js
fi
pm2 save
echo "Deployment completed successfully!"
EOL
sed -i "s/APP_NAME_PLACEHOLDER/${APP_NAME}/g" "/var/git/${APP_NAME}.git/hooks/post-receive"
sed -i "s/BRANCH_PLACEHOLDER/${GIT_BRANCH}/g" "/var/git/${APP_NAME}.git/hooks/post-receive"
chmod 755 "/var/git/${APP_NAME}.git/hooks/post-receive"
chown nodeapp:nodeapp "/var/git/${APP_NAME}.git/hooks/post-receive"
echo "[8/10] Creating PM2 ecosystem configuration..."
cat > "/var/www/${APP_NAME}/ecosystem.config.js" << EOL
module.exports = {
apps: [{
name: '${APP_NAME}',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: ${APP_PORT}
},
error_file: '/var/log/${APP_NAME}/err.log',
out_file: '/var/log/${APP_NAME}/out.log',
log_file: '/var/log/${APP_NAME}/combined.log',
time: true,
max_restarts: 10,
min_uptime: '10s',
kill_timeout: 5000,
restart_delay: 1000
}]
};
EOL
chown nodeapp:nodeapp "/var/www/${APP_NAME}/ecosystem.config.js"
chmod 644 "/var/www/${APP_NAME}/ecosystem.config.js"
echo "[9/10] Creating sample Node.js application..."
cat > "/var/www/${APP_NAME}/package.json" << EOL
{
"name": "${APP_NAME}",
"version": "1.0.0",
"description": "Sample Node.js application with PM2 clustering",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
EOL
cat > "/var/www/${APP_NAME}/app.js" << EOL
const express = require('express');
const cluster = require('cluster');
const os = require('os');
const app = express();
const port = process.env.PORT || ${APP_PORT};
app.get('/', (req, res) => {
res.json({
message: 'Hello from ${APP_NAME}!',
worker: process.pid,
hostname: os.hostname(),
uptime: process.uptime()
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});
app.listen(port, () => {
console.log(\`${APP_NAME} worker \${process.pid} listening on port \${port}\`);
});
EOL
chown -R nodeapp:nodeapp "/var/www/${APP_NAME}/"
find "/var/www/${APP_NAME}/" -type f -exec chmod 644 {} \;
find "/var/www/${APP_NAME}/" -type d -exec chmod 755 {} \;
cd "/var/www/${APP_NAME}"
sudo -u nodeapp npm install --only=production
echo "[10/10] Setting up PM2 startup and verifying installation..."
pm2 startup systemd -u nodeapp --hp "/var/www/${APP_NAME}"
sudo -u nodeapp pm2 start "/var/www/${APP_NAME}/ecosystem.config.js"
sudo -u nodeapp pm2 save
sleep 3
print_status "Verifying installation..."
if pm2 describe "${APP_NAME}" > /dev/null 2>&1; then
print_status "✓ PM2 application is running"
else
print_error "✗ PM2 application failed to start"
exit 1
fi
if [ -d "/var/git/${APP_NAME}.git" ] && [ -f "/var/git/${APP_NAME}.git/hooks/post-receive" ]; then
print_status "✓ Git repository and hooks configured"
else
print_error "✗ Git repository setup failed"
exit 1
fi
print_status "Installation completed successfully!"
echo ""
echo "Next steps:"
echo "1. Add remote: git remote add production nodeapp@$(hostname -I | awk '{print $1}'):/var/git/${APP_NAME}.git"
echo "2. Deploy code: git push production ${GIT_BRANCH}"
echo "3. Check status: sudo -u nodeapp pm2 status"
echo "4. View logs: sudo -u nodeapp pm2 logs ${APP_NAME}"
echo "5. Test app: curl http://localhost:${APP_PORT}"
Review the script before running. Execute with: bash install.sh