Back

Complete VPS Deployment Guide: Node.js/Next.js Production Setup

VPS DeploymentFebruary 2, 2026Marc Tyson CLEBERT

A step-by-step guide to deploying a production-ready web application on a VPS with security hardening, SSL, reverse proxy, automated backups, and CI/CD.


šŸ“¦ Choose Your Package Manager

This guide supports both npm and pnpm. Throughout this guide, you'll see instructions for both:

  • npm - Default package manager (comes with Node.js)
  • pnpm - Fast, disk space efficient alternative

Choose ONE and use it consistently throughout!


Table of Contents

  1. Prerequisites
  2. Initial VPS Setup & SSH Access
  3. Create Sudo User & SSH Key Authentication
  4. Harden SSH Security
  5. Setup Automatic Security Updates
  6. Configure Firewall (UFW)
  7. Install & Configure Nginx
  8. Setup DNS Records
  9. Install SSL Certificates (Certbot)
  10. Install Node.js & Package Manager
  11. Install PM2 Process Manager
  12. Setup Git SSH Authentication
  13. Install & Configure PostgreSQL
  14. Deploy Your Application
  15. Configure Nginx Reverse Proxy
  16. Setup Automated Database Backups
  17. Setup Auto-Deployment with GitHub Actions
  18. Configure Subdomains (Optional)
  19. Maintenance & Troubleshooting

Prerequisites

What you need:

  • A VPS (Virtual Private Server) with Ubuntu 22.04 or 24.04
  • A domain name (e.g., yourdomain.com)
  • Access to your domain's DNS settings
  • Your VPS IP address
  • Root access to your VPS (temporarily, for initial setup)
  • A GitHub account (for auto-deployment)

Example values used in this guide:

  • VPS IP: 123.456.789.012
  • Domain: yourdomain.com
  • VPS Username: deployuser
  • Database Name: appdb
  • Database User: dbuser

Replace these with your actual values!


Initial VPS Setup & SSH Access

Step 1: First Login as Root

From your local machine:

ssh root@123.456.789.012

Enter the root password provided by your VPS provider.


Step 2: Update System Packages

apt update && apt upgrade -y

Create Sudo User & SSH Key Authentication

Step 1: Create New User

# Create user (replace 'deployuser' with your preferred username)
adduser deployuser

You'll be prompted to:

  • Set a strong password
  • Fill in user info (optional, press Enter to skip)

Step 2: Add User to Sudo Group

usermod -aG sudo deployuser

Step 3: Test Sudo Access

su - deployuser
sudo whoami

Should output: root

Exit back to root:

exit

Step 4: Generate SSH Key on Local Machine

On your local machine (NOT the VPS):

# Check if you already have SSH keys
ls -la ~/.ssh/id_ed25519.pub

# If not, generate a new key pair
ssh-keygen -t ed25519 -C "your-email@example.com"

When prompted:

  • File location: Press Enter (default)
  • Passphrase: Optional but recommended

View your public key:

cat ~/.ssh/id_ed25519.pub

Copy the entire output (starts with ssh-ed25519 ...)


Step 5: Add Public Key to VPS

Back on the VPS as root:

# Switch to your new user
su - deployuser

# Create .ssh directory
mkdir -p ~/.ssh
chmod 700 ~/.ssh

# Create authorized_keys file
nano ~/.ssh/authorized_keys

Paste your public key into this file.

Save and exit (Ctrl+X, Y, Enter)

Set correct permissions:

chmod 600 ~/.ssh/authorized_keys
exit  # Back to root

Step 6: Test SSH Key Login

From your local machine (open a NEW terminal, keep root session open!):

ssh deployuser@123.456.789.012

āœ… You should connect without entering a password!

āš ļø Keep your root session open until SSH key login is confirmed!


Harden SSH Security

Step 1: Edit SSH Configuration

On the VPS (as root or sudo):

sudo nano /etc/ssh/sshd_config

Step 2: Modify These Settings

Find and change these lines:

# Change SSH port from 22 to something non-standard (e.g., 2222, 3333, 5009)
Port 2222

# Disable root login
PermitRootLogin no

# Disable password authentication (force SSH keys only)
PasswordAuthentication no

