logo
Step-by-Step Guide to Deploying a Node.js App on a VPS

Step-by-Step Guide to Deploying a Node.js App on a VPS

Dec 23, 2025

Introduction:

When you deploy a Node.js application to production, you face several challenges:

  1. Port Access: Node.js apps run on high ports (3000, 3001, etc.) that require special permissions. HTTP/HTTPS use ports 80 and 443, which only the root user can access directly.

  2. HTTPS Management: Setting up SSL/TLS certificates manually is complex. You need to obtain certificates, renew them before expiry, and handle multiple domains.

  3. Multiple Apps: If you want to run multiple Node.js apps on one server, you need something to route traffic to each one.

  4. Security: Exposing Node.js directly to the internet is risky. A reverse proxy sits in front and adds security layers.

  5. Performance: The reverse proxy can compress responses, cache content, and handle HTTPS termination so your app doesn't have to.

Caddy solves all of these problems. It's a modern web server that:

  • Automatically manages HTTPS certificates

  • Routes traffic to your Node.js app

  • Applies security headers and protections

  • Handles multiple domains

  • Requires minimal configuration

This guide walks you through setting up Caddy as a reverse proxy for a Node.js application, from bare server to secure production deployment.


Architecture: How It All Fits Together

Before we start, let's understand the complete architecture:

Internet
   ↓
Domain: example.com (resolves to your VPS IP)
   ↓
[Caddy Web Server] ← Listens on ports 80 & 443[PM2 Process Manager][Node.js App] ← Runs on port 3000 (internal only)
   ↓
[Database/Cache] ← Your app's data layer

Data flow:

User visits https://example.com
    ↓
Browser connects to VPS port 443 (HTTPS)
    ↓
Caddy receives the request
    ↓
Caddy applies security headers, rate limiting, etc.
    ↓
Caddy forwards request to localhost:3000 (Node.js)
    ↓
Node.js processes the request
    ↓
Node.js sends response back to Caddy
    ↓
Caddy sends response to user's browser
    ↓
User sees the result

Key benefits of this architecture:

  • Node.js doesn't handle HTTPS directly (Caddy does)

  • Node.js doesn't expose anything to the internet

  • If Node.js crashes, Caddy gracefully returns an error

  • If Node.js is updating, Caddy can wait for it to restart


Prerequisites: What You Need Before Starting

Server Requirements

RequirementDetails
Operating SystemUbuntu 20.04 LTS or later (most common)
VPS ProviderAny cloud provider (AWS, DigitalOcean, Linode, etc.)
RAMMinimum 512 MB (1 GB recommended for production)
CPUMinimum 1 core (2+ cores recommended)
StorageMinimum 10 GB (depends on app and data)
NetworkPublic IP address with ports 80 and 443 open

Software Requirements

SoftwareVersionPurpose
Node.js16.x or laterRun your application
npm or yarnLatestManage Node.js dependencies
PM2LatestProcess manager for Node.js
Caddy2.xReverse proxy and web server
curlAnyTest commands

Domain Requirements

  • A domain name (e.g., example.com)

  • Domain must be registered and pointing to your VPS IP address

  • How to check: nslookup example.com should return your VPS's IP

To point your domain to the VPS:

  1. Get your VPS IP address (shown in your VPS provider's dashboard)

  2. Go to your domain registrar (GoDaddy, Namecheap, etc.)

  3. Find "DNS Settings" or "Nameservers"

  4. Add an A record pointing to your VPS IP

  5. Wait 5-30 minutes for DNS to propagate

To verify DNS is working:

# Should return your VPS IP
nslookup example.com

# Should show your server
dig example.com

Access Requirements

You need one of:

  • SSH access to the VPS (with sudo privileges)

  • Root access to the VPS

  • Admin/sudo user on the VPS


Step 1: Prepare Your Node.js Application

Before installing Caddy, ensure your Node.js app is ready.

Create a Simple Node.js App (if you don't have one)

Create a basic Express server for testing:

# Create project directory
mkdir my-node-app
cd my-node-app

# Initialize Node.js project
npm init -y

# Install Express
npm install express

Create server.js:

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
    res.json({ 
        message: 'Hello from Node.js behind Caddy!',
        timestamp: new Date().toISOString()
    });
});

