How to Deploy a Next.js Full-Stack App on a VPS with PostgreSQL and PM2

How to Deploy a Next.js Full-Stack App on a VPS with PostgreSQL and PM2

Next.js has evolved from a React framework into a complete full-stack platform β€” API routes, server components, middleware, and database access all in one codebase. Deploying it on a VPS gives you more control than Vercel (no cold starts, no function timeout limits, no surprise egress bills) while delivering the same full-stack capability.

This guide deploys a full-stack Next.js application on Ubuntu VPS: PostgreSQL for the database, Prisma as the ORM, PM2 for process management, Nginx as the reverse proxy, and Let’s Encrypt for SSL.

The Full-Stack Setup

Component Role
Next.js Full-stack framework (frontend + API routes + SSR)
PostgreSQL Primary database
Prisma Type-safe ORM for database access
PM2 Process manager β€” keeps Next.js running 24/7
Nginx Reverse proxy β€” SSL termination, static file serving

πŸ’‘ VPS.DO Tip: Next.js with PostgreSQL runs well on 2 vCPU / 4 GB RAM. VPS.DO’s USA VPS 500SSD plan is a solid production foundation for most Next.js apps. View Plans β†’


Step 1: Server Setup

sudo apt update && sudo apt upgrade -y

# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

# Install PM2 globally
sudo npm install -g pm2

# Install Nginx and Certbot
sudo apt install -y nginx certbot python3-certbot-nginx

# Install PostgreSQL
sudo apt install -y postgresql postgresql-contrib

sudo systemctl enable postgresql nginx
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable

Step 2: Set Up PostgreSQL

sudo -u postgres psql
CREATE DATABASE myapp_production;
CREATE USER myapp_user WITH PASSWORD 'SecureDBPassword123!';
GRANT ALL PRIVILEGES ON DATABASE myapp_production TO myapp_user;
\c myapp_production
GRANT ALL ON SCHEMA public TO myapp_user;
\q
# Test connection
psql postgresql://myapp_user:SecureDBPassword123!@localhost/myapp_production

Step 3: Deploy Your Next.js Application

Option A: Clone from Git

sudo apt install -y git
sudo mkdir -p /var/www/myapp
sudo chown -R $USER:$USER /var/www/myapp
cd /var/www/myapp
git clone https://github.com/yourusername/your-nextjs-app.git .
npm ci

Option B: Create a sample Next.js app (for testing)

cd /var/www
npx create-next-app@latest myapp --typescript --tailwind --app
cd myapp
npm install @prisma/client prisma
npx prisma init

Step 4: Configure Environment Variables

nano /var/www/myapp/.env.production
# Database
DATABASE_URL="postgresql://myapp_user:SecureDBPassword123!@localhost:5432/myapp_production"

# Next.js
NEXTAUTH_URL="https://yourdomain.com"
NEXTAUTH_SECRET="your-32-char-random-secret-here"

# Application
NODE_ENV=production
NEXT_PUBLIC_APP_URL="https://yourdomain.com"

# Add your app-specific vars here
API_KEY="your-api-key"
SMTP_HOST="smtp.youremail.com"
chmod 600 /var/www/myapp/.env.production

Step 5: Configure Prisma and Run Migrations

cd /var/www/myapp

# If using Prisma, generate client and run migrations
npx prisma generate
npx prisma migrate deploy  # Apply all pending migrations

# Seed database if needed
npx prisma db seed

Step 6: Build the Next.js Application

cd /var/www/myapp
NODE_ENV=production npm run build

The build output goes to .next/. The next start command serves this built output.

Step 7: Configure PM2

nano /var/www/myapp/ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'myapp',
      script: 'node_modules/.bin/next',
      args: 'start',
      cwd: '/var/www/myapp',
      instances: 'max',          // One per CPU core (cluster mode)
      exec_mode: 'cluster',
      watch: false,
      max_memory_restart: '1G',
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      // Load from .env.production
      node_args: '--env-file=.env.production',
      error_file: '/var/log/myapp/error.log',
      out_file: '/var/log/myapp/out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
    },
  ],
};
sudo mkdir -p /var/log/myapp
sudo chown $USER:$USER /var/log/myapp

# Start with PM2
pm2 start ecosystem.config.js --env production

# Verify it's running
pm2 status
pm2 logs myapp --lines 20

# Test locally
curl http://localhost:3000

Enable auto-start on reboot

pm2 startup
# Run the command it outputs
pm2 save

Step 8: Configure Nginx

