
Dec 23, 2025
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.
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
| 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 | 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 |
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:
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
You need one of:
SSH access to the VPS (with sudo privileges)
Root access to the VPS
Admin/sudo user on the VPS
Before installing Caddy, ensure your Node.js app is ready.
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..."}
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:
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
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
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
# Global installation
sudo npm install -g 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
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
# Check if your app is listening on port 3000
curl http://localhost:3000
# Output: {"message":"Hello from Node.js behind Caddy!","timestamp":"..."}
# 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
# 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
# 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 the Caddy service
sudo systemctl enable caddy
# Start Caddy now
sudo systemctl start caddy
# Check status
sudo systemctl status caddy
# Output: active (running) since...
The Caddyfile is Caddy's configuration file. It's simple and readable.
# Default location
sudo nano /etc/caddy/Caddyfile
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 + O to save
Press Enter to confirm
Press Ctrl + X to exit nano
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
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.
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.
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.
# 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
Caddy automatically obtains and manages SSL/TLS certificates using Let's Encrypt.
# 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
# Certificates stored here
ls -la ~/.local/share/caddy/certificates/
# If running as a service:
ls -la /root/.local/share/caddy/certificates/
# 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.
# 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":"..."}
Open your browser and visit:
https://example.com
You should see:
A green lock icon (HTTPS is working)
Your Node.js app's response
If your Node.js app has multiple routes:
# Test health check
curl https://example.com/health
# Test API
curl https://example.com/api/endpoint
Visit https://www.ssllabs.com/ssltest/ and enter your domain. It should get an A or A+ rating.
Monitor what Caddy is doing:
# 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"}
# 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"
# Show only errors
sudo journalctl -u caddy | grep -i error
Once you've completed the setup, your configuration should look like:
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
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)
# 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
# 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
# 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
# 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;
# 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
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 package list
sudo apt update
# Update Caddy
sudo apt upgrade caddy
# Caddy reloads automatically with zero downtime
# Update globally
sudo npm install -g pm2
# Restart PM2
pm2 kill
pm2 startup systemd -u user --hp /home/user
pm2 save
# View real-time CPU and memory
pm2 monit
# View one-time snapshot
pm2 status
# View disk space
df -h
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"
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.
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!

29 Dec 2025
Node.js vs Python: Which is Better for Back-End Development?

25 Dec 2025
Top 5 Animated UI Component Libraries for Frontend Developers

24 Dec 2025
Why Most Modern Apps use Kafka