Back

Complete VPS Deployment Guide: Production Setup

DevOps & DeploymentAugust 9, 2026Marc Tyson CLEBERT

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

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, NVM, and PM2
  11. Setup Git SSH Authentication
  12. Install & Configure PostgreSQL
  13. Deploy Your Application
  14. Configure Nginx Reverse Proxy
  15. Setup Automated Database Backups
  16. Configure Subdomains (Optional)
  17. Maintenance & Troubleshooting

Prerequisites

Before you start, make sure you have:

  • A VPS from a provider like DigitalOcean, Linode, Hetzner, or Vultr, running Ubuntu 22.04 LTS (this guide assumes Ubuntu; adjust package manager commands if you use Debian/CentOS).
  • Root (or sudo) access to the server, delivered via email or your provider's dashboard.
  • A domain name you control, with access to its DNS settings.
  • A local machine with an SSH client (built into macOS/Linux; use PowerShell or PuTTY on Windows).
  • Your application's source code in a Git repository (GitHub, GitLab, or Bitbucket).
  • Basic familiarity with the Linux command line.

Recommended minimum VPS specs for a small-to-medium Next.js app with PostgreSQL: 2 vCPUs, 4GB RAM, 50GB SSD. You can go smaller (1 vCPU/1GB) for low-traffic side projects, but builds may be slow or require swap.


Initial VPS Setup & SSH Access

When your VPS is provisioned, you'll typically receive a root password and an IP address. Connect for the first time:

ssh root@YOUR_SERVER_IP

Accept the fingerprint prompt and enter your password. Once in, update the package index and upgrade existing packages:

apt update && apt upgrade -y

Set the server's hostname and timezone (useful for logs and cron jobs):

hostnamectl set-hostname myapp-prod
timedatectl set-timezone UTC

Reboot if the kernel was upgraded:

reboot

Wait a minute, then reconnect.


Create Sudo User & SSH Key Authentication

Operating as root for daily tasks is risky — a single mistyped command can take down the whole system, and it's the first account attackers target. Create a dedicated user instead.

adduser deploy
usermod -aG sudo deploy

adduser will prompt for a password — choose a strong one, or better, disable password login entirely later once key auth works.

Generate an SSH key pair (on your local machine)

ssh-keygen -t ed25519 -C "deploy@myapp"

Press Enter to accept the default path (~/.ssh/id_ed25519) and optionally set a passphrase (recommended).

Copy the public key to the server

ssh-copy-id deploy@YOUR_SERVER_IP

If ssh-copy-id isn't available (e.g., on Windows), copy it manually:

cat ~/.ssh/id_ed25519.pub | ssh root@YOUR_SERVER_IP "mkdir -p /home/deploy/.ssh && cat >> /home/deploy/.ssh/authorized_keys"
ssh root@YOUR_SERVER_IP "chown -R deploy:deploy /home/deploy/.ssh && chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys"

Test that key-based login works before closing your root session:

ssh deploy@YOUR_SERVER_IP

Harden SSH Security

With key auth confirmed working, lock down the SSH daemon. Edit the config:

sudo nano /etc/ssh/sshd_config

Set (or add) the following:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Port 22
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2

Changing the SSH port away from 22 (e.g., to something like 2222) cuts down significantly on automated bot noise in your logs, though it's obscurity, not real security — keep the other hardening steps regardless.

Restart SSH to apply changes:

sudo systemctl restart sshd

Before disconnecting, open a second terminal and confirm you can still log in with the new settings. If you get locked out, most VPS providers offer a web-based console to fix sshd_config from their dashboard.

Optionally install Fail2ban to automatically block IPs after repeated failed login attempts:

sudo apt install fail2ban -y
sudo systemctl enable --now fail2ban

The default sshd jail is enabled out of the box and will ban offending IPs after a handful of failed attempts.


Setup Automatic Security Updates

Keep the OS patched against known vulnerabilities without manual intervention:

sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades

Confirm "Yes" when prompted. This installs security patches automatically; review /etc/apt/apt.conf.d/50unattended-upgrades if you want to also apply general package updates or configure automatic reboots for kernel upgrades.


Configure Firewall (UFW)

ufw (Uncomplicated Firewall) is a friendly wrapper around iptables. Only open the ports you actually need:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH        # or your custom SSH port, e.g. sudo ufw allow 2222/tcp
sudo ufw allow 80/tcp         # HTTP
sudo ufw allow 443/tcp        # HTTPS
sudo ufw enable

