Part of our Performance & Scalability series
Read the complete guideNginx Production Configuration: SSL, Caching, and Security
Nginx is the production web server that handles virtually all high-traffic web deployments. When configured well, it provides SSL termination, efficient static file serving, WebSocket proxying, rate limiting, and security headers — all before a single request reaches your Node.js application. When configured poorly, it becomes a bottleneck, a security liability, or the cause of mysterious Cloudflare redirect loops.
This guide covers a production-grade Nginx configuration for a multi-app Node.js deployment: Next.js frontend, NestJS API, and Docusaurus docs — all running on the same server behind Cloudflare.
Key Takeaways
- Never split www and non-www into separate server blocks behind Cloudflare — causes redirect loops
- Use a single Nginx config file in
/etc/nginx/conf.d/— don't also symlink insites-enabled/X-XSS-Protectionis deprecated — use CSP instead;X-Frame-Options: DENYis still validproxy_passmust include the trailing slash when using a path prefix- Gzip compression for text/* MIME types is always worth enabling
- Rate limiting requires
limit_req_zonedefined athttp{}level, notserver{}level- Let's Encrypt certificates auto-renew with Certbot; add a cron job to reload Nginx after renewal
- WebSocket proxying requires specific headers:
UpgradeandConnection
Directory Structure
Keep Nginx configuration organized:
/etc/nginx/
nginx.conf — Main config (rarely touch this)
conf.d/
ecosire-production.conf — All your server blocks in ONE file
snippets/
ssl-params.snippet — SSL hardening (included by server blocks)
proxy-params.snippet — Common proxy headers
security-headers.snippet — Security headers
Critical: Put all your configuration in conf.d/ecosire-production.conf. Do NOT also add it to sites-enabled/ — this causes Nginx to process the config twice, leading to duplicate limit_req_zone errors and unexpected behavior.
Rate Limiting Zones
Define rate limiting zones at the http{} level in your config. They cannot be defined inside server{} blocks:
# /etc/nginx/conf.d/ecosire-production.conf
# Must be at http{} level — these are in the main conf or conf.d root
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_public:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=static_files:10m rate=200r/m;
Main Application Server Block
# Main web application — Next.js on port 3000
server {
listen 80;
listen [::]:80;
server_name ecosire.com www.ecosire.com;
# Cloudflare handles SSL termination — Nginx only sees HTTP
# (If direct SSL, add listen 443 ssl and certificate paths)
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CSP — adjust based on your needs
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://js.stripe.com; connect-src 'self' https://api.ecosire.com;" always;
# Remove server version from responses
server_tokens off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
gzip_min_length 1024;
# ─── Static Files: Next.js build output ─────────────────────────
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
# Files include content hash in filename — safe to cache forever
}
# ─── Public static assets ────────────────────────────────────────
location /assets/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=86400"; # 1 day
}
# ─── Well-known files (no locale prefix) ─────────────────────────
location /.well-known/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=86400";
}
# ─── App routes (rate limited) ───────────────────────────────────
location / {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1: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;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
API Server Block
# NestJS API — port 3001
server {
listen 80;
listen [::]:80;
server_name api.ecosire.com;
server_tokens off;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CORS — handled by NestJS, but Nginx can add preflight response
# for performance (avoids reaching Node.js for OPTIONS)
location / {
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin "https://ecosire.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Max-Age 1728000;
add_header Content-Length 0;
return 204;
}
limit_req zone=api_general burst=30 nodelay;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
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_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Stricter rate limits for auth endpoints
location ~ ^/api/auth/(login|exchange|callback) {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3001;
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;
}
# Health check — no rate limiting
location /api/health {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
access_log off; # Don't log health check spam
}
# Stripe webhook — needs raw body
location /api/billing/webhook {
proxy_pass http://127.0.0.1:3001;
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;
# No rate limiting on webhooks — Stripe IPs are trusted
# NestJS validates the Stripe signature
}
}
WebSocket Proxying
If your application uses WebSockets (Socket.IO, NestJS WebSocket gateway), the proxy configuration needs specific headers:
# WebSocket upgrade handling
location /ws/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; # Capital U required
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# WebSocket connections can be long-lived
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Without Upgrade and Connection headers, the browser's WebSocket handshake fails with a 426 Upgrade Required or 101 Switching Protocols error.
Direct SSL (Without Cloudflare)
If not using Cloudflare, handle SSL termination in Nginx directly:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ecosire.com;
ssl_certificate /etc/letsencrypt/live/ecosire.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ecosire.com/privkey.pem;
# SSL hardening
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ... rest of server block
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name ecosire.com www.ecosire.com;
return 301 https://ecosire.com$request_uri;
}
Cloudflare-Specific Configuration
Behind Cloudflare, Nginx only sees Cloudflare's IPs. To preserve real client IPs:
# Real IP from Cloudflare — add this in http{} or at top of server{}
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;
Also: never split www.ecosire.com and ecosire.com into separate Nginx server blocks when using Cloudflare. Cloudflare handles the www redirect at its edge. If you add a www server block that redirects to non-www, and Cloudflare is also redirecting www, you get a redirect loop (ERR_TOO_MANY_REDIRECTS).
Log Configuration
# Custom log format with useful fields
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"cf_ip":"$http_cf_connecting_ip",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"body_bytes":$body_bytes_sent,'
'"response_time":$request_time,'
'"referrer":"$http_referer",'
'"user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/ecosire-access.log json_combined;
error_log /var/log/nginx/ecosire-error.log warn;
Testing Your Configuration
# Test syntax before applying
nginx -t
# Reload without downtime
nginx -s reload
# Check which config file is active
nginx -T | grep "configuration file"
# Test a specific server block
curl -I https://ecosire.com
curl -I https://api.ecosire.com/api/health
# Check security headers
curl -I https://ecosire.com | grep -E "X-Frame|X-Content|Referrer|Content-Security"
Frequently Asked Questions
Should I use Nginx or Caddy for a new project?
Caddy is significantly simpler to configure — it handles Let's Encrypt SSL automatically and has sensible defaults out of the box. Nginx is more powerful and has a larger ecosystem of modules and documentation. For most new projects, Caddy is the better starting point; switch to Nginx if you need fine-grained control over SSL, complex upstream routing, or Nginx-specific modules. For existing Linux servers where Nginx is already installed, stick with Nginx.
How do I avoid the duplicate limit_req_zone error?
The limit_req_zone directive must appear at the http{} context level, not inside server{} blocks. In a Turborepo monorepo deployment where you're including multiple config files, ensure the directive appears only once across all included files. If you're seeing the error, check if you have both conf.d/app.conf and a sites-enabled/ symlink pointing to the same file.
How do I configure Nginx for Next.js ISR (Incremental Static Regeneration)?
Next.js handles ISR internally — Nginx just needs to proxy all requests to Next.js without caching the responses. Do not add Cache-Control headers that interfere with Next.js's ISR cache headers. For static assets in /_next/static/, add immutable cache headers since these files have content-hashed names. For all other routes, let Next.js set the cache headers.
Why does my SSL score drop when Cloudflare is enabled?
When Cloudflare is in proxy mode, SSL Labs tests Cloudflare's SSL, not yours. Set Cloudflare's SSL mode to "Full (Strict)" to ensure Cloudflare validates your origin certificate. Install a Cloudflare Origin Certificate on your server — it's free and valid for 15 years. Your SSL Labs score becomes Cloudflare's score (usually A+), which is actually an improvement over a typical self-configured Nginx SSL setup.
How do I handle multiple Node.js apps on the same server?
Run each app on a different port (Next.js on 3000, NestJS on 3001, Docusaurus on 3002, etc.) and add a server block for each subdomain in your Nginx config. Use PM2 to manage all Node.js processes. Nginx routes by subdomain (or path prefix) to the correct port. Each server block can have its own rate limiting, caching, and security header configuration.
Next Steps
A production Nginx configuration is a living document — it evolves as your application's requirements change, your traffic patterns shift, and new security best practices emerge. ECOSIRE runs Nginx in production serving multiple applications, handling SSL termination, rate limiting, and Cloudflare integration across all domains.
Whether you need DevOps consulting, production infrastructure setup, or a complete deployment architecture design, explore our services to see how we can help.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
AI Fraud Detection for E-commerce: Protect Revenue Without Blocking Sales
Implement AI fraud detection that catches 95%+ of fraudulent transactions while keeping false positive rates under 2%. ML scoring, behavioral analysis, and ROI guide.
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
Cybersecurity Trends 2026-2027: Zero Trust, AI Threats, and Defense
The definitive guide to cybersecurity trends for 2026-2027—AI-powered attacks, zero trust implementation, supply chain security, and building resilient security programs.
More from Performance & Scalability
Webhook Debugging and Monitoring: The Complete Troubleshooting Guide
Master webhook debugging with this complete guide covering failure patterns, debugging tools, retry strategies, monitoring dashboards, and security best practices.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.