How to Deploy a Node.js App on a VPS with Nginx, PM2, and GitHub Actions CI/CD

How to Deploy a Node.js App on a VPS with Nginx, PM2, and GitHub Actions CI/CD

Deploying a Node.js application to a VPS gives you complete control over your runtime environment, dependency versions, and deployment process — without the abstraction layers and cost of platform-as-a-service providers. This guide walks through the complete production setup: installing Node.js, configuring PM2 for process management, setting up Nginx as a reverse proxy with SSL, and automating deployments with a GitHub Actions CI/CD pipeline that pushes to your VPS on every merge to main.

Architecture Overview

The deployment stack consists of four layers:

  1. GitHub Actions — CI/CD pipeline that runs tests, builds the application, and SSHes into the VPS to pull and restart
  2. PM2 — Node.js process manager that keeps the application running, handles crashes, manages cluster mode, and provides logging
  3. Node.js application — Your Express, Fastify, Next.js, or other Node.js server running on a local port (e.g., 3000)
  4. Nginx — Reverse proxy that handles SSL termination, routes requests to PM2-managed Node.js, and serves static assets directly

Step 1: Provision the VPS and Install Node.js

After securing your VPS (SSH keys, firewall, Fail2ban — see the VPS security hardening guide), install Node.js using the NodeSource repository to get the latest LTS version:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install nodejs -y
node --version  # Should show v20.x.x
npm --version

Step 2: Install and Configure PM2

PM2 is a production process manager that automatically restarts your Node.js application on crashes, manages environment variables, supports cluster mode for multi-core scaling, and integrates with systemd for automatic startup.

sudo npm install -g pm2

Create a PM2 Ecosystem Configuration File

In your project directory, create ecosystem.config.js:

module.exports = {
  apps: [
    {
      name: 'myapp',
      script: './dist/server.js',  // or src/index.js for non-compiled apps
      instances: 'max',            // use all available CPU cores
      exec_mode: 'cluster',
      watch: false,
      max_memory_restart: '500M',
      env: {
        NODE_ENV: 'development',
        PORT: 3000
      },
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000
      },
      error_file: '/var/log/pm2/myapp-error.log',
      out_file: '/var/log/pm2/myapp-out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
      merge_logs: true
    }
  ]
};

Start the application:

pm2 start ecosystem.config.js --env production
pm2 save  # Save process list so it restores on reboot

Configure PM2 to Start on Server Boot

pm2 startup
# Follow the output instruction — it will print a sudo command to run
pm2 save

Useful PM2 Commands

pm2 status              # List all processes and their status
pm2 logs myapp          # Stream application logs
pm2 logs myapp --lines 200  # Last 200 log lines
pm2 reload myapp        # Zero-downtime reload (cluster mode)
pm2 restart myapp       # Hard restart
pm2 monit               # Interactive monitoring dashboard

Step 3: Configure Nginx as Reverse Proxy

sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/myapp
upstream nodejs {
    # PM2 cluster uses multiple workers on the same port
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Increase proxy buffer for Node.js responses
    proxy_buffer_size 128k;
    proxy_buffers 4 256k;
    proxy_busy_buffers_size 256k;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Serve static assets directly from disk (much faster)
    location /static/ {
        alias /home/deploy/myapp/public/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Proxy everything else to Node.js
    location / {
        proxy_pass http://nodejs;
        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_read_timeout 90s;
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

# Issue SSL certificate
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Step 4: Create a Deploy User and SSH Key for CI/CD

Create a dedicated deployment user on the VPS with limited permissions — it should only be able to pull code and restart the PM2 process:

sudo adduser deploy --disabled-password --gecos ""
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

Generate an SSH key pair specifically for CI/CD (run this on your local machine or anywhere outside the VPS):

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

Add the public key to the deploy user’s authorized_keys on the VPS:

sudo nano /home/deploy/.ssh/authorized_keys
# Paste contents of ~/.ssh/deploy_key.pub
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

Allow the deploy user to run PM2 reload without a password (sudoers):

sudo visudo -f /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /usr/bin/pm2 reload myapp

Step 5: Set Up the Application Directory

sudo mkdir -p /home/deploy/myapp
sudo chown deploy:deploy /home/deploy/myapp

# As the deploy user, clone your repository
sudo -u deploy git clone git@github.com:yourorg/myapp.git /home/deploy/myapp

Step 6: Create the GitHub Actions CI/CD Pipeline

In your repository, create .github/workflows/deploy.yml:

name: Deploy to VPS

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
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            set -e
            cd /home/deploy/myapp
            git pull origin main
            npm ci --only=production
            npm run build
            pm2 reload ecosystem.config.js --env production --update-env
            echo "Deployment completed at $(date)"

Configure GitHub Secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions, and add:

  • VPS_HOST: Your VPS IP address or domain
  • VPS_SSH_KEY: The private key contents from ~/.ssh/deploy_key

Step 7: Test the Full Pipeline

Push a commit to the main branch and watch the GitHub Actions workflow:

  1. The test job runs your test suite
  2. If tests pass, the deploy job SSHes into the VPS
  3. It pulls the latest code, installs dependencies, builds, and reloads PM2
  4. PM2’s cluster mode ensures zero downtime during reload — existing workers continue serving requests while new workers start up

Step 8: Environment Variables in Production

Never commit secrets to your repository. Manage production environment variables on the VPS:

sudo nano /home/deploy/myapp/.env.production
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_jwt_secret_here
STRIPE_SECRET_KEY=sk_live_...
chmod 600 /home/deploy/myapp/.env.production

Load the production env file in your PM2 ecosystem config by updating env_production:

require('dotenv').config({ path: '.env.production' });

Monitoring and Alerting

Configure PM2 to send alerts when your application crashes or restarts unexpectedly:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7

Set up UptimeRobot or a similar service to monitor your application’s health endpoint and alert you via email, Slack, or SMS if it becomes unreachable. Add a health endpoint to your Node.js application:

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

Getting Started

A Node.js production deployment works well from a 2 vCPU / 2 GB RAM VPS for most applications, scaling to 4 vCPU / 4 GB RAM for higher-traffic services. PM2 cluster mode automatically utilizes all available CPU cores. Ubuntu VPS plans at VPS.DO provide the clean Ubuntu 22.04 LTS environment this guide assumes, with NVMe storage and KVM virtualization for full Node.js compatibility.

Conclusion

A Node.js application deployed with PM2, Nginx, and GitHub Actions gives you a production-grade pipeline that automatically tests and deploys every push to main — with zero-downtime reloads and comprehensive logging. This setup is more cost-effective and transparent than PaaS alternatives, and gives you complete control over your runtime environment, deployment process, and server configuration.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!