# Ensure public key authentication is enabled
PubkeyAuthentication yes

# Disable empty passwords
PermitEmptyPasswords no

# Disable challenge-response authentication
KbdInteractiveAuthentication no

Save and exit


Step 3: Test SSH Configuration

sudo sshd -t

Should output: no errors


Step 4: Restart SSH Service

sudo systemctl daemon-reload
sudo systemctl restart ssh
sudo systemctl restart ssh.socket

Step 5: Verify SSH is Listening on New Port

sudo ss -tlnp | grep sshd

Should show your new port (e.g., :2222) instead of :22


Step 6: Test New SSH Connection

From your local machine (keep current session open!):

ssh deployuser@123.456.789.012 -p 2222

āœ… If this works, you're good!


Step 7: Update Local SSH Config (Optional)

On your local machine:

nano ~/.ssh/config

Add:

Host myvps
    HostName 123.456.789.012
    User deployuser
    Port 2222
    IdentityFile ~/.ssh/id_ed25519

Now you can connect with just:

ssh myvps

Setup Automatic Security Updates

Step 1: Install Unattended Upgrades

sudo apt update
sudo apt install unattended-upgrades -y

Step 2: Enable Automatic Updates

sudo dpkg-reconfigure --priority=low unattended-upgrades

Select "Yes" when prompted.


Step 3: Configure Update Behavior

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

Uncomment/modify these lines:

// Enable security updates
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};

// Automatically remove unused dependencies
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";

// Automatically reboot if required (at 3 AM)
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";

Save and exit


Step 4: Configure Update Frequency

sudo nano /etc/apt/apt.conf.d/20auto-upgrades

Add:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";

Save and exit


Step 5: Verify Service is Running

sudo systemctl status unattended-upgrades

Configure Firewall (UFW)

Step 1: Allow SSH on Your Custom Port FIRST

āš ļø Critical: Do this BEFORE enabling UFW!

# Allow your custom SSH port (replace 2222 with your port)
sudo ufw allow 2222/tcp

Step 2: Allow HTTP and HTTPS

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Step 3: Enable UFW

sudo ufw enable

Type y when prompted.


Step 4: Verify Firewall Rules

sudo ufw status numbered

Should show:

Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 2222/tcp                   ALLOW IN    Anywhere
[ 2] 80/tcp                     ALLOW IN    Anywhere
[ 3] 443/tcp                    ALLOW IN    Anywhere

Install & Configure Nginx

Step 1: Install Nginx

sudo apt update
sudo apt install nginx -y

Step 2: Verify Nginx is Running

sudo systemctl status nginx

Should show active (running)


Step 3: Test Nginx

Visit in your browser: http://123.456.789.012

You should see the "Welcome to nginx" page.


Setup DNS Records

Before configuring SSL, you need to point your domain to your VPS.

Step 1: Log into Your DNS Provider

(e.g., Hostinger, Namecheap, Cloudflare, GoDaddy)


Step 2: Add A Records

Add these DNS records:

TypeName/HostValue/Points toTTL
A@123.456.789.0123600
Awww123.456.789.0123600

Explanation:


Step 3: Wait for DNS Propagation

DNS changes can take 5 minutes to 48 hours (usually 15-30 minutes).

Test DNS resolution:

nslookup yourdomain.com
dig yourdomain.com +short

Should return: 123.456.789.012


Install SSL Certificates (Certbot)

Step 1: Install Certbot

sudo apt update
sudo apt install certbot python3-certbot-nginx -y

Step 2: Obtain SSL Certificate

āš ļø Make sure DNS is propagating first!

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

You'll be asked:

  1. Email address: Enter your email (for renewal notifications)
  2. Terms of Service: Type Y
  3. Share email with EFF: Type N (optional)
  4. Redirect HTTP to HTTPS: Type 2 (recommended)

Step 3: Verify SSL is Working

Visit: https://yourdomain.com

Should show šŸ”’ lock icon in browser.


Step 4: Test Auto-Renewal

sudo certbot renew --dry-run

Should output: Congratulations, all simulated renewals succeeded


Step 5: Verify Certbot Timer

sudo systemctl status certbot.timer

Should show active (running) - this auto-renews certificates.


