Nginx as Reverse Proxy: Complete Setup Guide

Tested on: Ubuntu 26.04 LTS · Debian 12 · Rocky Linux 10 — Last updated: June 2026
Nginx handles reverse proxying better than almost anything else on Linux — it's fast, its configuration is explicit, and it composes cleanly across SSL termination, load balancing, caching, and WebSocket proxying. This guide covers the complete setup from a bare server to a production-ready reverse proxy configuration, including every header, directive, and edge case you'll encounter running real applications.
Prerequisites
- A Linux server running Ubuntu 26.04, Debian 12, or Rocky Linux 10
- Root or sudo access
- One or more backend applications already running (Node.js, Python/gunicorn, Go, etc.) — or you can substitute
localhost:3000with any running service for testing - DNS records pointing your domain(s) to this server (required for SSL certificates)
- Ports 80 and 443 open in your firewall (
ufw allow 'Nginx Full'on Ubuntu)
How a Reverse Proxy Works
Without a reverse proxy, every application needs its own port — your Node.js app on 3000, your FastAPI service on 8000, Grafana on 3001. None of them can share port 80 or 443. With Nginx in front, a single process owns the public ports and routes traffic based on hostname or URL path:
Internet → Nginx (port 80/443)
├── app.example.com → Node.js on localhost:3000
├── api.example.com → FastAPI on localhost:8000
├── example.com/grafana/ → Grafana on localhost:3001
└── static.example.com → /var/www/static/ (served directly)Nginx also becomes your single point for SSL certificates, security headers, rate limiting, and access logging. Your backend applications stay simple — they speak plain HTTP to localhost and never touch TLS or public IPs directly.
Install Nginx
# Ubuntu / Debian
sudo apt update && sudo apt install nginx -y
# Rocky Linux / RHEL / Fedora
sudo dnf install nginx -y
# Arch Linux
sudo pacman -S nginx
# Enable and start
sudo systemctl enable --now nginx
# Confirm version and status
nginx -v
sudo systemctl status nginx
# Quick smoke test
curl -I http://localhostExpected output from nginx -v:
nginx version: nginx/1.24.0 (Ubuntu)On Ubuntu and Debian, Nginx uses the sites-available / sites-enabled pattern. On RHEL-based systems, drop config files directly into /etc/nginx/conf.d/. The examples below use the Ubuntu layout — adjust the path if you're on Rocky Linux.
Create a Reusable proxy_params File
Before writing any server blocks, create a shared parameters file. Every proxy location will include this, so you define the headers once instead of repeating them everywhere:
sudo tee /etc/nginx/proxy_params << 'EOF'
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
EOFThe Upgrade and Connection headers are needed for WebSocket connections — they don't hurt regular HTTP proxying, so include them universally. The $connection_upgrade variable requires a map block in your http context (covered in the WebSocket section below).
Basic proxy_pass Configuration
Create a site configuration for your first application:
sudo nano /etc/nginx/sites-available/myappserver {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://localhost:3000;
include /etc/nginx/proxy_params;
}
}# Enable the site and test the config
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxThe nginx -t command validates the config without reloading — always run it before applying changes. A successful test shows:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulOne critical detail with proxy_pass: the trailing slash matters. proxy_pass http://localhost:3000 passes the full URI as-is. proxy_pass http://localhost:3000/ (with trailing slash) strips the location prefix. For a root location / block these are equivalent, but for subpath proxying the difference is significant — covered in the Grafana example below.
SSL Termination
Install Certbot and get a certificate. Your DNS must already point to this server:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com
# Test automatic renewal
sudo certbot renew --dry-runCertbot modifies your Nginx config automatically. For full control, write the SSL server block manually:
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# Modern TLS — drops SSLv3, TLS 1.0, TLS 1.1
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# HSTS — tells browsers to always use HTTPS for this domain
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# TLS session cache — improves performance for returning clients
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
location / {
proxy_pass http://localhost:3000;
include /etc/nginx/proxy_params;
}
}Multiple Sites on One Server
Each site gets its own file in sites-available. Nginx matches the server_name to route requests to the right block:
# /etc/nginx/sites-available/site1
server {
listen 443 ssl http2;
server_name site1.example.com;
ssl_certificate /etc/letsencrypt/live/site1.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/site1.example.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
include /etc/nginx/proxy_params;
}
}
# /etc/nginx/sites-available/site2
server {
listen 443 ssl http2;
server_name site2.example.com;
ssl_certificate /etc/letsencrypt/live/site2.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/site2.example.com/privkey.pem;
location / {
proxy_pass http://localhost:4000;
include /etc/nginx/proxy_params;
}
}sudo ln -s /etc/nginx/sites-available/site1 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/site2 /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxLoad Balancing
Define an upstream block to distribute traffic across multiple backend instances. The upstream name is arbitrary — it's referenced in proxy_pass:
upstream myapp {
server localhost:3000;
server localhost:3001;
server localhost:3002;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://myapp;
include /etc/nginx/proxy_params;
}
}The default algorithm is round-robin. Override it with one of these directives inside the upstream block:
# Least connections — best for long-running requests (file uploads, slow queries)
upstream myapp {
least_conn;
server localhost:3000;
server localhost:3001;
}
# IP hash — same client always hits same server (sticky sessions, no shared session store needed)
upstream myapp {
ip_hash;
server localhost:3000;
server localhost:3001;
}
# Weighted — send 3x more traffic to the first server
upstream myapp {
server localhost:3000 weight=3;
server localhost:3001 weight=1;
}
# Backup — only receives traffic when all primary servers are down
upstream myapp {
server localhost:3000;
server localhost:3001 backup;
}Proxy Caching
Define the cache storage in the http block of /etc/nginx/nginx.conf, then reference it in location blocks:
# In /etc/nginx/nginx.conf — inside the http { } block
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=APPCACHE:10m
max_size=1g
inactive=60m
use_temp_path=off;# In your server block
location / {
proxy_pass http://myapp;
include /etc/nginx/proxy_params;
proxy_cache APPCACHE;
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_background_update on;
proxy_cache_lock on;
# Expose cache status in the response — useful for debugging
add_header X-Cache-Status $upstream_cache_status;
}
# Bypass cache entirely for admin and auth routes
location ~ ^/(admin|login|logout) {
proxy_pass http://myapp;
include /etc/nginx/proxy_params;
proxy_no_cache 1;
proxy_cache_bypass 1;
}The X-Cache-Status header returns HIT, MISS, BYPASS, EXPIRED, or STALE — check it with curl -I https://app.example.com to confirm caching is working.
WebSocket Proxying
WebSockets require an HTTP Upgrade handshake. Add the map block to your http context first, then configure the location:
# In /etc/nginx/nginx.conf — inside http { }
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}# In your server block
location /ws/ {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 86400s; # keep WS connection alive — adjust to your needs
proxy_send_timeout 86400s;
}If your proxy_params file already includes the Upgrade and Connection headers (as set up earlier), you only need the extended timeout overrides in the WebSocket location block.
Security Headers and Rate Limiting
# In http block — define rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/m;
limit_conn_zone $binary_remote_addr zone=addr:10m;# In your server block
server_tokens off; # don't expose Nginx version in headers or error pages
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header
Further Reading
