Node.js App #
Caddy is very well suited as a reverse proxy server in front of your Node.js applications. In modern web architecture, running a Node.js application (like Express, Fastify, or NestJS) directly facing the public internet is not recommended. Node.js applications should focus on handling business logic and database operations, while infrastructure tasks — like automatic SSL/TLS termination, Gzip/Zstd content compression, security header management, and high-performance static file serving — are fully delegated to Caddy at the front gate. This collaboration not only improves your application’s performance and responsiveness, but also provides layered security protection for your internal server. We’ll thoroughly review the Node.js deployment architecture behind Caddy, practice industry-standard production configurations, manage Node.js process lifecycles using the PM2 process manager, do multi-instance load balancing, configure transparent Socket.io WebSocket handling, and compose zero-downtime deployment automation scripts.
Deployment Architecture #
Before diving into configuration details, let’s study the request data flow structure from the user’s browser to being processed by the Node.js runtime on your server.
In a reverse proxy architecture, Caddy acts as the single intermediary receiving all encrypted HTTPS traffic (port 443) from the outside internet. Caddy handles the TLS handshake and certificate validation, decrypts the data packets, then forwards the request locally through a clean internal HTTP network (port 3000) to the Node.js process running on the same machine or in your local virtual network (VPC).
flowchart TD
Internet["Internet (HTTPS - Port 443)"] --> Caddy["Caddy<br>(TLS Termination, Caching, Compression, Serves Static Assets)"]
Caddy -->|"Local HTTP (Port 3000)"| Node["Node.js<br>(Express / Fastify / NestJS App under PM2)"]
Node --> DB["Database / Microservices"]
style Caddy stroke:#0288d1,stroke-width:2px
style Node stroke:#43a047,stroke-width:2pxWith the topology above, your Node.js application is safely isolated from direct internet port exposure, minimizing the risk of runtime security hole exploitation.
Basic Configuration #
In its simplest form, configuring Caddy to route domain traffic to a Node.js application only requires one simple directive in the Caddyfile:
# The most basic Node.js reverse proxy configuration
example.com {
reverse_proxy localhost:3000
}
Although very minimalist, Caddy automatically does the following great things in the background:
- Requests free SSL/TLS certificates from Let’s Encrypt or ZeroSSL and installs them automatically.
- Triggers automatic redirects from HTTP (port 80) to HTTPS (port 443) for all visitors.
- Enables the HTTP/2 protocol by default to speed up web page asset load times.
Complete Configuration with Best Practices #
For real production environment deployments serving high traffic, you need a more robust Caddyfile configuration. You must configure structured access logging, dynamic response compression, HTTP security headers, and separate static asset serving (JS, CSS, images) so Caddy handles it directly without burdening the Node.js process.
Here’s the recommended complete production configuration template:
# Optimal production configuration for a Node.js application
example.com {
# 1. Structured Access Logging Configuration (JSON)
log {
output file /var/log/caddy/nodejs-access.log {
roll_size 100mb
roll_keep 7
roll_keep_days 30
}
format json
}
# 2. Automatic Response Compression (Zstd and Gzip)
encode zstd gzip
# 3. Security Using HTTP Security Headers
header {
# Prevent clickjacking
X-Frame-Options "SAMEORIGIN"
# Enable browser encryption enforcement (HSTS)
Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Prevent browsers from guessing file MIME types (MIME sniffing)
X-Content-Type-Options "nosniff"
# Configure a safe referrer policy
Referrer-Policy "strict-origin-when-cross-origin"
# Hide the web server binary information for security
-Server
-X-Powered-By
}
# 4. Efficient Static Asset Serving Directly by Caddy.
# All requests to the /static/ folder or favicon/robots files
# are read directly from disk by Caddy without triggering the Node.js process.
@static path /static/* /favicon.ico /robots.txt
handle @static {
root * /var/www/myapp
file_server
# Install aggressive 1-year Cache-Control because static files have unique name hashes
header Cache-Control "public, max-age=31536000, immutable"
}
# 5. All Other (Dynamic/API) Requests Are Routed to Node.js
handle {
reverse_proxy localhost:3000 {
# Forward the visitor's real IP header to Node.js
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {host}
# Backend Health Testing (Active Health Check)
health_uri /health
health_interval 10s
health_timeout 5s
health_status 200
# Transport timeout configuration
transport http {
dial_timeout 5s
response_header_timeout 60s
read_timeout 120s
write_timeout 120s
}
}
}
}
Node.js with the PM2 Process Manager #
In Linux production environments, you must not run a Node.js application using the raw node app.js command in the terminal. If an uncaught exception error occurs, the Node.js process dies immediately (crash) and stops your entire site service.
You need a production-level process manager like PM2. PM2 acts as a supervisor monitoring your Node.js process 24 hours a day, distributing load across all CPU cores (Clustering Mode), and automatically reviving your application if it crashes or after a server reboot.
1. Global PM2 Installation #
# Install PM2 globally on our Linux system
sudo npm install -g pm2
2. Composing the Application Configuration (ecosystem.config.js) #
Create a file named ecosystem.config.js in your Node.js project directory to define the application’s operational parameters:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'nodejs-prod-app',
script: 'dist/server.js', // Your application's entry point file
instances: 'max', // Run as many instances as the CPU core count (Cluster Mode)
exec_mode: 'cluster', // Enable cluster mode for internal load balancing
// Environment Variable Configuration
env: {
NODE_ENV: 'development',
PORT: 3000
},
env_production: {
NODE_ENV: 'production',
PORT: 3000
},
// Log File Configuration
error_file: '/var/log/myapp/error.log',
out_file: '/var/log/myapp/access.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// Crash Recovery Policy (Restart Policy)
max_restarts: 10, // Maximum consecutive automatic restarts before stopping
min_uptime: '15s', // Minimum duration the app must stay alive to be considered stable
max_memory_restart: '500M', // Auto-restart if RAM consumption exceeds 500MB (leak protection)
kill_timeout: 3000 // Graceful shutdown wait time before forced termination (ms)
}]
};
3. Running and Saving the PM2 Process #
# Run the application using the ecosystem file in production mode
pm2 start ecosystem.config.js --env production
# Save the active PM2 process list to the system configuration
pm2 save
# Configure PM2 to automatically restart when the Linux server boots (reboot)
pm2 startup
Multi-Instance Load Balancing #
If you have a Node.js server serving very high traffic, running one instance on one local port is sometimes not enough. You can run several Node.js processes on different ports (e.g., ports 3000, 3001, 3002, and 3003) and leverage Caddy to distribute traffic load evenly across all those ports.
Here’s the custom load balancing configuration in the Caddyfile:
# Caddy Load Balancing configuration for a local Node.js cluster
example.com {
encode zstd gzip
reverse_proxy {
# Define all Node.js instance targets (Upstreams)
to localhost:3000 localhost:3001 localhost:3002 localhost:3003
# Request distribution policy: Round Robin (alternating sequentially)
lb_policy round_robin
# Active Health Check: Test the /health route every 10 seconds
health_uri /health
health_interval 10s
# Passive Health Check (Circuit Breaker):
# If an instance fails to respond 3 times in a row,
# isolate that instance from the routing list for 30 seconds.
fail_duration 30s
max_fails 3
# Forward the real IP header
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
Node.js with WebSocket #
WebSocket enables full-duplex two-way real-time communication between the user’s browser and the Node.js server (e.g., for chat applications or live data dashboards).
On traditional proxy servers, you must write many extra configuration lines to identify and forward the Upgrade and Connection connection headers so the WebSocket handshake can run. However, in Caddy, WebSocket support is already transparently active. Caddy intelligently detects protocol upgrade requests and directly opens a binary TCP tunnel path without requiring any additional configuration.
# Example of Socket.io WebSocket routing in Caddy
example.com {
# Regular route for frontend static pages
handle /static/* {
root * /var/www/myapp
file_server
}
# Socket.io WebSocket connection route.
# Caddy forwards the connection automatically without extra flags.
handle /socket.io/* {
reverse_proxy localhost:3000
}
# Main API route
handle {
reverse_proxy localhost:3000
}
}
Zero-Downtime Deployment with Node.js #
Every time you update a Node.js application’s code on the server, you must kill the old process and run a new process. If you restart crudely, the server experiences a dead pause (downtime) for a few seconds to minutes, causing users to see “502 Bad Gateway” error messages.
You can combine PM2 Cluster Mode (which supports rolling restart) with Caddy configuration validation to compose a zero-downtime deployment automation script:
#!/bin/bash
# deploy-nodejs.sh — Safe zero-downtime deploy automation script
PROJECT_DIR="/var/www/myapp"
CADDY_BIN="/usr/bin/caddy"
echo "=== Starting the Zero-Downtime Deployment Process ==="
# 1. Enter the project directory and pull the latest code from the Git repository
cd "$PROJECT_DIR" || exit 1
git pull origin main
# 2. Install new dependencies specifically for production needs
npm ci --production
# 3. Compile the code if using TypeScript or a bundler
npm run build
# 4. Validate whether the current system Caddyfile configuration is still valid
echo "Validating the Caddyfile configuration..."
$CADDY_BIN validate --config /etc/caddy/Caddyfile
if [ $? -ne 0 ]; then
echo "✗ Caddyfile validation failed! Cancel the deployment."
exit 1
fi
# 5. Trigger PM2 to do a gradual rolling restart.
# PM2 reloads each cluster instance one by one in the background.
# The old instance stays active serving users until the new instance is ready to receive traffic.
echo "Starting the Node.js process rolling restart in PM2..."
pm2 reload ecosystem.config.js --env production
# 6. Wait 5 seconds to give the application initialization time
sleep 5
# 7. Do an internal system health verification (Local Health Check)
echo "Testing the application route health..."
HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:3000/health)
if [ "$HTTP_STATUS" != "200" ]; then
echo "✗ Health test failed with status: $HTTP_STATUS!"
echo "Triggering a rollback to the previous process version..."
pm2 revert nodejs-prod-app
exit 1
fi
echo "✓ Deployment completed safely without downtime!"
Express.js — Required Configuration #
When an Express.js Node.js application runs behind a reverse proxy like Caddy, Express by default assumes the connection is insecure (because Caddy-to-Express communication uses plain HTTP, not HTTPS). As a result, Express can’t read the visitor’s real IP address or the real HTTPS protocol used by the browser.
To fix this, you must configure Express to trust the proxy headers sent by Caddy, and limit the port binding process to only the localhost IP address (127.0.0.1) so it can’t be accessed directly from the outside internet:
// server.js (Production Express.js Application)
const express = require('express');
const app = express();
// IMPORTANT: Enable trust proxy so Express trusts the X-Forwarded-* headers from Caddy.
// This guarantees the 'req.ip' property returns the visitor browser's real IP,
// and 'req.secure' correctly detects the HTTPS status for session cookie security.
app.set('trust proxy', 1);
// Provide a health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'UP',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
// Logging Middleware with the real client IP address
app.use((req, res, next) => {
// req.ip now contains the real IP (e.g., 203.0.113.50) thanks to 'trust proxy'
console.log(`[${new Date().toISOString()}] ${req.ip} - ${req.method} ${req.path}`);
next();
});
app.get('/api/users', (req, res) => {
res.json([{ id: 1, name: 'Budi' }, { id: 2, name: 'Ani' }]);
});
// Run the Express server
const PORT = process.env.PORT || 3000;
const HOST = '127.0.0.1'; // BIND ONLY TO LOCALHOST (Safe from direct external port access)
app.listen(PORT, HOST, () => {
console.log(`Express server is internally active at http://${HOST}:${PORT}`);
});
Centralized CORS Configuration at the Caddy Gateway Level #
A common modern web application scenario is separating the SPA frontend domain (e.g., app.example.com) from the backend API domain (api.example.com). User browsers block API requests because of the Same-Origin Policy security rules.
You can handle Cross-Origin Resource Sharing (CORS) directly at the Caddy server level, eliminating the need to write repetitive CORS middleware code in every Node.js microservice:
# Centralized CORS configuration in Caddy
api.example.com {
# Special handling for OPTIONS method requests (Preflight Request)
@options method OPTIONS
handle @options {
header Access-Control-Allow-Origin "https://app.example.com"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Content-Type, Authorization"
header Access-Control-Allow-Credentials "true"
header Access-Control-Max-Age "86400" # Store the preflight cache for 24 hours
respond "" 204 # Return an empty response without content
}
# Insert CORS headers for regular transaction requests (GET, POST, etc.)
header Access-Control-Allow-Origin "https://app.example.com"
header Access-Control-Allow-Credentials "true"
# Compress and forward to the Node.js backend
encode zstd gzip
reverse_proxy localhost:3000
}
Streaming Responses from Node.js (Server-Sent Events) #
If your Node.js application provides continuous data streaming features (like live feed updates, real-time server log monitoring, or AI text streaming integration with Server-Sent Events / SSE), Caddy by default holds those streaming responses in its memory buffer until all the data is collected before sending it all at once to the user. This ruins your live streaming data experience.
To fix this, you must disable the response buffering mechanism on the Caddy reverse proxy by setting the flush_interval -1 parameter:
# Caddyfile configuration specifically for Streaming Responses (SSE)
example.com {
# Special endpoint for real-time data streaming
handle /events/* {
reverse_proxy localhost:3000 {
# Setting the flush interval to -1 forces Caddy to immediately
# stream every data chunk from Node.js to the client browser without buffering.
flush_interval -1
}
}
# Other regular endpoints still use standard buffering for efficiency
handle {
reverse_proxy localhost:3000
}
}
Node.js Request Processing Flow Diagram by Caddy #
For a visualization of the user request journey through the Caddy edge server until processed by the Node.js application cluster, let’s look at the following flowchart:
flowchart TD
A["Client Request Arrives\n(HTTPS - Port 443)"] --> B["1. Caddy handles the TLS handshake\n(Data decryption & security validation)"]
B --> C{"2. Does the request route\nmatch static assets?"}
C -- "Yes" --> D["3. Caddy reads the file directly from disk\n(e.g., /static/app.js)"]
D --> E["4. Insert aggressive Cache-Control headers\nand send back to the Client"]
C -- "No" --> F["5. Evaluate dynamic / API routes"]
F --> G{"6. Is there a special\nServer-Sent Events (SSE) endpoint?"}
G -- "Yes" --> H["7. Stream the request via reverse_proxy\n(Flush interval -1: no buffering)"]
G -- "No" --> I["8. Stream the request via reverse_proxy\n(Use Round Robin load balancing)"]
H --> J["9. PM2 Node.js Cluster\n(Distribution to Node.js processes on Port 3000-3003)"]
I --> J
J --> K["10. Execute the application business logic\n(Express/Fastify processes the data)"]
K --> L["11. The response is sent back to Caddy"]
L --> M["12. Caddy compresses the response (Gzip/Zstd)\nand sends it to the Client"]Summary #
- Port Isolation Pattern: Always do binding of the Node.js application port to the local address
127.0.0.1(not0.0.0.0) so it can’t be accessed directly without passing through Caddy.- Cookie & Session Security: Enable
app.set('trust proxy', 1)on Express.js so the application recognizes the real HTTPS protocol and detects the real client IP.- Static Asset Efficiency: Use the
@staticnamed matcher in the Caddyfile to serve CSS/JS files directly, saving Node.js RAM processing load.- Process Supervision: Use PM2 in cluster mode (
exec_mode: cluster) in production for process stability, automatic crash recovery, and full CPU core utilization.- Graceful Reload: Apply the
pm2 reloadcommand instead ofpm2 restartwhen doing new deployments to guarantee a process transition without downtime.- Real-time Streaming: Set the
flush_interval -1property on proxy routes for Server-Sent Events (SSE)-based APIs so data isn’t held in the buffer.- CORS Centralization: Centralize CORS header handling at the Caddyfile level to simplify multi-service API security configuration.