Introduction:
When you deploy a Node.js application to production, you face several challenges:
-
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.
-
HTTPS Management: Setting up SSL/TLS certificates manually is complex. You need to obtain certificates, renew them before expiry, and handle multiple domains.
-
Multiple Apps: If you want to run multiple Node.js apps on one server, you need something to route traffic to each one.
-
Security: Exposing Node.js directly to the internet is risky. A reverse proxy sits in front and adds security layers.
-
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
| Requirement | Details |
|---|---|
| Operating System | Ubuntu 20.04 LTS or later (most common) |
| VPS Provider | Any cloud provider (AWS, DigitalOcean, Linode, etc.) |
| RAM | Minimum 512 MB (1 GB recommended for production) |
| CPU | Minimum 1 core (2+ cores recommended) |
| Storage | Minimum 10 GB (depends on app and data) |
| Network | Public IP address with ports 80 and 443 open |
Software Requirements
| Software | Version | Purpose |
|---|---|---|
| Node.js | 16.x or later | Run your application |
| npm or yarn | Latest | Manage Node.js dependencies |
| PM2 | Latest | Process manager for Node.js |
| Caddy | 2.x | Reverse proxy and web server |
| curl | Any | Test 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.comshould return your VPS’s IP
To point your domain to the VPS:
-
Get your VPS IP address (shown in your VPS provider’s dashboard)
-
Go to your domain registrar (GoDaddy, Namecheap, etc.)
-
Find “DNS Settings” or “Nameservers”
-
Add an A record pointing to your VPS IP
-
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:
| Line | Meaning |
|---|---|
example.com { |
Listen on this domain |
reverse_proxy localhost:3000 |
Forward all requests to Node.js on port 3000 |
} |
End domain configuration |
Save the file:
-
Press
Ctrl + Oto save -
Press
Enterto confirm -
Press
Ctrl + Xto 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:
-
DNS not propagated yet (wait 5-30 minutes)
-
Node.js app not running
-
Caddy not running
-
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:
-
Certificate not obtained yet (wait 5 minutes)
-
Domain not pointing to this server
-
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
-
Keep system updated:
sudo apt update && sudo apt upgrade -y -
Use strong passwords:
-
Change your VPS root password
-
Don’t use default credentials
-
-
Enable firewall:
sudo ufw enable sudo ufw allow 22/tcp # SSH sudo ufw allow 80/tcp # HTTP sudo ufw allow 443/tcp # HTTPS -
Monitor your application:
# Check logs regularly pm2 logs sudo journalctl -u caddy -f -
Backup your data:
-
Regular backups of your VPS
-
Use your provider’s snapshot feature
-
-
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
-
PM2 keeps your Node.js app running reliably in production with auto-restart and auto-start features.
-
Caddy handles HTTPS automatically using Let’s Encrypt, no manual certificate management needed.
-
Reverse proxy architecture separates concerns – Caddy handles web serving, Node.js focuses on business logic.
-
Always validate before reloading – Check your configuration syntax to avoid downtime.
-
Monitor your setup – Check logs and process status regularly to catch issues early.
-
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:
-
Add Security Headers – Protect against XSS and other attacks
-
Enable Rate Limiting – Prevent abuse and DDoS
-
Add IP Filtering – Allow/deny specific networks
-
Implement Basic Auth – Password protect sensitive routes
-
Limit Upload Sizes – Prevent disk exhaustion
All of these are simple additions to your Caddyfile!