Install Node.js & Package Manager

Step 1: Install NVM (Node Version Manager)

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash

Step 2: Reload Shell

source ~/.bashrc

Step 3: Install Node.js LTS

nvm install --lts
nvm use --lts
node --version
npm --version

Step 4: Install pnpm (Optional - Skip if using npm)

If you want to use pnpm instead of npm:

# Install pnpm globally
npm install -g pnpm

# Verify installation
pnpm --version

If using npm only, skip this step!


Install PM2 Process Manager

PM2 keeps your Node.js app running and restarts it if it crashes.

Step 1: Install PM2 Globally

Choose ONE based on your package manager:

For npm users:

npm install -g pm2

For pnpm users:

pnpm add -g pm2

Step 2: Verify PM2 Installation

pm2 --version
which pm2

Important: Note the path returned by which pm2 - you'll need this later!

Example output:

  • npm: /home/deployuser/.nvm/versions/node/v20.11.0/bin/pm2
  • pnpm: /home/deployuser/.local/share/pnpm/pm2

Step 3: Setup PM2 Startup Script

pm2 startup systemd

Copy and run the command it outputs (starts with sudo env PATH=...)


Setup Git SSH Authentication

Step 1: Generate SSH Key for GitHub

ssh-keygen -t ed25519 -C "your-email@example.com"

Press Enter for all prompts (no passphrase needed for automation).


Step 2: Display Public Key

cat ~/.ssh/id_ed25519.pub

Copy the entire output.


Step 3: Add SSH Key to GitHub

  1. Go to GitHub.com → Profile → Settings
  2. Click SSH and GPG keys
  3. Click New SSH key
  4. Title: VPS - yourdomain.com
  5. Key: Paste your public key
  6. Click Add SSH key

Step 4: Test GitHub Connection

ssh -T git@github.com

Type yes when prompted.

Expected output:

Hi yourusername! You've successfully authenticated...

Step 5: Configure Git

git config --global user.name "Your Name"
git config --global user.email "your-email@example.com"

Install & Configure PostgreSQL

Step 1: Install PostgreSQL

sudo apt update
sudo apt install postgresql postgresql-contrib -y

Step 2: Verify PostgreSQL is Running

sudo systemctl status postgresql

Should show active (running)


Step 3: Create Database User

sudo -u postgres psql

In PostgreSQL shell, run:

-- Create user (replace 'dbuser' and 'strong_password')
CREATE USER dbuser WITH PASSWORD 'your_strong_password_here';

-- Grant permission to create databases (for migrations)
ALTER USER dbuser CREATEDB;

-- Create database (replace 'appdb')
CREATE DATABASE appdb OWNER dbuser;

-- Grant all privileges
GRANT ALL PRIVILEGES ON DATABASE appdb TO dbuser;

-- Exit PostgreSQL
\q

Step 4: Test Database Connection

psql -U dbuser -d appdb -h localhost

Enter password when prompted.

If successful, you'll see:

appdb=>

Type \q to exit.


Deploy Your Application

Step 1: Create Application Directory

# Create directory for your app
sudo mkdir -p /var/www/yourdomain
sudo chown -R deployuser:deployuser /var/www/yourdomain

Step 2: Clone Your Repository

cd /var/www
git clone git@github.com:yourusername/yourrepo.git yourdomain
cd yourdomain

Step 3: Copy .env File from Local Machine

On your local machine:

scp -P 2222 .env deployuser@123.456.789.012:/var/www/yourdomain/

Step 4: Update .env with Production Values

On VPS:

nano /var/www/yourdomain/.env

Update with production values:

NODE_ENV=production
PORT=3000
DATABASE_URL="postgresql://dbuser:your_strong_password_here@localhost:5432/appdb?schema=public"

# Add other environment variables your app needs

Save and exit


Step 5: Install Dependencies

Choose ONE based on your package manager:

For npm users:

npm install

For pnpm users:

pnpm install

Step 6: Run Database Migrations (if using Prisma)

For npm users:

npx prisma generate
npx prisma migrate deploy

For pnpm users:

pnpm prisma generate
pnpm prisma migrate deploy

# Or if you have custom scripts in package.json:
pnpm db:generate
pnpm db:push