sudo nano /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    server_tokens off;

    # Serve Next.js static files directly (faster than proxying)
    location /_next/static/ {
        alias /var/www/myapp/.next/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Serve public folder directly
    location /public/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }

    # Favicon and other root static files
    location = /favicon.ico {
        alias /var/www/myapp/public/favicon.ico;
        access_log off;
    }

    # Proxy everything else to Next.js
    location / {
        proxy_pass http://127.0.0.1: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;

        # Increase timeout for slow API routes
        proxy_read_timeout 60s;
    }

    access_log /var/log/nginx/myapp.access.log;
    error_log  /var/log/nginx/myapp.error.log;
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Your Next.js app is now live at https://yourdomain.com. βœ…


Step 9: Zero-Downtime Deployment Script

nano ~/deploy.sh
#!/bin/bash
set -e

APP_DIR="/var/www/myapp"
APP_NAME="myapp"

echo "πŸš€ Starting deployment at $(date)"

cd $APP_DIR

# Pull latest code
echo "πŸ“₯ Pulling latest code..."
git pull origin main

# Install dependencies (only if package.json changed)
echo "πŸ“¦ Installing dependencies..."
npm ci --only=production

# Generate Prisma client (if schema changed)
echo "πŸ—„οΈ  Generating Prisma client..."
npx prisma generate

# Run database migrations
echo "πŸ”„ Running migrations..."
npx prisma migrate deploy

# Build Next.js
echo "πŸ—οΈ  Building Next.js..."
NODE_ENV=production npm run build

# Zero-downtime reload with PM2
echo "♻️  Reloading PM2 cluster..."
pm2 reload $APP_NAME

# Verify deployment
sleep 5
if pm2 show $APP_NAME | grep -q "online"; then
    echo "βœ… Deployment successful!"
    pm2 status
else
    echo "❌ Deployment failed β€” check pm2 logs"
    exit 1
fi
chmod +x ~/deploy.sh

Run ~/deploy.sh for every future deployment. PM2’s cluster reload replaces workers one at a time β€” zero downtime.


Step 10: Database Backups

nano ~/backup-db.sh
#!/bin/bash
DATE=$(date +%Y-%m-%d_%H-%M)
BACKUP_DIR="/var/backups/myapp"
mkdir -p $BACKUP_DIR

pg_dump -U myapp_user -h localhost myapp_production \
  | gzip > $BACKUP_DIR/db-$DATE.sql.gz

find $BACKUP_DIR -name "*.sql.gz" -mtime +14 -delete
echo "Database backup complete: $DATE"
chmod +x ~/backup-db.sh
crontab -e
# 0 2 * * * /bin/bash /root/backup-db.sh

Next.js Specific Optimizations

Enable standalone output (smaller deployment)

// next.config.js
module.exports = {
  output: 'standalone',  // Creates a minimal production bundle
};

With standalone mode, the .next/standalone directory contains everything needed to run the app β€” no node_modules required in production:

# Update PM2 to run standalone
# In ecosystem.config.js:
script: '.next/standalone/server.js'

Configure Next.js for your domain

// next.config.js
module.exports = {
  // Allow images from specific domains
  images: {
    domains: ['yourdomain.com', 'cdn.yourdomain.com'],
  },

  // Set base URL for all environments
  env: {
    NEXT_PUBLIC_BASE_URL: process.env.NEXTAUTH_URL,
  },
};

Add Redis caching for API routes

npm install ioredis

// lib/redis.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export default redis;

// pages/api/products.js
import redis from '@/lib/redis';

export default async function handler(req, res) {
  const cached = await redis.get('products');
  if (cached) return res.json(JSON.parse(cached));

  const products = await fetchFromDatabase();
  await redis.setex('products', 300, JSON.stringify(products)); // Cache 5 min
  res.json(products);
}

Monitoring Your Next.js App

# Real-time logs
pm2 logs myapp

# CPU and memory dashboard
pm2 monit

# Request metrics (from Nginx logs)
sudo tail -f /var/log/nginx/myapp.access.log | \
  awk '{print $7, $9}' | sort | uniq -c | sort -rn | head -20

# Database connections
sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname='myapp_production';"

Final Thoughts

Deploying Next.js on a VPS gives you a production environment with no function timeout limits, no cold starts, predictable costs, and complete control over your infrastructure. The PM2 cluster mode uses all available CPU cores, Nginx caches static assets directly, and PostgreSQL runs on the same machine for sub-millisecond database queries.

For most Next.js applications serving under 10,000 daily users, a $20/month VPS.DO plan with this configuration performs comparably to significantly more expensive managed platforms β€” while giving you full visibility and control.

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!