nginx as a Reverse Proxy: Best Practices
Configure nginx as a production-grade reverse proxy: upstream keepalive pools, buffering tuning, WebSocket upgrades, and correct X-Forwarded-* header handling.
Before you start
- ▸Root or sudo access on the server
- ▸An application server already running and listening on a local TCP port
- ▸A registered domain name or known IP address pointed at the server
- ▸Basic familiarity with editing files on the command line
Running nginx as a reverse proxy in front of an application server—Node.js, Gunicorn, a Java service—is one of the most common production patterns on Linux. Done carelessly, you end up with broken WebSocket connections, leaked internal IPs, and a proxy that hammers your upstream with short-lived TCP connections. The configuration below reflects what actually works in production, covering upstream pools, keepalive tuning, response buffering, WebSocket proxying, and the forwarding headers your application needs to trust.
Prerequisites and Installation
Install nginx from your distro's official repos. The mainline branch (1.25+) is recommended; it receives bug and security fixes faster than the stable branch.
# Debian / Ubuntu
sudo apt update && sudo apt install -y nginx
# Fedora / RHEL 9 / Rocky 9
sudo dnf install -y nginx
# Arch
sudo pacman -S nginx
sudo systemctl enable --now nginx
All examples below assume your app listens on 127.0.0.1:3000. Adjust the address and port for your stack.
Structuring the Config File
Rather than stuffing everything into /etc/nginx/nginx.conf, place your site config in a separate file and include it. Debian/Ubuntu uses /etc/nginx/sites-available/ with a symlink to sites-enabled/; Fedora/Arch use /etc/nginx/conf.d/. Either pattern works—pick one and be consistent.
# Debian/Ubuntu
sudo nano /etc/nginx/sites-available/myapp
# Fedora / Arch
sudo nano /etc/nginx/conf.d/myapp.conf
Upstream Block and Keepalive Connections
An upstream block names a pool of backend servers. Even with a single backend, declaring it here lets you tune keepalive without repeating the address everywhere. Without keepalive, nginx opens a new TCP connection to your app for every proxied request—wasteful and slow under load.
upstream app_backend {
server 127.0.0.1:3000;
# Maximum idle keepalive connections to the upstream
# PER worker process. Start at 32; raise if workers are
# saturating this limit under load.
keepalive 32;
# Optional: mark a second instance as hot-standby
# server 127.0.0.1:3001 backup;
}
For keepalive to work, the proxy directives in your location block must also set the HTTP version and clear the Connection header:
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
Without proxy_http_version 1.1, nginx defaults to HTTP/1.0, which does not support persistent connections regardless of the keepalive setting.
Forwarding Headers: X-Forwarded-* and Real IP
Your application server sees nginx as the client. Without forwarding headers, it cannot determine the real client IP, the original protocol, or the original Host. Set all three consistently.
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Original client IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Original protocol (http or https)
proxy_set_header X-Forwarded-Proto $scheme;
# Preserve the Host header the browser sent
proxy_set_header Host $host;
}
Important: If there is another proxy upstream of nginx (a CDN or load balancer), $proxy_add_x_forwarded_for appends the additional IP correctly. Never blindly trust X-Forwarded-For values sent by untrusted clients—always configure your application to trust only the proxy layer.
If your application also needs the original port:
proxy_set_header X-Forwarded-Port $server_port;
Buffering
Buffering controls whether nginx holds the full upstream response in memory/disk before sending it to the client, or streams it directly. The right setting depends on your workload.
Response Buffering (proxy_buffering)
Enabled by default. nginx reads the full response from the upstream, frees the upstream connection quickly, then sends the response to the (potentially slow) client. Good for most HTTP APIs and HTML pages.
proxy_buffering on;
proxy_buffer_size 16k; # holds response headers
proxy_buffers 8 32k; # 8 buffers × 32 KB each for body
proxy_busy_buffers_size 64k;
For large file downloads or streaming responses (server-sent events, chunked JSON), disable buffering on that specific location:
location /stream/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
# Also disable gzip on streaming endpoints:
gzip off;
}
Request Buffering (proxy_request_buffering)
By default nginx buffers the entire client request body before forwarding it. For large file uploads you may want to stream the body straight to the upstream to save memory and reduce latency:
proxy_request_buffering off;
client_max_body_size 50m;
WebSocket Proxying
WebSocket connections start as an HTTP/1.1 Upgrade request. nginx does not handle this automatically; you must explicitly pass the Upgrade and Connection headers.
location /ws/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
# These two headers perform the protocol upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Forward real IP as usual
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 can be long-lived; extend timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Do not set Connection "" here—WebSocket requires the Connection: upgrade value, not an empty string.
Timeouts and Error Handling
Sensible defaults prevent slow upstreams from holding connections open indefinitely:
proxy_connect_timeout 5s; # time to establish TCP to upstream
proxy_send_timeout 60s; # time to send request to upstream
proxy_read_timeout 60s; # time to receive response from upstream
# Retry on the next upstream if the current one fails to connect
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
Complete Example Configuration
upstream app_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com;
# Redirect HTTP → HTTPS in production
# return 301 https://$host$request_uri;
access_log /var/log/nginx/myapp_access.log;
error_log /var/log/nginx/myapp_error.log warn;
location / {
proxy_pass http://app_backend;
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_buffering on;
proxy_buffer_size 16k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout;
}
location /ws/ {
proxy_pass http://app_backend;
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 3600s;
proxy_send_timeout 3600s;
}
}
Activating and Verifying
# Test syntax before reloading
sudo nginx -t
# Graceful reload—no dropped connections
sudo systemctl reload nginx
Verify forwarding headers reach your app by checking your application logs or hitting a debug endpoint that echoes request headers. From the server itself:
curl -s http://localhost/ -H "X-Test: hello" -D -
Check upstream keepalive is working by inspecting nginx's status module (if compiled in) or by watching connection states:
ss -tnp | grep :3000
You should see a small pool of ESTABLISHED connections that persist across multiple requests rather than rapidly cycling through TIME_WAIT.
Troubleshooting
- 502 Bad Gateway: nginx cannot reach the upstream. Confirm the app is listening (
ss -tlnp | grep 3000) and that SELinux/AppArmor is not blocking the connection (sudo ausearch -m avc -ts recenton RHEL family;sudo aa-statuson Debian/Ubuntu). On RHEL/Rocky,sudo setsebool -P httpd_can_network_connect 1is frequently the fix. - WebSocket disconnects immediately: Confirm the
UpgradeandConnectionheaders are set and that you are not clearing Connection withproxy_set_header Connection ""in the WebSocket location. - Application sees wrong IP (always 127.0.0.1): The
X-Forwarded-Forheader is being set but your app is readingREMOTE_ADDRdirectly. Configure your framework to trust the proxy (e.g., Expressapp.set('trust proxy', 1), DjangoUSE_X_FORWARDED_HOST). - Large uploads failing: Increase
client_max_body_sizeand consider settingproxy_request_buffering off. Also check the upstream's own body size limit. - High TIME_WAIT on port 3000: keepalive is not active. Check that
proxy_http_version 1.1andproxy_set_header Connection ""are both present in every location that uses the upstream.
Frequently asked questions
- Why do I still see TIME_WAIT connections even with keepalive configured?
- Both proxy_http_version 1.1 and proxy_set_header Connection "" must be present in every location block using the upstream. If either is missing, nginx falls back to HTTP/1.0 or sends Connection: close, preventing connection reuse.
- How many keepalive connections should I set?
- Start at 32 per worker. Multiply workers (worker_processes) × keepalive to estimate the total idle connections your upstream must tolerate. Raise the value if your monitoring shows workers hitting the limit under peak load, but stay within the upstream's connection limit.
- Should I disable proxy_buffering for all locations?
- No. Buffering frees upstream connections quickly when clients are slow, which is beneficial for most request types. Only disable it for streaming endpoints (SSE, chunked long-poll, large file downloads) where you need low latency or cannot buffer the full body.
- My application still sees 127.0.0.1 as the client IP. What's wrong?
- nginx is sending X-Forwarded-For correctly, but your application is reading the raw socket address. Configure your framework to use the proxy header—for example, trust proxy in Express, FORWARDED_ALLOW_IPS in Gunicorn, or the equivalent for your stack.
- Does this configuration work behind a CDN or another load balancer?
- Yes, but be careful with X-Forwarded-For. Use $proxy_add_x_forwarded_for (not $remote_addr alone) so existing forwarding chain values are preserved. Configure your application to trust only the known proxy addresses to prevent clients from spoofing the header.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.