Step 7: Build Your Application

For npm users:

npm run build

For pnpm users:

pnpm build

Step 8: Create PM2 Ecosystem File

nano ecosystem.config.js

Add:

module.exports = {
  apps: [
    {
      name: "yourapp",
      script: "./server.js", // or './index.js' - adjust to your entry file
      instances: 1,
      exec_mode: "fork",
      autorestart: true,
      watch: false,
      max_memory_restart: "1G",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
      error_file: "./logs/err.log",
      out_file: "./logs/out.log",
      log_file: "./logs/combined.log",
      time: true,
    },
  ],
};

Adjust script to match your actual entry file!

Save and exit


Step 9: Create Logs Directory

mkdir -p logs

Step 10: Start Application with PM2

pm2 start ecosystem.config.js
pm2 save

Step 11: Verify Application is Running

pm2 status
pm2 logs yourapp --lines 50

Step 12: Test Application Locally

curl http://localhost:3000

Should return your app's HTML.


Configure Nginx Reverse Proxy

Step 1: Create Nginx Configuration

sudo nano /etc/nginx/sites-available/yourdomain

Add:

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

# HTTPS server with reverse proxy
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name yourdomain.com www.yourdomain.com;

    # SSL certificates (managed by Certbot)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Reverse proxy to Node.js app on port 3000
    location / {
        proxy_pass http://localhost:3000;
        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;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
}

Save and exit


Step 2: Enable the Site

sudo ln -s /etc/nginx/sites-available/yourdomain /etc/nginx/sites-enabled/

# Remove default nginx page (optional)
sudo rm /etc/nginx/sites-enabled/default

Step 3: Test Nginx Configuration

sudo nginx -t

Should output: syntax is ok and test is successful


Step 4: Reload Nginx

sudo systemctl reload nginx

Step 5: Test Your Website

Visit: https://yourdomain.com

āœ… You should see your application!


Setup Automated Database Backups

Step 1: Create Backup Directory

sudo mkdir -p /var/backups/postgresql
sudo chown deployuser:deployuser /var/backups/postgresql

Step 2: Create Backup Script

nano ~/backup-db.sh

Add:

#!/bin/bash

# Configuration (replace with your values)
DB_NAME="appdb"
DB_USER="dbuser"
BACKUP_DIR="/var/backups/postgresql"
DATE=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${DATE}.sql.gz"
RETENTION_DAYS=7

# Create backup
pg_dump -U $DB_USER -d $DB_NAME | gzip > $BACKUP_FILE

# Check if backup was successful
if [ $? -eq 0 ]; then
    echo "$(date): Backup successful - $BACKUP_FILE" >> /var/log/db-backup.log
else
    echo "$(date): Backup FAILED!" >> /var/log/db-backup.log
    exit 1
fi

# Delete backups older than RETENTION_DAYS
find $BACKUP_DIR -name "${DB_NAME}_*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete

echo "$(date): Old backups cleaned (older than $RETENTION_DAYS days)" >> /var/log/db-backup.log

Save and exit


Step 3: Make Script Executable

chmod +x ~/backup-db.sh

Step 4: Create PostgreSQL Password File

nano ~/.pgpass

Add (replace with your actual credentials):

localhost:5432:appdb:dbuser:your_database_password

Save and exit

Set permissions:

chmod 600 ~/.pgpass

Step 5: Create Log File

sudo touch /var/log/db-backup.log
sudo chown deployuser:deployuser /var/log/db-backup.log

Step 6: Test Backup Script

~/backup-db.sh

Verify backup was created:

ls -lh /var/backups/postgresql/
cat /var/log/db-backup.log

Step 7: Schedule Daily Backups with Cron

crontab -e

Add this line (runs daily at 2 AM):

0 2 * * * /home/deployuser/backup-db.sh

Save and exit


Step 8: Verify Cron Job

crontab -l

Restore a Backup (if needed)

# List available backups
ls -lh /var/backups/postgresql/

# Restore a specific backup
gunzip < /var/backups/postgresql/appdb_2026-02-01_02-00-00.sql.gz | psql -U dbuser -d appdb

Setup Auto-Deployment with GitHub Actions

Automatically deploy your application when you push to the main branch.


