How To Guide

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.

{ "slug": "how-to-set-up-nginx-reverse-proxy", "title": "How to Set Up an Nginx Reverse Proxy (2026 Guide)", "description": "Configure an Nginx reverse proxy with upstream blocks, SSL via certbot, WebSocket upgrades, HTTP/2, caching, and proper X-Forwarded-For headers.", "type": "how-to", "keywords": [ "nginx reverse proxy", "proxy_pass", "upstream block", "X-Forwarded-For", "certbot ssl", "websocket nginx", "http/2", "proxy_cache" ], "toolSlugs": ["nginx", "certbot", "openssl", "curl"], "steps": [ { "name": "Install Nginx and verify the config directory", "text": "Install with 'sudo apt install nginx' (Debian/Ubuntu) or 'sudo dnf install nginx' (RHEL/Fedora). Confirm the include path with 'nginx -T' — most distros load site files from /etc/nginx/conf.d/*.conf or /etc/nginx/sites-enabled/*." }, { "name": "Define an upstream block for your backend", "text": "Create an upstream group naming each application server (host:port). Add 'keepalive 32;' so Nginx reuses backend TCP connections instead of dialing on every request." }, { "name": "Add a server block with proxy_pass and forwarded headers", "text": "Point 'proxy_pass' at the upstream. Set Host, X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto so your backend sees the real client IP and original scheme, not Nginx's loopback address." }, { "name": "Terminate TLS with certbot and enable HTTP/2", "text": "Run 'sudo certbot --nginx -d example.com' to issue a Let's Encrypt certificate and rewrite the server block. Change 'listen 443 ssl;' to 'listen 443 ssl; http2 on;' for multiplexed connections." }, { "name": "Enable WebSocket upgrades and response caching", "text": "Pass 'Upgrade' and 'Connection' headers so ws:// and wss:// connections tunnel through. Add a 'proxy_cache_path' zone and 'proxy_cache' directive to cache safe GETs at the edge." }, { "name": "Test the config, reload, and add a health check", "text": "Run 'sudo nginx -t' before every reload. Reload gracefully with 'sudo systemctl reload nginx'. Expose '/healthz' with 'return 200' or proxy it to your app so load balancers can probe liveness." } ], "content": "
\n

How to Set Up an Nginx Reverse Proxy

\n

A 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\n

Minimal nginx.conf for a reverse proxy

\n

Below 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.

\n
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\n

Upstream blocks and load balancing

\n

The 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:

\n
proxy_set_header Connection \"\";
\n\n

Forwarding the real client IP

\n

By default your backend sees 127.0.0.1 as the source address. Two headers fix this:

\n
    \n
  • X-Real-IP: $remote_addr — a single IP, the immediate client.
  • \n
  • X-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
\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.

\n\n

SSL termination with certbot

\n

Install the plugin and issue a certificate in one command:

\n
sudo apt install certbot python3-certbot-nginx\nsudo certbot --nginx -d example.com -d www.example.com
\n

Certbot 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.

\n\n

WebSocket support

\n

WebSockets 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:

\n
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}
\n

The long proxy_read_timeout stops idle sockets from being killed after 60 seconds.

\n\n

HTTP/2 and caching

\n

Modern 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:

\n
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\n

Health checks

\n

Open-source Nginx does passive health checking — it marks a backend down after max_fails errors within fail_timeout:

\n
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}
\n

Expose a cheap probe for external load balancers:

\n
location = /healthz {\n    access_log off;\n    return 200 \"ok\\n\";\n}
\n\n

Common 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_pass targets an upstream name you cannot append a path — put rewrites in a rewrite directive 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 on restart is not.
  • \n
\n
" }
nginx reverse proxy proxy_pass upstream block X-Forwarded-For certbot ssl websocket nginx http/2 proxy_cache

Explore 300+ Free Tools

Utilko has tools for developers, writers, designers, students, and everyday users — all free, all browser-based.