Verify:

sudo ufw status verbose

Do not open your Node.js app port (commonly 3000) or your database port (5432) to the public internet — those should only be reachable locally, via Nginx and the app respectively.


Install & Configure Nginx

Nginx will serve as your reverse proxy, terminate SSL, and handle static asset caching.

sudo apt install nginx -y
sudo systemctl enable --now nginx

Visit http://YOUR_SERVER_IP in a browser — you should see the default Nginx welcome page, confirming it's running and reachable through the firewall.

A minimal initial server block (before SSL is configured) at /etc/nginx/sites-available/myapp:

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

    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_cache_bypass $http_upgrade;
    }
}

Enable the site and remove the default:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

nginx -t tests the config syntax before reloading — always run it after any edit to avoid taking the site down on a typo.


Setup DNS Records

At your domain registrar or DNS provider, create the following records pointing to your VPS's IP address:

TypeHostValueTTL
A@YOUR_SERVER_IP3600
AwwwYOUR_SERVER_IP3600

DNS propagation can take anywhere from a few minutes to 48 hours depending on your provider and previous TTL settings. Check propagation status with:

dig example.com +short

Once this returns your server's IP consistently, you're ready to issue SSL certificates.


Install SSL Certificates (Certbot)

Certbot automates Let's Encrypt certificate issuance and renewal, integrating directly with Nginx.

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com

Certbot will:

  1. Verify domain ownership via an HTTP challenge.
  2. Obtain the certificate.
  3. Automatically edit your Nginx config to add the listen 443 ssl block and redirect HTTP → HTTPS.

Confirm auto-renewal is scheduled (Certbot installs a systemd timer or cron job by default):

sudo systemctl status certbot.timer
sudo certbot renew --dry-run

The dry run simulates a renewal without actually replacing your certificate, so you can safely confirm the process works.


Install Node.js, NVM, and PM2

Use NVM (Node Version Manager) rather than the distro's package manager — it lets you pin an exact Node version per project and switch easily.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm use --lts
node -v
npm -v

Install PM2, a production process manager that keeps your app running, restarts it on crash, and manages logs:

npm install -g pm2

Configure PM2 to start on server boot:

pm2 startup systemd

This prints a command with your specific user/paths — copy and run the exact line it outputs (it needs sudo).


Setup Git SSH Authentication

To pull private repositories without typing a password each deploy, generate a dedicated deploy key on the VPS:

ssh-keygen -t ed25519 -C "vps-deploy-key" -f ~/.ssh/id_ed25519_deploy

Add the public key (~/.ssh/id_ed25519_deploy.pub) to your Git provider:

  • GitHub: Repo → Settings → Deploy keys → Add deploy key (read-only is sufficient for pulling).
  • GitLab: Repo → Settings → Repository → Deploy keys.

Configure SSH to use this key for the Git host by editing ~/.ssh/config:

Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_deploy
    IdentitiesOnly yes

Test the connection:

ssh -T git@github.com

Install & Configure PostgreSQL

sudo apt install postgresql postgresql-contrib -y
sudo systemctl enable --now postgresql

Create a database and a dedicated (non-superuser) application user:

sudo -u postgres psql

Inside the psql prompt:

CREATE DATABASE myapp_production;
CREATE USER myapp_user WITH ENCRYPTED PASSWORD 'use-a-long-random-password-here';
GRANT ALL PRIVILEGES ON DATABASE myapp_production TO myapp_user;
\q