🚨 IMPORTANT: Determine Your Setup First

Before proceeding, you need to know:

  1. Which package manager you're using (npm or pnpm)
  2. The paths to your installed tools

Run these commands and save the output - you'll need them later:

# Find Node.js path
which node

# Find your package manager path
which npm    # if using npm
which pnpm   # if using pnpm

# Find PM2 path
which pm2

# Example outputs:
# /home/deployuser/.nvm/versions/node/v20.11.0/bin/node
# /home/deployuser/.nvm/versions/node/v20.11.0/bin/npm
# /home/deployuser/.local/share/pnpm/pnpm
# /home/deployuser/.nvm/versions/node/v20.11.0/bin/pm2

Write these paths down! You'll use them in Step 7.


Step 1: Create Deployment User and Directory

On your VPS:

# Already created if you followed earlier steps
# Ensure proper permissions
sudo chown -R deployuser:deployuser /var/www/yourdomain

Step 2: Generate Deployment SSH Key

On your VPS as deployuser:

# Generate a separate key for GitHub Actions
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key

Press Enter for no passphrase.


Step 3: Add Deployment Key to Authorized Keys

cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys

Step 4: Copy Private Key for GitHub Secrets

cat ~/.ssh/deploy_key

Copy the entire output (including -----BEGIN and -----END lines)


Step 5: Add Secrets to GitHub Repository

  1. Go to your GitHub repository
  2. Click Settings → Secrets and variables → Actions
  3. Click New repository secret

Add these secrets:

NameValue
SSH_PRIVATE_KEYThe private key you just copied
SSH_HOSTYour VPS IP (e.g., 123.456.789.012)
SSH_USERdeployuser
SSH_PORT2222 (your custom SSH port)

Step 6: Verify Your Package.json Scripts

On your VPS, check your package.json:

cat /var/www/yourdomain/package.json

Look for these scripts:

{
  "scripts": {
    "build": "next build",           // or your build command
    "db:generate": "prisma generate", // if using Prisma
    "db:push": "prisma db push"      // if using Prisma
  }
}

Note the exact script names - you'll use them in the next step!


Step 7: Create Deployment Script

Now create the deploy script using YOUR specific paths and package manager.

nano ~/deploy.sh

For NPM Users:

Copy this template and update the PATH with YOUR paths from the "IMPORTANT" section above:

#!/bin/bash
set -e

# ============================================
# UPDATE THIS PATH WITH YOUR ACTUAL PATHS
# Replace the path below with output from: which node && which npm && which pm2
# ============================================
export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:$PATH"

echo "Starting deployment..."

# Navigate to app directory
cd /var/www/yourdomain

# 1. Reset local changes (fixes git merge conflicts)
echo "Resetting local changes..."
git reset --hard

# 2. Pull latest changes
echo "Pulling latest code from GitHub..."
git pull origin main

# 3. Install dependencies (including devDependencies for build tools)
echo "Installing dependencies..."
npm ci

# 4. Run database migrations (if using Prisma)
echo "Running database migrations..."
npx prisma generate
npx prisma migrate deploy

# 5. Build application
echo "Building application..."
npm run build

# 6. Restart PM2 application
echo "Restarting application..."
pm2 restart yourapp

echo "Deployment completed successfully at $(date)"

For PNPM Users:

Copy this template and update the PATH with YOUR paths from the "IMPORTANT" section above:

#!/bin/bash
set -e

# ============================================
# UPDATE THESE PATHS WITH YOUR ACTUAL PATHS
# Replace the paths below with output from: which node && which pnpm && which pm2
# ============================================
export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:/home/deployuser/.local/share/pnpm:$PATH"

echo "Starting deployment..."

# Navigate to app directory
cd /var/www/yourdomain

# 1. Reset local changes (fixes git merge conflicts)
echo "Resetting local changes..."
git reset --hard

# 2. Pull latest changes
echo "Pulling latest code from GitHub..."
git pull origin main

# 3. Install dependencies (including devDependencies for build tools)
echo "Installing dependencies..."
pnpm install --frozen-lockfile

# 4. Run database migrations (if using Prisma)
echo "Running database migrations..."