app.get('/health', (req, res) => {
    res.json({ status: 'healthy' });
});

app.listen(PORT, () => {
    console.log(`✓ Server running on http://localhost:${PORT}`);
});

Test locally:

node server.js
# Output: ✓ Server running on http://localhost:3000

# In another terminal:
curl http://localhost:3000
# Output: {"message":"Hello from Node.js behind Caddy!","timestamp":"2025-01-15..."}

Upload to Server

Transfer your app to the VPS:

# From your local machine
# Option 1: Using scp (copy files)
scp -r my-node-app user@your-vps-ip:/home/user/

# Option 2: Using git
# Push to GitHub, then git clone on the VPS

Install dependencies on the server:

Method 1: NodeSource PPA (Recommended for Production)[1]

This is the industry-standard approach for production VPS deployments, providing the latest LTS version with security updates.

Step 1: Update System and Install Dependencies

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl

Step 2: Add NodeSource Repository

For Node.js v24 LTS (current stable):

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -

To verify the script before running (security best practice):

curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
nano nodesource_setup.sh  # Review contents
sudo bash nodesource_setup.sh
rm nodesource_setup.sh

Step 3: Install Node.js and npm

sudo apt install -y nodejs build-essential

The build-essential package is critical—it installs gcc, g++, and make, required for npm packages that compile native code (bcrypt, sqlite3, etc.).

Step 4: Verify Installation

node --version    # Should show v24.x.x
npm --version     # Should show 11.x.x
npm list -g pm2   # Check if PM2 is installed globally

Method 2: NVM (Node Version Manager)[2][3]

Ideal for developers needing multiple Node.js versions or development-focused workflows.

Step 1: Install NVM

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

Step 2: Load NVM into Current Session

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Or reopen your terminal.

Step 3: Verify NVM Installation

nvm --version  # Should show v0.39.5 or similar

Step 4: Install Specific Node.js Version

nvm install node              # Latest
nvm install --lts             # Latest LTS
nvm install 20                # Specific version
nvm list                       # Show installed versions
nvm use 20                     # Switch to v20
nvm alias default 20          # Set default version

Make NVM Persistent (Add to ~/.bashrc):

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

Then reload:

source ~/.bashrc

Then Go To:

# On the VPS
cd /home/user/my-node-app
npm install

Step 2: Run Node.js with PM2 (Process Manager)

Why PM2?

Running node server.js directly has problems:

  • If the app crashes, it stays down

  • If you restart the server, the app doesn't auto-start

  • You can't easily manage multiple Node.js apps

  • It's hard to update code without downtime

PM2 solves all of these:

Process Manager (PM2)
    ├─ Monitor app health
    ├─ Auto-restart on crash
    ├─ Start on system boot
    ├─ Manage multiple apps
    ├─ Provide logs
    └─ Support zero-downtime updates

Install PM2

# Global installation
sudo npm install -g pm2

Start Your App with PM2

# Navigate to your app directory
cd /home/user/my-node-app

# Start the app with PM2
pm2 start server.js --name "node-app"

# Output:
# ┌────┬────────────────┬──────┬──────┬────────┬─────────┬────────┐
# │ id │ name           │ mode │ ↺    │ status │ cpu     │ memory  │
# ├────┼────────────────┼──────┼──────┼────────┼─────────┼─────────┤
# │ 0  │ node-app       │ fork │ 0    │ online │ 0%      │ 28.2mb  │
# └────┴────────────────┴──────┴──────┴────────┴─────────┴────────┘

What this means:

  • status: online = App is running

  • ↺ 0 = Haven't needed to restart yet

  • cpu: 0% = Not using much CPU

  • memory: 28.2mb = Using 28 MB RAM

Configure PM2 to Start on Boot

When you restart the server, you want your app to automatically start:

# Generate startup script
pm2 startup systemd -u user --hp /home/user

