Complete VPS Deployment Guide: Node.js/Next.js Production Setup
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
- Prerequisites
- Initial VPS Setup & SSH Access
- Create Sudo User & SSH Key Authentication
- Harden SSH Security
- Setup Automatic Security Updates
- Configure Firewall (UFW)
- Install & Configure Nginx
- Setup DNS Records
- Install SSL Certificates (Certbot)
- Install Node.js & Package Manager
- Install PM2 Process Manager
- Setup Git SSH Authentication
- Install & Configure PostgreSQL
- Deploy Your Application
- Configure Nginx Reverse Proxy
- Setup Automated Database Backups
- Setup Auto-Deployment with GitHub Actions
- Configure Subdomains (Optional)
- 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:
| Type | Name/Host | Value/Points to | TTL |
|---|---|---|---|
| A | @ | 123.456.789.012 | 3600 |
| A | www | 123.456.789.012 | 3600 |
Explanation:
@= root domain (yourdomain.com)www= www subdomain (www.yourdomain.com)
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:
- Email address: Enter your email (for renewal notifications)
- Terms of Service: Type
Y - Share email with EFF: Type
N(optional) - 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
- Go to GitHub.com ā Profile ā Settings
- Click SSH and GPG keys
- Click New SSH key
- Title:
VPS - yourdomain.com - Key: Paste your public key
- 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:
- Which package manager you're using (npm or pnpm)
- 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
- Go to your GitHub repository
- Click Settings ā Secrets and variables ā Actions
- Click New repository secret
Add these secrets:
| Name | Value |
|---|---|
SSH_PRIVATE_KEY | The private key you just copied |
SSH_HOST | Your VPS IP (e.g., 123.456.789.012) |
SSH_USER | deployuser |
SSH_PORT | 2222 (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:
- PATH variable: Replace with YOUR actual paths from the "IMPORTANT" section
- App directory: Replace
/var/www/yourdomainwith your actual path - Git branch: Replace
mainwith your branch name if different - Database commands: Match your package.json script names
- PM2 app name: Replace
yourappwith your actual PM2 app name fromecosystem.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.comto 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
- Go to your GitHub repository
- Click Actions tab
- You should see your workflow running
- 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:

Troubleshooting Auto-Deployment
ā "command not found" in GitHub Actions
Problem: Workflow logs show npm: command not found or pnpm: command not found
Solution:
- On VPS: Run
which npm(orwhich pnpm) - Update the
export PATH=line in~/deploy.sh - 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:
- Verify GitHub secrets are correct (Settings ā Secrets ā Actions)
- Check SSH key:
ls -la ~/.ssh/deploy_key - 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:
| Type | Name/Host | Value/Points to | TTL |
|---|---|---|---|
| A | api | 123.456.789.012 | 3600 |
Or use a wildcard to allow all subdomains:
| Type | Name/Host | Value/Points to | TTL |
|---|---|---|---|
| A | * | 123.456.789.012 | 3600 |
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:
- Go to GitHub repository ā Actions tab
- Click on the failed workflow
- 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:
- Node.js: https://nodejs.org/docs
- npm: https://docs.npmjs.com
- pnpm: https://pnpm.io/motivation
- PM2: https://pm2.keymetrics.io/docs
- Nginx: https://nginx.org/en/docs
- PostgreSQL: https://www.postgresql.org/docs
- GitHub Actions: https://docs.github.com/actions
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