# Option A: If you have custom scripts in package.json
pnpm db:generate
pnpm db:push

# Option B: If using Prisma commands directly (comment out Option A if using this)
# pnpm prisma generate
# pnpm prisma migrate deploy

# 5. Build application
echo "Building application..."
pnpm build

# 6. Restart PM2 application
echo "Restarting application..."
pm2 restart yourapp

echo "Deployment completed successfully at $(date)"

Step 8: Customize Your Deploy Script

Before saving, update these values in YOUR deploy script:

  1. PATH variable: Replace with YOUR actual paths from the "IMPORTANT" section
  2. App directory: Replace /var/www/yourdomain with your actual path
  3. Git branch: Replace main with your branch name if different
  4. Database commands: Match your package.json script names
  5. PM2 app name: Replace yourapp with your actual PM2 app name from ecosystem.config.js

Save and exit (Ctrl+X, Y, Enter)


Step 9: Make Deploy Script Executable

chmod +x ~/deploy.sh

Step 10: Test Your PATH Configuration

Before running the full deploy, verify your PATH works:

For npm users:

bash -c 'export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:$PATH"; which node && which npm && which pm2'

For pnpm users:

bash -c 'export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:/home/deployuser/.local/share/pnpm:$PATH"; which node && which pnpm && which pm2'

All commands should return paths. If any say "not found", your PATH is incorrect!


Step 11: Test Deploy Script Manually

~/deploy.sh

Watch the output carefully. Common issues:

  • āŒ "command not found" → PATH is wrong, go back to Step 7
  • āŒ "Permission denied" → Run: sudo chown -R deployuser:deployuser /var/www/yourdomain
  • āŒ Git errors → Run: ssh -T git@github.com to test GitHub access
  • āœ… "Deployment completed successfully" → Perfect! Continue to next step

Step 12: Create GitHub Actions Workflow

On your local machine, in your repository:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml

Add:

name: Deploy to VPS

on:
  push:
    branches:
      - main  # Deploy when pushing to main branch

jobs:
  deploy:
    name: Deploy Application
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            /home/deployuser/deploy.sh

Save and exit


Step 13: Commit and Push Workflow

git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions deployment workflow"
git push origin main

Step 14: Monitor Deployment

  1. Go to your GitHub repository
  2. Click Actions tab
  3. You should see your workflow running
  4. Click on the workflow run to see detailed logs

āœ… Your application will now automatically deploy whenever you push to main!


Step 15: Optional - Add Deployment Status Badge

Add this to your README.md:

![Deploy Status](https://github.com/yourusername/yourrepo/actions/workflows/deploy.yml/badge.svg)

Troubleshooting Auto-Deployment

āŒ "command not found" in GitHub Actions

Problem: Workflow logs show npm: command not found or pnpm: command not found

Solution:

  1. On VPS: Run which npm (or which pnpm)
  2. Update the export PATH= line in ~/deploy.sh
  3. Test manually: ~/deploy.sh

āŒ "overwritten by merge" Git errors

Problem:

error: Your local changes would be overwritten by merge

Solution: Already fixed with git reset --hard in the deploy script (Step 1 of deploy.sh)


āŒ Build or database migration fails

Problem: npm run build or pnpm db:push fails

For npm users:

# Check your scripts match
cat /var/www/yourdomain/package.json | grep -A 5 "scripts"

# Update deploy.sh to match your actual script names
nano ~/deploy.sh

For pnpm users:

# Check your scripts match
cat /var/www/yourdomain/package.json | grep -A 5 "scripts"

# Update deploy.sh to match your actual script names
nano ~/deploy.sh

āŒ PM2 restart fails

Problem: pm2 restart yourapp fails

Solution:

# Check PM2 is running
pm2 status

# Check your app name
cat /var/www/yourdomain/ecosystem.config.js | grep "name:"

# Update deploy.sh with correct app name
nano ~/deploy.sh

āŒ Works manually but fails in GitHub Actions

Problem: ~/deploy.sh works but GitHub Actions fails

Solution:

  1. Verify GitHub secrets are correct (Settings → Secrets → Actions)
  2. Check SSH key: ls -la ~/.ssh/deploy_key
  3. Test SSH connection: ssh -p 2222 deployuser@123.456.789.012 'echo "SSH works"'

Quick Reference: Complete Examples

NPM Example (Complete deploy.sh):

#!/bin/bash
set -e

# Update with YOUR path from: which node && which npm && which pm2
export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:$PATH"

echo "Starting deployment..."
cd /var/www/yourdomain
git reset --hard
git pull origin main
npm ci
npx prisma generate
npx prisma migrate deploy
npm run build
pm2 restart yourapp
echo "Deployment completed successfully at $(date)"

PNPM Example (Complete deploy.sh):

#!/bin/bash
set -e

# Update with YOUR paths from: which node && which pnpm && which pm2
export PATH="/home/deployuser/.nvm/versions/node/v20.11.0/bin:/home/deployuser/.local/share/pnpm:$PATH"

echo "Starting deployment..."
cd /var/www/yourdomain
git reset --hard
git pull origin main
pnpm install --frozen-lockfile
pnpm db:generate
pnpm db:push
pnpm build
pm2 restart yourapp
echo "Deployment completed successfully at $(date)"

Advanced: Deploy Only on Pull Request Merge

To deploy only when PRs are merged (not on every push):

on:
  pull_request:
    types: [closed]
    branches:
      - main

jobs:
  deploy:
    if: github.event.pull_request.merged == true
    # ... rest of the job

Advanced: Add Pre-deployment Tests

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      # For npm users:
      - run: npm ci
      - run: npm test
      
      # For pnpm users (uncomment if using pnpm):
      # - uses: pnpm/action-setup@v2
      #   with:
      #     version: 8
      # - run: pnpm install --frozen-lockfile
      # - run: pnpm test

  deploy:
    name: Deploy Application
    needs: test  # Only deploy if tests pass
    runs-on: ubuntu-latest
    # ... rest of deploy job

Configure Subdomains (Optional)

Want to host multiple apps or an API on subdomains like api.yourdomain.com?

Step 1: Add DNS Record

Add an A record for the subdomain:

TypeName/HostValue/Points toTTL
Aapi123.456.789.0123600

Or use a wildcard to allow all subdomains:

TypeName/HostValue/Points toTTL
A*123.456.789.0123600

Step 2: Create Subdomain Application

cd /var/www
git clone git@github.com:yourusername/api-repo.git api
cd api

Install dependencies:

For npm users:

npm install

For pnpm users:

pnpm install

Step 3: Start with PM2 on Different Port

# In ecosystem.config.js, use port 3001 (or any unused port)
pm2 start ecosystem.config.js
pm2 save

Step 4: Get SSL for Subdomain

sudo certbot --nginx -d api.yourdomain.com

Step 5: Create Nginx Config for Subdomain

sudo nano /etc/nginx/sites-available/api

Add:

server {
    listen 80;
    listen [::]:80;
    server_name api.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name api.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://localhost:3001;
        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;
    }
}

Step 6: Enable and Test

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

Visit: https://api.yourdomain.com


Maintenance & Troubleshooting

Common Commands

PM2 Management:

pm2 status                    # Check all apps
pm2 logs yourapp              # View app logs
pm2 logs yourapp --lines 100  # View last 100 lines
pm2 restart yourapp           # Restart app
pm2 stop yourapp              # Stop app
pm2 delete yourapp            # Remove app from PM2

Nginx Management:

sudo nginx -t                        # Test config
sudo systemctl status nginx          # Check status
sudo systemctl reload nginx          # Reload config
sudo tail -f /var/log/nginx/error.log  # View error log

PostgreSQL Management:

sudo systemctl status postgresql  # Check status
psql -U dbuser -d appdb           # Connect to database

Firewall Management:

sudo ufw status numbered  # View all rules
sudo ufw allow 8080/tcp   # Add new rule
sudo ufw delete 1         # Delete rule by number

View System Logs:

sudo journalctl -u nginx -n 50       # Last 50 Nginx logs
sudo journalctl -u postgresql -n 50  # Last 50 PostgreSQL logs

Package Manager Specific Commands

NPM:

cd /var/www/yourdomain
npm install                # Install/update dependencies
npm run build              # Build application
npm list                   # List installed packages
npm outdated               # Check for updates