# This outputs a command to run. Copy and paste it:
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u user --hp /home/user

# Then save the PM2 configuration
pm2 save

Test that it's saved:

# Show PM2 configuration
pm2 status

# Output shows your app listed

Verify App Runs on Port 3000

# Check if your app is listening on port 3000
curl http://localhost:3000

# Output: {"message":"Hello from Node.js behind Caddy!","timestamp":"..."}

PM2 Common Commands

# View app status
pm2 status

# View live logs
pm2 logs node-app

# Stop the app
pm2 stop node-app

# Restart the app
pm2 restart node-app

# Delete the app from PM2
pm2 delete node-app

# Update app with zero downtime
pm2 reload node-app

# Monitor CPU/memory usage
pm2 monit

Step 3: Install Caddy on Ubuntu

Add Caddy Repository

# Update package list
sudo apt update

# Add Caddy's GPG key
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https

# Add Caddy repository
curl -1sLf 'https://dl.caddy.community/api/publish/deb/cfg/gpg/gpgkey.pub' | \
  sudo gpg --dearmor -o /usr/share/keyrings/caddy-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/caddy-archive-keyring.gpg] https://dl.caddy.community/api/publish/deb/cfg/stable/any/ any main" | \
  sudo tee /etc/apt/sources.list.d/caddy-stable.list

Install Caddy

# Update package list
sudo apt update

# Install Caddy
sudo apt install -y caddy

# Verify installation
caddy version
# Output: v2.x.x (specific version number)

Enable Caddy to Start on Boot

# Enable the Caddy service
sudo systemctl enable caddy

# Start Caddy now
sudo systemctl start caddy

# Check status
sudo systemctl status caddy
# Output: active (running) since...

Step 4: Configure Caddy as a Reverse Proxy

The Caddyfile is Caddy's configuration file. It's simple and readable.

Locate the Caddyfile

# Default location
sudo nano /etc/caddy/Caddyfile

Basic Configuration

Replace the existing content with:

example.com {
    reverse_proxy localhost:3000
}

What this configuration does:

LineMeaning
example.com {Listen on this domain
reverse_proxy localhost:3000Forward all requests to Node.js on port 3000
}End domain configuration

Save the file:

  • Press Ctrl + O to save

  • Press Enter to confirm

  • Press Ctrl + X to exit nano

Understanding Reverse Proxy Flow

Client request to https://example.com/api/users
    ↓
Caddy receives at port 443
    ↓
Caddy terminates HTTPS
    ↓
Caddy forwards HTTP request to http://localhost:3000/api/users
    ↓
Node.js receives and processes
    ↓
Node.js sends response to Caddy
    ↓
Caddy converts response to HTTPS
    ↓
Response sent to client

Step 5: Validate Caddy Configuration

Before reloading, always validate the configuration to catch errors:

# Validate syntax
sudo caddy validate --config /etc/caddy/Caddyfile

# Output on success:
# Valid configuration

If there are errors, it will tell you the line number and problem.

Common Errors

Error: missing domain name

# ❌ WRONG
reverse_proxy localhost:3000

# ✅ CORRECT
example.com {
    reverse_proxy localhost:3000
}

Error: unmarshal: line 1: unknown directive 'example.com'

This usually means there's a syntax error. Check:

  • Are there opening and closing braces { }?

  • No trailing colons after domain name

  • Proper indentation

Error: duplicate site addresses

You have the same domain listed twice. Check for duplicates.


Step 6: Reload Caddy Configuration

After validation, reload Caddy with the new configuration:

# Reload without downtime
sudo systemctl reload caddy

# Check status
sudo systemctl status caddy

Important: reload keeps existing connections active while loading the new config. This is different from restart, which stops and starts the service.

Verify Caddy Is Running

# Check Caddy process
ps aux | grep caddy
# Should show caddy process

# Check listening ports
sudo netstat -tlnp | grep caddy
# Should show ports 80 and 443

Step 7: Verify HTTPS Certificate

Caddy automatically obtains and manages SSL/TLS certificates using Let's Encrypt.

