You have a Node.js app running on port 3000, a Python API on port 8000, and maybe a WordPress site on port 8080. Each one works perfectly when you test it by itself. The problem is you only have one public IP address, one port 80, and one port 443 — and your users should not be typing port numbers into their browser.
An Nginx reverse proxy sits in front of all your applications, receives every incoming request on ports 80 and 443, and forwards each request to the correct backend based on the domain name or URL path. When you add a second instance of the same app behind it, you have load balancing. When one backend crashes and Nginx automatically routes traffic to the surviving instances, you have failover. All of this runs on a single free VPS.
This guide covers the complete setup from scratch — single-backend reverse proxy, multi-backend load balancing, SSL termination, WebSocket proxying, caching, and security hardening. Everything runs on Ubuntu 22.04 or 24.04. If you’re looking specifically to host a WordPress site behind Nginx, see our dedicated guide on installing WordPress with Nginx on a free VPS.
What Is a Reverse Proxy and Why You Need One
A reverse proxy is a server that sits between the internet and your backend applications. Clients never connect to your applications directly — they connect to Nginx, and Nginx decides which backend handles each request.
This is different from a forward proxy (like a VPN or SOCKS proxy), which sits between a client and the internet. A reverse proxy sits between the internet and your servers.
What Nginx does as a reverse proxy:
- Request routing — sends
api.yourdomain.comto your Python API andyourdomain.comto your frontend app, all from one IP - SSL termination — handles HTTPS encryption/decryption so your backends only deal with plain HTTP internally
- Load balancing — distributes requests across multiple backend instances so no single instance gets overwhelmed
- Static file serving — serves images, CSS, and JS directly without hitting your application server
- Caching — stores backend responses and serves them directly on repeat requests, reducing backend load dramatically
- Connection buffering — absorbs slow client connections so your backend threads aren’t held open waiting for clients on 3G connections to finish receiving data
- Failover — if a backend crashes, Nginx stops sending it traffic and routes to healthy instances instead
In practice, every production deployment of Node.js, Python (Django/Flask/FastAPI), Go, Ruby, or Java applications should sit behind a reverse proxy. These application servers are designed to process business logic, not handle raw internet traffic, SSL handshakes, or slow clients.
Reverse Proxy vs. Serving Directly — When It Matters
| Factor | Direct Exposure (No Proxy) | Behind Nginx Reverse Proxy |
|---|---|---|
| SSL/HTTPS | Each app handles its own certificates | ✅ Nginx handles all SSL — one place to manage certs |
| Multiple apps on one IP | ❌ Must use different ports (3000, 8000, 8080…) | ✅ All apps served on ports 80/443, routed by domain or path |
| Static files | Served by your app — wastes app threads | ✅ Nginx serves static files directly — 10x faster |
| Slow clients | ❌ App thread held open per slow connection | ✅ Nginx buffers — frees backend thread immediately |
| DDoS resilience | ❌ App gets hammered directly | ✅ Nginx handles connection limits, rate limiting built-in |
| Zero-downtime deploys | ❌ Restarting app = downtime | ✅ Restart one backend while others serve traffic |
| Horizontal scaling | ❌ Not possible without external load balancer | ✅ Add more backends to upstream block — instant scaling |
The only scenario where serving directly without a reverse proxy makes sense is local development. In production — even on a single-app, single-server setup — Nginx in front is strictly better.
What You Need Before You Start
- A VPS running Ubuntu 22.04 or 24.04 — minimum 1 vCPU, 1 GB RAM for a proxy with 1–3 backends
- Root or sudo SSH access
- A domain name pointed to your VPS IP (required for SSL in Step 7, optional for everything else)
- At least one backend application to proxy to — we’ll set up example backends in Step 3
- About 25 minutes
DNS setup first: If you plan to use SSL (you should), add an A record for your domain and any subdomains pointing to your VPS IP now. DNS propagation can take up to 48 hours. You can complete Steps 1–6 using your VPS IP while DNS propagates.
Step 1 — Get a Free VPS and Connect
If you don’t have a VPS yet, VPSWala’s free VPS deploys Ubuntu 22.04 or 24.04 in 60 seconds — no credit card required. The Starter plan (ARM64, 2 GB RAM) handles Nginx proxying to 2–3 lightweight backends. The 30-day Professional free trial (8-core AMD EPYC, 8 GB DDR5 ECC RAM) is what you want if you’re running load-balanced production services — 8 cores give Nginx plenty of worker processes for high concurrency.
Connect via SSH:
ssh root@YOUR_VPS_IP
For first-time VPS setup — creating a sudo user, disabling root login, baseline security — follow our guide on 5 things you must do after launching a Linux VPS before continuing. For SSH key setup, see our SSH tips guide.
Step 2 — Install Nginx
sudo apt update && sudo apt upgrade -y sudo apt install -y nginx
Start Nginx and enable it to start on boot:
sudo systemctl start nginx sudo systemctl enable nginx sudo systemctl status nginx
Should show active (running). Open the firewall:
sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable sudo ufw status
Visit http://YOUR_VPS_IP in a browser — you should see the Nginx default welcome page. That confirms Nginx is installed and publicly reachable.
Cloud firewall note: If you can’t reach Nginx despite it running correctly, your VPS provider likely has an external firewall separate from UFW. Ports 80 and 443 need to be open there too. See our guide on opening ports 80 and 443 for the full walkthrough including provider-level firewalls.
Check the installed version:
nginx -v
Ubuntu 24.04 ships Nginx 1.24+. If you need the latest mainline version for newer features (HTTP/3, etc.), you can add the official Nginx repository — but the Ubuntu default version is perfectly fine for everything in this guide.
Step 3 — Set Up Backend Application Servers
A reverse proxy needs something to proxy to. We’ll set up two simple backend servers — a Node.js app and a Python app — so you can see proxying and load balancing in action. In production, these would be your real applications.
Backend A — Node.js (Express) on Port 3001
Install Node.js:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs
Create the app:
mkdir -p ~/apps/backend-a && cd ~/apps/backend-a npm init -y npm install express
nano server.js
const express = require('express'); const app = express(); const PORT = 3001; app.get('/', (req, res) => { res.json({ message: 'Hello from Backend A (Node.js)', port: PORT, timestamp: new Date().toISOString() }); }); app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy' }); }); app.listen(PORT, '127.0.0.1', () => { console.log(`Backend A running on port ${PORT}`); });
Note that the server binds to 127.0.0.1, not 0.0.0.0. This is critical — your backends should only listen on localhost. Nginx is the only thing that should be publicly reachable. If your backend listens on 0.0.0.0, users can bypass Nginx entirely by hitting YOUR_IP:3001.
Backend B — Python (Flask) on Port 3002
sudo apt install -y python3 python3-pip python3-venv mkdir -p ~/apps/backend-b && cd ~/apps/backend-b python3 -m venv venv source venv/bin/activate pip install flask gunicorn
nano app.py
from flask import Flask, jsonify from datetime import datetime app = Flask(__name__) @app.route('/') def home(): return jsonify({ 'message': 'Hello from Backend B (Python/Flask)', 'port': 3002, 'timestamp': datetime.now().isoformat() }) @app.route('/health') def health(): return jsonify({'status': 'healthy'}), 200 if __name__ == '__main__': app.run(host='127.0.0.1', port=3002)
Run Both Backends with PM2 (Process Manager)
PM2 keeps your backends running 24/7 — restarts them after crashes and reboots:
sudo npm install -g pm2
# Start Backend A (Node.js) cd ~/apps/backend-a pm2 start server.js --name backend-a # Start Backend B (Python via Gunicorn) cd ~/apps/backend-b pm2 start "venv/bin/gunicorn -w 2 -b 127.0.0.1:3002 app:app" --name backend-b # Save PM2 process list and set up startup on reboot pm2 save pm2 startup
Verify both are running:
pm2 list
Test them locally:
curl http://127.0.0.1:3001 curl http://127.0.0.1:3002
Both should return JSON responses. If either fails, check logs: pm2 logs backend-a or pm2 logs backend-b. For a deeper dive on running applications 24/7, see our guide on running Python scripts 24/7 on a free VPS.
Step 4 — Configure Nginx as a Reverse Proxy
This is the core concept. Nginx receives requests on port 80 and forwards them to your backend based on the domain name or URL path.
Scenario A — One Domain, One Backend
The simplest reverse proxy configuration — all traffic to your domain goes to one backend:
sudo nano /etc/nginx/sites-available/yourdomain.com
server { listen 80; server_name yourdomain.com www.yourdomain.com; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; # Pass real client info to the backend 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; # Connection settings proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } }
Enable the site:
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl reload nginx
nginx -t tests the configuration for syntax errors — always run it before reloading. If it reports errors, fix them before proceeding.
Visit http://yourdomain.com — you should see the JSON response from Backend A. Nginx received your HTTP request and forwarded it to your Node.js app running on port 3001. Your users have no idea a backend exists — they see your domain on port 80.
Understanding the proxy_set_header Lines
Without these headers, your backend application sees every request as coming from 127.0.0.1 (Nginx itself) instead of the real client. Here’s what each one does:
Host— passes the original domain the client requested, so your app can serve the right content for multi-domain setupsX-Real-IP— the actual client IP address, so your app logs, rate-limiting, and geo-detection work correctlyX-Forwarded-For— the full chain of IPs if the request passed through multiple proxies (CDN → Nginx → backend)X-Forwarded-Proto— tells the backend whether the original request was HTTP or HTTPS, so it can generate correct URLs in responses
Scenario B — Multiple Domains, Multiple Backends
Run different applications on different subdomains — all from the same VPS and IP:
sudo nano /etc/nginx/sites-available/app.yourdomain.com
server { listen 80; server_name app.yourdomain.com; location / { 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; } }
sudo nano /etc/nginx/sites-available/api.yourdomain.com
server { listen 80; server_name api.yourdomain.com; location / { proxy_pass http://127.0.0.1:3002; 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; } }
sudo ln -s /etc/nginx/sites-available/app.yourdomain.com /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/api.yourdomain.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
Now app.yourdomain.com serves your Node.js frontend and api.yourdomain.com serves your Python API — same server, same IP, no port numbers visible to users. For more on subdomain setup with SSL, see our guide on subdomains with Let’s Encrypt on Nginx.
Scenario C — Path-Based Routing (One Domain, Multiple Backends)
Route different URL paths to different backends on the same domain:
sudo nano /etc/nginx/sites-available/yourdomain.com
server { listen 80; server_name yourdomain.com www.yourdomain.com; # Frontend app location / { 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; } # API backend location /api/ { proxy_pass http://127.0.0.1:3002/; 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; } # Serve static files directly — don't hit the backend location /static/ { alias /var/www/yourdomain.com/static/; expires 30d; add_header Cache-Control "public, immutable"; } }
proxy_pass http://127.0.0.1:3002/; (with trailing slash) strips /api/ from the path before forwarding. A request to /api/users arrives at the backend as /users. Without the trailing slash, the backend receives /api/users — which may not match your backend routes. This is the single most common Nginx proxy misconfiguration.Step 5 — Add Load Balancing Across Multiple Backends
Load balancing distributes incoming requests across multiple backend instances. This gives you more throughput, redundancy, and the ability to restart one instance without downtime.
Start Multiple Instances of the Same App
Let’s run three instances of the Node.js backend:
cd ~/apps/backend-a PORT=3001 pm2 start server.js --name backend-a-1 -- --port 3001 PORT=3003 pm2 start server.js --name backend-a-2 -- --port 3003 PORT=3004 pm2 start server.js --name backend-a-3 -- --port 3004 pm2 save
Update server.js to read the port from environment or argument so each instance uses a different port:
const PORT = process.env.PORT || 3001;
Or start each with an explicit environment variable:
pm2 start server.js --name backend-a-1 --env PORT=3001 pm2 start server.js --name backend-a-2 --env PORT=3003 pm2 start server.js --name backend-a-3 --env PORT=3004
Round Robin (Default)
Nginx distributes requests evenly — one to each backend in rotation:
sudo nano /etc/nginx/sites-available/yourdomain.com
upstream app_backends { server 127.0.0.1:3001; server 127.0.0.1:3003; server 127.0.0.1:3004; } server { listen 80; server_name yourdomain.com www.yourdomain.com; location / { proxy_pass http://app_backends; 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; } }
sudo nginx -t sudo systemctl reload nginx
Refresh your browser several times — the port value in the JSON response should rotate between 3001, 3003, and 3004. That’s load balancing working.
Weighted Round Robin
When your backends have different capacities — maybe one has more RAM or CPU — assign weights so stronger servers get more traffic:
upstream app_backends { server 127.0.0.1:3001 weight=5; # Gets 5 out of 8 requests server 127.0.0.1:3003 weight=2; # Gets 2 out of 8 requests server 127.0.0.1:3004 weight=1; # Gets 1 out of 8 requests }
Least Connections
Sends each new request to the backend with the fewest active connections. Better than round robin when requests have variable processing times — a slow database query won’t cause requests to pile up on one backend:
upstream app_backends { least_conn; server 127.0.0.1:3001; server 127.0.0.1:3003; server 127.0.0.1:3004; }
IP Hash (Session Persistence)
Routes every request from the same client IP to the same backend. Required when your application stores sessions in memory (not recommended, but common) or when you need consistent routing for stateful APIs:
upstream app_backends { ip_hash; server 127.0.0.1:3001; server 127.0.0.1:3003; server 127.0.0.1:3004; }
Load Balancing Methods Summary
| Method | Directive | Best For |
|---|---|---|
| Round Robin | (default — no directive needed) | Identical backends, uniform request times |
| Weighted Round Robin | weight=N on each server |
Backends with different capacity |
| Least Connections | least_conn; |
Variable request times (API/database workloads) |
| IP Hash | ip_hash; |
Sticky sessions, stateful apps |
| Random with Two Choices | random two least_conn; |
Large-scale deployments, avoids herd behavior |
Step 6 — Configure Health Checks and Failover
When a backend crashes, Nginx should stop sending it traffic immediately. Nginx open-source uses passive health checks — it detects failures when a request actually fails, then temporarily removes the backend from rotation.
upstream app_backends { least_conn; server 127.0.0.1:3001 max_fails=3 fail_timeout=30s; server 127.0.0.1:3003 max_fails=3 fail_timeout=30s; server 127.0.0.1:3004 max_fails=3 fail_timeout=30s backup; }
What this does:
max_fails=3— after 3 consecutive failed requests, Nginx marks the backend as downfail_timeout=30s— wait 30 seconds before trying the failed backend againbackup— this server only receives traffic when all primary servers are down. Use a separate backup instance as a last resort to keep your site alive during cascading failures
Test failover by stopping one backend:
pm2 stop backend-a-2
Refresh your browser repeatedly — you should only see responses from ports 3001 and 3004. Nginx has automatically excluded the dead backend. Start it again:
pm2 start backend-a-2
After 30 seconds (the fail_timeout period), Nginx will start sending traffic to it again.
Custom Error Pages for Backend Failures
When all backends are down, instead of showing Nginx’s ugly 502 Bad Gateway error, serve a custom maintenance page:
sudo mkdir -p /var/www/error-pages sudo nano /var/www/error-pages/502.html
<!DOCTYPE html> <html> <head><title>Temporarily Unavailable</title></head> <body style="font-family:sans-serif;text-align:center;padding:50px;"> <h1>We'll Be Right Back</h1> <p>Our servers are being updated. Please try again in a few minutes.</p> </body> </html>
Add to your server block:
error_page 502 503 504 /custom_502.html; location = /custom_502.html { root /var/www/error-pages; internal; }
Rename the file to match:
sudo mv /var/www/error-pages/502.html /var/www/error-pages/custom_502.html
Step 7 — SSL Termination with Let’s Encrypt
SSL termination means Nginx handles all HTTPS encryption and decryption. The connection between Nginx and your backends stays plain HTTP over localhost — no performance overhead, no SSL configuration needed in your application code.
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot prompts:
- Email address — for expiry notifications
- Agree to terms —
A - Share email with EFF — your choice
- Redirect HTTP to HTTPS — choose 2 — forces all traffic to HTTPS
For multiple subdomains (like the multi-domain setup in Step 4):
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com -d app.yourdomain.com -d api.yourdomain.com
Certbot automatically modifies your Nginx config files to add the SSL directives. Verify the result:
sudo nginx -t sudo systemctl reload nginx
Test auto-renewal:
sudo certbot renew --dry-run
Your Nginx config now looks something like this (Certbot adds the SSL lines automatically):
server { listen 443 ssl; server_name yourdomain.com www.yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location / { proxy_pass http://app_backends; 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; } } server { listen 80; server_name yourdomain.com www.yourdomain.com; return 301 https://$host$request_uri; }
Visit https://yourdomain.com — padlock should appear. Your backends know nothing about SSL — they still receive plain HTTP on localhost. This is the production standard for SSL handling.
Step 8 — Proxy WebSocket Connections
WebSocket connections (used by chat apps, real-time dashboards, Socket.IO, etc.) require special proxy headers because they upgrade from HTTP to a persistent bidirectional connection. Without these headers, WebSocket connections fail silently or drop after 60 seconds.
location /ws/ { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; # Required for WebSocket upgrade 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; # WebSocket connections are long-lived — extend timeouts proxy_read_timeout 86400s; proxy_send_timeout 86400s; }
The two critical lines are Upgrade $http_upgrade and Connection "upgrade" — these tell Nginx to pass the WebSocket upgrade request through to the backend instead of treating it as a normal HTTP request. The extended timeouts (86400s = 24 hours) prevent Nginx from killing idle WebSocket connections.
If your entire application uses WebSockets (like a Socket.IO app), you can add these headers to the main location / block instead of a separate path. For hosting a Discord bot or similar WebSocket-heavy application, see our guide on hosting a Discord bot 24/7 on a free VPS.
Step 9 — Enable Reverse Proxy Caching
Proxy caching stores backend responses on disk. When the same URL is requested again, Nginx serves the cached copy directly without touching the backend. For read-heavy applications and APIs, this reduces backend load by 80–95%.
Define the Cache Zone
Add this to the http block in the main Nginx config:
sudo nano /etc/nginx/nginx.conf
Add inside the http { } block (before or after existing directives):
# Proxy cache configuration proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=proxy_cache:10m max_size=1g inactive=60m use_temp_path=off;
What each parameter means:
/var/cache/nginx/proxy— where cached files are stored on disklevels=1:2— subdirectory structure to avoid too many files in one directorykeys_zone=proxy_cache:10m— 10 MB of shared memory for cache keys (holds ~80,000 keys)max_size=1g— total cache size limit on diskinactive=60m— remove cached items not accessed in 60 minutes
Create the cache directory:
sudo mkdir -p /var/cache/nginx/proxy sudo chown www-data:www-data /var/cache/nginx/proxy
Enable Caching in Your Server Block
location / { proxy_pass http://app_backends; 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; # Caching proxy_cache proxy_cache; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; add_header X-Cache-Status $upstream_cache_status; }
Key directives:
proxy_cache_valid 200 302 10m— cache successful responses for 10 minutesproxy_cache_use_stale— if the backend is down or slow, serve the stale cached version instead of an error. This is a major reliability improvement — your site stays up even when backends have temporary issuesproxy_cache_lock on— when multiple clients request the same uncached URL simultaneously, only one request goes to the backend. The rest wait for the cache to fill. Prevents cache stampedesX-Cache-Statusheader — shows HIT, MISS, or EXPIRED in the response headers so you can verify caching is working
sudo nginx -t sudo systemctl reload nginx
Test caching by requesting the same URL twice:
curl -I https://yourdomain.com
First request: X-Cache-Status: MISS. Second request: X-Cache-Status: HIT. The second request was served entirely from Nginx’s cache — the backend was never contacted.
Bypass Cache for Authenticated Content
Don’t cache user-specific pages — logged-in dashboards, shopping carts, etc.:
# Don't cache requests with authentication proxy_cache_bypass $http_authorization $cookie_sessionid; proxy_no_cache $http_authorization $cookie_sessionid;
Add these inside the same location block. Requests with an Authorization header or a session cookie always go directly to the backend.
Step 10 — Performance Tuning
1. Optimize Nginx Worker Configuration
sudo nano /etc/nginx/nginx.conf
# Set workers to match CPU cores worker_processes auto; events { worker_connections 2048; multi_accept on; use epoll; } http { # Connection keepalive to backends keepalive_timeout 65; keepalive_requests 1000; # Buffer settings — prevent temp file writes for most responses proxy_buffering on; proxy_buffer_size 16k; proxy_buffers 4 64k; proxy_busy_buffers_size 128k; # Gzip compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 4; gzip_min_length 256; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml font/woff2; # Security headers (applied to all sites) add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # ... rest of http block }
What the buffer settings do: When your backend sends a response, Nginx stores it in memory buffers. If the response fits in the buffers, Nginx sends it directly to the client without ever writing to disk. The default buffers are too small for most API responses — these values handle responses up to ~256 KB entirely in memory.
2. Upstream Keepalive Connections
By default, Nginx opens a new TCP connection to the backend for every request and closes it when done. Keepalive connections reuse existing connections — eliminating the TCP handshake overhead:
upstream app_backends { least_conn; server 127.0.0.1:3001 max_fails=3 fail_timeout=30s; server 127.0.0.1:3003 max_fails=3 fail_timeout=30s; server 127.0.0.1:3004 max_fails=3 fail_timeout=30s; keepalive 32; }
And update the location block to support keepalive:
location / { proxy_pass http://app_backends; proxy_http_version 1.1; proxy_set_header Connection ""; # ... rest of proxy headers }
The empty Connection "" header clears the default Connection: close that Nginx sends to backends, allowing connections to be reused. The keepalive 32 directive maintains a pool of 32 idle connections to the upstream — dramatically reduces latency under load.
3. Serve Static Files Directly
Static files (images, CSS, JS, fonts) should never hit your application backend — Nginx serves them 10–50x faster:
# Serve static assets directly location /static/ { alias /var/www/yourdomain.com/static/; expires 30d; add_header Cache-Control "public, immutable"; access_log off; } location ~* \.(ico|css|js|gif|jpeg|jpg|png|webp|woff2|svg)$ { expires 30d; add_header Cache-Control "public, immutable"; access_log off; try_files $uri @backend; } location @backend { proxy_pass http://app_backends; 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; }
The try_files $uri @backend pattern checks if the static file exists on disk first. If it does, Nginx serves it directly. If it doesn’t, the request falls through to the backend — a clean pattern that works for apps with both static files and dynamic routes.
Performance Summary
| Optimization | Impact | Status |
|---|---|---|
| Worker processes auto-tuned | Uses all available CPU cores | Configured above |
| Gzip compression | 60–80% smaller responses | Configured above |
| Proxy response caching | 80–95% reduction in backend load | Configured in Step 9 |
| Upstream keepalive | Eliminates TCP handshake per request | Configured above |
| Static file serving | 10–50x faster than app servers | Configured above |
| Response buffering | Frees backend threads faster | Configured above |
| Browser cache headers | Eliminates repeat requests | Configured via expires directives |
Step 11 — Security Hardening
1. Rate Limiting
Protect your backends from brute-force attacks and abusive traffic. Define a rate limit zone in the http block:
sudo nano /etc/nginx/nginx.conf
http { # Rate limiting: 10 requests/second per IP, burst up to 20 limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; # Stricter rate limit for login/auth endpoints limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s; # ... rest of http block }
Apply in your server block:
# General rate limit location / { limit_req zone=general burst=20 nodelay; proxy_pass http://app_backends; # ... proxy headers } # Strict rate limit on authentication endpoints location /api/auth/ { limit_req zone=auth burst=5 nodelay; proxy_pass http://app_backends; # ... proxy headers }
burst=20 allows short bursts above the rate limit (handles page loads that trigger multiple asset requests simultaneously). nodelay processes burst requests immediately instead of queuing them. For more on preventing abuse, see our guide on preventing brute force attacks.
2. Hide Nginx Version
Attackers use version numbers to find known vulnerabilities. Hide it:
# In the http block of nginx.conf server_tokens off;
3. Block Common Attack Patterns
# Block access to hidden files (.git, .env, .htaccess, etc.) location ~ /\. { deny all; return 404; } # Block access to backup files location ~* \.(bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist)$ { deny all; return 404; }
4. Restrict Client Request Size
# Prevent large uploads that could fill disk client_max_body_size 10m;
Adjust based on your application — 10 MB is fine for APIs, increase to 64 MB or more if users upload files.
5. Add Security Headers
If you didn’t add these in the performance section already:
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The Strict-Transport-Security header tells browsers to always use HTTPS — prevents SSL stripping attacks. Only add this after you’ve confirmed HTTPS works correctly.
6. Fail2Ban for Nginx
Fail2Ban monitors Nginx access logs and bans IPs that show malicious patterns:
sudo apt install -y fail2ban
sudo nano /etc/fail2ban/jail.local
[nginx-limit-req] enabled = true filter = nginx-limit-req logpath = /var/log/nginx/error.log maxretry = 5 bantime = 3600 findtime = 600 [nginx-botsearch] enabled = true filter = nginx-botsearch logpath = /var/log/nginx/access.log maxretry = 3 bantime = 86400 findtime = 600
sudo systemctl enable fail2ban sudo systemctl start fail2ban sudo fail2ban-client status
For comprehensive server-level security beyond Nginx, see our guide on securing and firewalling your VPS server.
Step 12 — Monitoring and Logging
1. Enable Nginx Status Page
# Only accessible from localhost — for monitoring tools location /nginx_status { stub_status on; allow 127.0.0.1; deny all; }
sudo nginx -t sudo systemctl reload nginx curl http://127.0.0.1/nginx_status
Output shows active connections, total requests, reading/writing/waiting counts — useful for dashboards and alerting.
2. Structured Access Logging
The default Nginx log format doesn’t include upstream information. Add a custom format that shows which backend handled each request and how long it took:
sudo nano /etc/nginx/nginx.conf
Add in the http block:
log_format proxy_log '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'upstream=$upstream_addr ' 'upstream_response_time=$upstream_response_time ' 'request_time=$request_time ' 'cache=$upstream_cache_status';
Use it in your server block:
access_log /var/log/nginx/yourdomain.com-access.log proxy_log; error_log /var/log/nginx/yourdomain.com-error.log warn;
Now every log line tells you which backend served the request, how long the backend took (upstream_response_time), total request time, and whether the response came from cache. This is invaluable for debugging slow responses and identifying overloaded backends.
3. Monitor Logs in Real Time
# Watch access log live sudo tail -f /var/log/nginx/yourdomain.com-access.log # Watch errors only sudo tail -f /var/log/nginx/yourdomain.com-error.log # Count requests per backend (check load distribution) sudo awk '{print $NF}' /var/log/nginx/yourdomain.com-access.log | grep upstream | sort | uniq -c | sort -rn
4. Set Up Uptime Monitoring
Install Uptime Kuma for a self-hosted monitoring dashboard that alerts you when your proxy or backends go down:
pm2 start "npx uptime-kuma" --name uptime-kuma
Or set it up properly with Docker. For comprehensive monitoring with Grafana and Prometheus, see our guide on monitoring dashboards. For basic uptime checks, external services like UptimeRobot (free tier) can ping your domain every 5 minutes and email you when it’s down.
5. Log Rotation
Nginx logs can grow fast under traffic. Ubuntu’s logrotate handles Nginx logs by default (/etc/logrotate.d/nginx), but verify it’s active:
cat /etc/logrotate.d/nginx
If it exists and shows daily rotation with compression, you’re covered. If not:
sudo nano /etc/logrotate.d/nginx
/var/log/nginx/*.log { daily missingok rotate 14 compress delaycompress notifempty sharedscripts postrotate [ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid) endscript }
Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Backend is not running or listening on wrong address | Check pm2 list — verify backend is running. Check backend binds to 127.0.0.1:PORT. Test with curl http://127.0.0.1:3001 |
| 504 Gateway Timeout | Backend is running but too slow to respond | Increase proxy_read_timeout in your location block. Check backend logs for slow queries or processing. Profile your application code |
| Connection refused in error log | Backend not listening or wrong port in proxy_pass | Run ss -tlnp | grep 3001 to verify the port is open. Match the port in proxy_pass exactly to what your app listens on |
| Nginx won’t start — address already in use | Another process (Apache, old Nginx) is already using port 80 | Run sudo ss -tlnp | grep :80 to find the process. Stop it with sudo systemctl stop apache2 or kill the PID |
| Load balancing not distributing evenly | Browser keepalive reuses same connection | Test with curl instead of browser — each curl request opens a new connection. Or use for i in {1..10}; do curl -s https://yourdomain.com | jq .port; done |
| WebSocket connections drop after 60 seconds | Missing upgrade headers or default proxy timeout | Add proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". Set proxy_read_timeout 86400s |
| Backend receives 127.0.0.1 as client IP | Missing X-Real-IP / X-Forwarded-For headers | Add proxy_set_header X-Real-IP $remote_addr and configure your app to trust proxy headers |
| Cached content not updating | Proxy cache serving stale responses | Purge cache: sudo rm -rf /var/cache/nginx/proxy/* then sudo systemctl reload nginx. Lower proxy_cache_valid duration or add cache bypass headers |
| SSL certificate fails — challenge failed | DNS not pointing to VPS or port 80 blocked | Run dig yourdomain.com +short — must return your VPS IP. Verify port 80 is open in both UFW and your cloud provider’s firewall |
| nginx -t shows “duplicate upstream” error | Same upstream name defined in multiple config files | Upstream names must be globally unique across all config files. Rename one of them |
| Mixed content after adding SSL | Backend generates HTTP URLs instead of HTTPS | Ensure proxy_set_header X-Forwarded-Proto $scheme is set and your application reads this header to generate URLs |
| 413 Request Entity Too Large | client_max_body_size too small for upload |
Increase client_max_body_size in your server block — e.g., client_max_body_size 64m |
Complete Configuration Reference
Here’s a production-ready Nginx reverse proxy config combining everything from this guide — load balancing, SSL, caching, WebSockets, security, and performance tuning:
# /etc/nginx/sites-available/yourdomain.com upstream app_backends { least_conn; server 127.0.0.1:3001 max_fails=3 fail_timeout=30s; server 127.0.0.1:3003 max_fails=3 fail_timeout=30s; server 127.0.0.1:3004 max_fails=3 fail_timeout=30s; keepalive 32; } server { listen 443 ssl http2; server_name yourdomain.com www.yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Security server_tokens off; client_max_body_size 10m; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Logging access_log /var/log/nginx/yourdomain.com-access.log proxy_log; error_log /var/log/nginx/yourdomain.com-error.log warn; # Static files — served directly by Nginx location /static/ { alias /var/www/yourdomain.com/static/; expires 30d; add_header Cache-Control "public, immutable"; access_log off; } # WebSocket endpoint location /ws/ { proxy_pass http://app_backends; 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_read_timeout 86400s; proxy_send_timeout 86400s; } # Main application — proxied with caching location / { limit_req zone=general burst=20 nodelay; proxy_pass http://app_backends; proxy_http_version 1.1; proxy_set_header Connection ""; 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; # Caching proxy_cache proxy_cache; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; proxy_cache_bypass $http_authorization $cookie_sessionid; proxy_no_cache $http_authorization $cookie_sessionid; add_header X-Cache-Status $upstream_cache_status; } # Block hidden files location ~ /\. { deny all; return 404; } # Monitoring (localhost only) location /nginx_status { stub_status on; allow 127.0.0.1; deny all; } # Custom error pages error_page 502 503 504 /custom_502.html; location = /custom_502.html { root /var/www/error-pages; internal; } } # HTTP to HTTPS redirect server { listen 80; server_name yourdomain.com www.yourdomain.com; return 301 https://$host$request_uri; }
Your Nginx reverse proxy is now handling SSL termination, distributing traffic across multiple backends with automatic failover, caching responses, compressing output, rate-limiting abusive clients, and logging everything with upstream details. All on one server.
The VPSWala Professional free trial — 8-core AMD EPYC, 8 GB DDR5 ECC RAM, 1 TB Micron NVMe — gives Nginx 8 worker processes and enough RAM to cache thousands of responses while running 5–10 backend instances simultaneously. That’s a production load balancer setup that rivals $40–60/month dedicated load balancer services, free for 30 days with no credit card required.
For related setups, see our guides on hosting WordPress on a free VPS with Apache, subdomains with Let’s Encrypt on Nginx, deploying a Node.js app on a free VPS, and running multiple VPS instances. Need a Windows environment instead? Grab a free RDP server here.