PNPM:

cd /var/www/yourdomain
pnpm install               # Install/update dependencies
pnpm build                 # Build application
pnpm list                  # List installed packages
pnpm outdated              # Check for updates
pnpm store prune           # Clean unused packages

Manual Deployment

If you need to deploy manually without GitHub Actions:

~/deploy.sh

Check Tool Paths

If commands aren't working, verify your paths:

NPM setup:

which node
which npm
which pm2

PNPM setup:

which node
which pnpm
which pm2

Security Checklist

  • āœ… SSH key authentication only (no passwords)
  • āœ… Custom SSH port (not 22)
  • āœ… Root login disabled
  • āœ… Firewall enabled (UFW)
  • āœ… Automatic security updates enabled
  • āœ… SSL certificates installed
  • āœ… Database backups automated
  • āœ… Strong database password
  • āœ… .env file secured (not in git)
  • āœ… Separate deployment SSH key
  • āœ… GitHub Actions secrets configured

Common Issues & Solutions

App not starting

# Check PM2 status
pm2 status

# Check logs for errors
pm2 logs yourapp --lines 100

# Check if port is already in use
sudo lsof -i :3000

502 Bad Gateway

Causes:

  • App is not running
  • Wrong port in nginx config
  • App crashed

Solutions:

# Check if app is running
pm2 status

# Restart app
pm2 restart yourapp

# Check app logs
pm2 logs yourapp

# Verify port matches
cat /var/www/yourdomain/ecosystem.config.js | grep PORT
cat /etc/nginx/sites-available/yourdomain | grep proxy_pass

Database connection errors

Check connection string:

cat /var/www/yourdomain/.env | grep DATABASE_URL

Test connection:

psql -U dbuser -d appdb -h localhost

Check PostgreSQL is running:

sudo systemctl status postgresql

SSL certificate errors

Check expiration:

sudo certbot certificates

Renew manually:

sudo certbot renew

Check auto-renewal:

sudo systemctl status certbot.timer

GitHub Actions deployment fails

Check workflow logs:

  1. Go to GitHub repository → Actions tab
  2. Click on the failed workflow
  3. Expand each step to see errors

Common issues:

SSH connection fails:

# On VPS, verify SSH key exists
ls -la ~/.ssh/deploy_key

# Test GitHub SSH
ssh -T git@github.com

Script not found:

# Verify deploy.sh exists and is executable
ls -la ~/deploy.sh
chmod +x ~/deploy.sh

Permission errors:

# Fix ownership
sudo chown -R deployuser:deployuser /var/www/yourdomain

NPM vs PNPM Confusion

If you accidentally mixed npm and pnpm:

To switch to npm:

cd /var/www/yourdomain
rm -rf node_modules pnpm-lock.yaml
npm install

To switch to pnpm:

cd /var/www/yourdomain
rm -rf node_modules package-lock.json
pnpm install

Update your deploy.sh accordingly!


Summary

You now have a production-ready VPS with:

āœ… Secure SSH access with key authentication
āœ… Custom SSH port and disabled root login
āœ… Automatic security updates
āœ… UFW firewall protecting your server
āœ… Nginx reverse proxy with SSL
āœ… Node.js application running with PM2
āœ… Support for both npm and pnpm
āœ… PostgreSQL database with backups
āœ… Automated daily database backups
āœ… GitHub Actions CI/CD pipeline
āœ… Automatic deployments on push
āœ… Support for multiple domains/subdomains


Quick Start Commands

Connect to VPS:

ssh deployuser@123.456.789.012 -p 2222

Check app status:

pm2 status
pm2 logs yourapp

Manual deploy:

~/deploy.sh

Check website:

curl https://yourdomain.com

šŸŽ‰ Congratulations! Your application is now live, secure, and auto-deploying!


Support & Resources

Official Documentation:

Troubleshooting:

  • Check PM2 logs: pm2 logs
  • Check Nginx logs: sudo tail -f /var/log/nginx/error.log
  • Check system logs: sudo journalctl -xe

Guide created: February 2026
Stack: Ubuntu 24.04, Nginx, Node.js, npm/pnpm, PostgreSQL, PM2, Certbot, GitHub Actions
Last updated: February 2026