Check Certificate Status

# View certificate information
echo | openssl s_client -servername example.com -connect example.com:443

# Look for:
# - subject=CN=example.com
# - Not Before and Not After dates

View Caddy's Certificate Storage

# Certificates stored here
ls -la ~/.local/share/caddy/certificates/

# If running as a service:
ls -la /root/.local/share/caddy/certificates/

View Certificate Expiry

# Get expiry date
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -dates

# Output:
# notBefore=Jan 15 12:00:00 2025 GMT
# notAfter=Apr 15 12:00:00 2025 GMT

Caddy automatically renews certificates 30 days before expiry. You don't need to do anything.


Step 8: Test Your Setup

Test from Command Line

# Test HTTP (redirects to HTTPS)
curl -i http://example.com
# Output: HTTP/1.1 301 Moved Permanently
#         Location: https://example.com/

# Test HTTPS
curl https://example.com
# Output: {"message":"Hello from Node.js behind Caddy!","timestamp":"..."}

Test from Browser

Open your browser and visit:

https://example.com

You should see:

  • A green lock icon (HTTPS is working)

  • Your Node.js app's response

Test Specific Routes

If your Node.js app has multiple routes:

# Test health check
curl https://example.com/health

# Test API
curl https://example.com/api/endpoint

Check HTTPS Security

Visit https://www.ssllabs.com/ssltest/ and enter your domain. It should get an A or A+ rating.


Step 9: View Logs

Monitor what Caddy is doing:

Real-Time Logs

# View live logs
sudo journalctl -u caddy -f

# Example output:
# Jan 15 12:30:45 server caddy[1234]: {"level":"info","msg":"autosaved config (load returned nil)"}
# Jan 15 12:30:46 server caddy[1234]: {"level":"info","msg":"Certificates obtained successfully"}

View Past Logs

# Last 50 lines
sudo journalctl -u caddy -n 50

# Last 1 hour
sudo journalctl -u caddy --since "1 hour ago"

# By date
sudo journalctl -u caddy --since "2025-01-15"

Filter for Errors

# Show only errors
sudo journalctl -u caddy | grep -i error

Complete Working Configuration

Once you've completed the setup, your configuration should look like:

VPS Setup Summary

1. Node.js App

# Location: /home/user/my-node-app/server.js
# Running with PM2 on port 3000
# Status: pm2 status

2. PM2 Process

# Status: online
# Auto-restart: enabled
# Auto-start on boot: enabled

3. Caddy Configuration

# File: /etc/caddy/Caddyfile
example.com {
    reverse_proxy localhost:3000
}

4. Services

# Both should be running:
sudo systemctl status caddy
sudo systemctl status pm2-user

# Both should auto-start on boot
sudo systemctl is-enabled caddy
sudo systemctl is-enabled pm2-user

Now You're Ready for Advanced Security Features

Your basic setup is complete! Your Node.js app is:

  • ✅ Running in production with PM2

  • ✅ Behind a reverse proxy (Caddy)

  • ✅ Secured with automatic HTTPS

  • ✅ Accessible at your domain

Now you can add the advanced security features from the other guides:

  • Security headers (prevent XSS, clickjacking)

  • IP filtering (allow/deny specific IPs)

  • Rate limiting (prevent abuse and DDoS)

  • Basic authentication (password protect routes)

  • Request body limits (prevent large uploads)


Troubleshooting Common Issues

Issue 1: "Connection Refused" Error

# Error: curl: (7) Failed to connect to example.com port 443: Connection refused

Possible causes:

  1. DNS not propagated yet (wait 5-30 minutes)

  2. Node.js app not running

  3. Caddy not running

  4. Firewall blocking ports 80/443

Solutions:

# Check Node.js is running
pm2 status

# Check Caddy is running
sudo systemctl status caddy

# Check ports are listening
sudo netstat -tlnp | grep -E ':80|:443'

# Check firewall (if using UFW)
sudo ufw status
sudo ufw allow 80
sudo ufw allow 443

Issue 2: "Certificate Problem" Error

