How to Set Up an Nginx Reverse Proxy (2026 Guide)
SEO how-to guide for configuring an Nginx reverse proxy with upstream, SSL, WebSockets, HTTP/2, and caching.
How to Set Up an Nginx Reverse Proxy
\nA reverse proxy is a server that sits in front of one or more application backends, accepts client requests, and forwards them upstream. Unlike a forward proxy (which hides the client from the internet), a reverse proxy hides your backends behind a single public endpoint. Nginx is the most widely deployed reverse proxy on the internet because it handles TLS termination, HTTP/2, WebSockets, caching, load balancing, and gzip in a single lightweight event-loop process.
\n\nMinimal nginx.conf for a reverse proxy
\nBelow is a complete, production-shaped configuration that proxies example.com to a Node/Go/Python app listening on 127.0.0.1:3000. Drop it into /etc/nginx/conf.d/example.conf.
upstream app_backend {\n server 127.0.0.1:3000;\n server 127.0.0.1:3001 backup;\n keepalive 32;\n}\n\nserver {\n listen 80;\n server_name example.com www.example.com;\n return 301 https://$host$request_uri;\n}\n\nserver {\n listen 443 ssl;\n http2 on;\n server_name example.com www.example.com;\n\n ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;\n ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;\n ssl_protocols TLSv1.2 TLSv1.3;\n\n location / {\n proxy_pass http://app_backend;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Host $host;\n proxy_read_timeout 60s;\n }\n}\n\nUpstream blocks and load balancing
\nThe upstream directive groups backend servers. Nginx round-robins by default; add least_conn; or ip_hash; for stickier strategies. The keepalive directive is critical — without it, Nginx opens a new TCP+handshake for every proxied request, which cripples throughput. Pair it with proxy_http_version 1.1; and an empty Connection header inside the location:
proxy_set_header Connection \"\";\n\nForwarding the real client IP
\nBy default your backend sees 127.0.0.1 as the source address. Two headers fix this:
- \n
X-Real-IP: $remote_addr— a single IP, the immediate client. \nX-Forwarded-For: $proxy_add_x_forwarded_for— a comma-separated chain that appends the client to whatever the previous proxy sent. Use this variable, not$remote_addr, so chained proxies are preserved. \n
On the backend, trust these headers only when the connection came from Nginx. In Express set app.set('trust proxy', 'loopback'); in Django set SECURE_PROXY_SSL_HEADER. Never trust X-Forwarded-For from arbitrary clients — spoofing is trivial.
SSL termination with certbot
\nInstall the plugin and issue a certificate in one command:
\nsudo apt install certbot python3-certbot-nginx\nsudo certbot --nginx -d example.com -d www.example.com\nCertbot writes the ssl_certificate lines into your server block and installs a systemd timer that renews every 60 days. Verify with sudo certbot renew --dry-run.
WebSocket support
\nWebSockets start as HTTP/1.1 and upgrade via the Upgrade and Connection headers. Nginx will not forward hop-by-hop headers unless you tell it to:
map $http_upgrade $connection_upgrade {\n default upgrade;\n '' close;\n}\n\nlocation /ws/ {\n proxy_pass http://app_backend;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_read_timeout 3600s;\n}\nThe long proxy_read_timeout stops idle sockets from being killed after 60 seconds.
HTTP/2 and caching
\nModern Nginx (1.25+) prefers http2 on; as a standalone directive over the deprecated listen 443 ssl http2; shortcut. For a cache layer, declare the zone at http {} scope and enable it per location:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m\n max_size=1g inactive=60m use_temp_path=off;\n\nlocation /static/ {\n proxy_cache app_cache;\n proxy_cache_valid 200 302 10m;\n proxy_cache_valid 404 1m;\n add_header X-Cache-Status $upstream_cache_status;\n proxy_pass http://app_backend;\n}\n\nHealth checks
\nOpen-source Nginx does passive health checking — it marks a backend down after max_fails errors within fail_timeout:
upstream app_backend {\n server 10.0.0.11:3000 max_fails=3 fail_timeout=15s;\n server 10.0.0.12:3000 max_fails=3 fail_timeout=15s;\n}\nExpose a cheap probe for external load balancers:
\nlocation = /healthz {\n access_log off;\n return 200 \"ok\\n\";\n}\n\nCommon gotchas
\n- \n
- Host header: without
proxy_set_header Host $host;Nginx sends the upstream address (e.g.app_backend) as the Host, breaking virtual-hosted backends and OAuth redirects. \n - Trailing slash on proxy_pass:
proxy_pass http://backend/;(with slash) strips the location prefix;proxy_pass http://backend;(no slash) preserves the full URI. Mixing these up is the #1 cause of 404s. \n - Upstream name vs URL: when
proxy_passtargets an upstream name you cannot append a path — put rewrites in arewritedirective instead. \n - Client body size: the default
client_max_body_size 1m;rejects file uploads. Raise it explicitly. \n - Always test first:
sudo nginx -t && sudo systemctl reload nginx. A reload is zero-downtime; a bad config onrestartis not. \n