By default, PostgreSQL only listens on localhost, which is what you want — your Node.js app connects locally, and the database is never exposed to the internet. Verify this in /etc/postgresql/*/main/postgresql.conf (listen_addresses = 'localhost') and leave pg_hba.conf restricted to local/peer connections unless you have a specific reason to allow remote access.

Generate a strong random password instead of typing one by hand:

openssl rand -base64 24

Deploy Your Application

Clone your repository into a sensible location, e.g. /var/www/:

sudo mkdir -p /var/www
sudo chown deploy:deploy /var/www
cd /var/www
git clone git@github.com:yourusername/myapp.git
cd myapp

Create a production environment file (never commit this to Git):

nano .env.production
DATABASE_URL="postgresql://myapp_user:your-password@localhost:5432/myapp_production"
NODE_ENV=production
NEXTAUTH_URL=https://example.com
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32

Install dependencies and build:

npm ci
npm run build

npm ci (rather than npm install) uses the exact versions from package-lock.json, which is what you want for reproducible production builds.

Start the app under PM2:

pm2 start npm --name "myapp" -- start
pm2 save

pm2 save persists the current process list so it's restored automatically after the pm2 startup boot hook runs.

Check status and logs:

pm2 status
pm2 logs myapp

Configure Nginx Reverse Proxy

Update the Nginx config (already partially set up by Certbot) with production-grade headers and settings:

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

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

    # 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;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    client_max_body_size 10M;

    location /_next/static/ {
        proxy_pass http://localhost:3000;
        proxy_cache_valid 60m;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    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;
    }
}

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Setup Automated Database Backups

Before scheduling anything with cron, set up password-less authentication for pg_dump — cron jobs have no terminal to type a password into, so without this the backup will hang waiting for input (or fail outright) the first time it runs unattended. Create a ~/.pgpass file for the deploy user:

echo "localhost:5432:myapp_production:myapp_user:your-password-here" > ~/.pgpass
chmod 600 ~/.pgpass

pg_dump and other libpq-based tools automatically pick up matching credentials from this file — no PGPASSWORD environment variable or interactive prompt needed. The file must be chmod 600; PostgreSQL's client libraries silently ignore it otherwise.

Create a backup script at /home/deploy/scripts/backup-db.sh:

#!/bin/bash
BACKUP_DIR="/home/deploy/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="myapp_production"

mkdir -p "$BACKUP_DIR"
pg_dump -U myapp_user -h localhost "$DB_NAME" | gzip > "$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz"

# Keep only the last 7 days of local backups
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete

Make it executable:

chmod +x /home/deploy/scripts/backup-db.sh

Schedule it daily with cron:

crontab -e

Add:

0 2 * * * /home/deploy/scripts/backup-db.sh >> /home/deploy/backups/backup.log 2>&1

This runs at 2 AM server time daily. Local backups protect against accidental data deletion, but they live on the same disk as your database — for real disaster recovery, sync them off-server. A simple option is the rclone tool, which supports S3, Backblaze B2, Google Drive, and most other cloud storage:

sudo apt install rclone -y
rclone config   # walk through setup for your storage provider

Then add a second cron line (or append to the backup script) to push the latest archive off-box after each backup runs.


Configure Subdomains (Optional)

To run additional services (e.g., an API on api.example.com or a staging environment on staging.example.com), add a DNS A record for each subdomain pointing to the same server IP, then create a matching Nginx server block:

server {
    listen 443 ssl http2;
    server_name api.example.com;

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

    location / {
        proxy_pass http://localhost:4000;   # different port for the API process
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Issue a certificate for the new subdomain:

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

Run the second app under its own PM2 process name and port so it doesn't conflict with the primary app:

pm2 start npm --name "myapp-api" -- start -- --port 4000
pm2 save

Maintenance & Troubleshooting

Check application logs:

pm2 logs myapp --lines 100

Check Nginx error logs:

sudo tail -f /var/log/nginx/error.log

Restart the app after a code change:

cd /var/www/myapp
git pull
npm ci
npm run build
pm2 restart myapp

Check disk space (builds and logs can fill up a small VPS surprisingly fast):

df -h
du -sh /var/www/myapp/.next

Check memory usage and running processes:

free -h
pm2 monit

Common issues:

SymptomLikely CauseFix
502 Bad GatewayApp isn't running or crashedpm2 status, check pm2 logs, restart with pm2 restart myapp
SSL certificate errorsCert expired or DNS changedsudo certbot renew, verify DNS with dig
"Connection refused" on DBPostgreSQL not running or wrong credentialssudo systemctl status postgresql, check .env.production
Server unresponsive after deployBuild ran out of memory on a small VPSAdd swap (see below), or build on CI and deploy artifacts
Locked out of SSHFirewall or sshd_config misconfigurationUse provider's web console to fix, then re-test in a second session before closing the first

Add swap space (helpful on 1–2GB RAM VPS during npm run build):

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Routine checklist:

  • Review pm2 logs and journalctl -u nginx periodically for errors.
  • Confirm certbot renew --dry-run still succeeds every few months.
  • Confirm backups are actually restorable — periodically test restoring a .sql.gz dump to a scratch database.
  • Keep the OS and Node.js LTS version current; review unattended-upgrades logs at /var/log/unattended-upgrades/.

With SSH hardened, a firewall in place, SSL terminated at Nginx, your app supervised by PM2, and backups running on a schedule, you have a solid, low-maintenance baseline for running a Node.js/Next.js app in production.