# Error: curl: (60) SSL certificate problem

Possible causes:

  1. Certificate not obtained yet (wait 5 minutes)

  2. Domain not pointing to this server

  3. Firewall blocking certificate renewal

Solutions:

# Check DNS points to your server
nslookup example.com

# Wait for certificate
sleep 30
curl https://example.com

# Check Caddy logs for certificate errors
sudo journalctl -u caddy | grep -i cert

Issue 3: "502 Bad Gateway"

# Error from browser: 502 Bad Gateway

Causes: Node.js app is down or not listening on port 3000

Solutions:

# Check if Node.js is running
pm2 status
# Should show "online" status

# If not running, restart it
pm2 restart node-app

# Check if it's listening on port 3000
curl http://localhost:3000

# View Node.js logs
pm2 logs node-app

Issue 4: "Port Already in Use"

# Error: listen EADDRINUSE :::3000

Something else is using port 3000

Solutions:

# Find what's using port 3000
sudo lsof -i :3000

# Kill the process
sudo kill -9 <PID>

# Or use a different port in your Node.js app
# Change: const PORT = 3001;

Issue 5: Caddy Won't Start

# Error: Unit caddy.service failed to load

Solutions:

# Check if Caddyfile is valid
sudo caddy validate --config /etc/caddy/Caddyfile

# Check permissions on Caddyfile
ls -la /etc/caddy/Caddyfile
# Should be readable by caddy user

# Check Caddy logs
sudo journalctl -u caddy -n 50

Maintenance and Updates

Update Node.js App (Zero Downtime)

When you update your code without stopping the server:

# Pull latest code
cd /home/user/my-node-app
git pull

# Install new dependencies
npm install

# Reload Node.js with zero downtime
pm2 reload node-app

# Verify it's running
pm2 status

Update Caddy

# Update package list
sudo apt update

# Update Caddy
sudo apt upgrade caddy

# Caddy reloads automatically with zero downtime

Update PM2

# Update globally
sudo npm install -g pm2

# Restart PM2
pm2 kill
pm2 startup systemd -u user --hp /home/user
pm2 save

Monitor Server Resources

# View real-time CPU and memory
pm2 monit

# View one-time snapshot
pm2 status

# View disk space
df -h

Security Best Practices for This Setup

  1. Keep system updated:

    sudo apt update && sudo apt upgrade -y
    
  2. Use strong passwords:

    • Change your VPS root password

    • Don't use default credentials

  3. Enable firewall:

    sudo ufw enable
    sudo ufw allow 22/tcp    # SSH
    sudo ufw allow 80/tcp    # HTTP
    sudo ufw allow 443/tcp   # HTTPS
    
  4. Monitor your application:

    # Check logs regularly
    pm2 logs
    sudo journalctl -u caddy -f
    
  5. Backup your data:

    • Regular backups of your VPS

    • Use your provider's snapshot feature

  6. Use environment variables for secrets:

    # Don't commit secrets to git!
    export DATABASE_URL="postgres://..."
    export API_KEY="secret-key"
    pm2 start server.js --name "node-app"
    

Key Takeaways

  1. PM2 keeps your Node.js app running reliably in production with auto-restart and auto-start features.

  2. Caddy handles HTTPS automatically using Let's Encrypt, no manual certificate management needed.

  3. Reverse proxy architecture separates concerns - Caddy handles web serving, Node.js focuses on business logic.

  4. Always validate before reloading - Check your configuration syntax to avoid downtime.

  5. Monitor your setup - Check logs and process status regularly to catch issues early.

  6. This is the foundation - Once this is working, you can add all the advanced security features from the other guides.


Next Steps

Now that your basic setup is working, you can:

  1. Add Security Headers - Protect against XSS and other attacks

  2. Enable Rate Limiting - Prevent abuse and DDoS

  3. Add IP Filtering - Allow/deny specific networks

  4. Implement Basic Auth - Password protect sensitive routes

  5. Limit Upload Sizes - Prevent disk exhaustion

All of these are simple additions to your Caddyfile!


Further Reading