$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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 recent on RHEL family; sudo aa-status on Debian/Ubuntu). On RHEL/Rocky, sudo setsebool -P httpd_can_network_connect 1 is frequently the fix.
  • WebSocket disconnects immediately: Confirm the Upgrade and Connection headers are set and that you are not clearing Connection with proxy_set_header Connection "" in the WebSocket location.
  • Application sees wrong IP (always 127.0.0.1): The X-Forwarded-For header is being set but your app is reading REMOTE_ADDR directly. Configure your framework to trust the proxy (e.g., Express app.set('trust proxy', 1), Django USE_X_FORWARDED_HOST).
  • Large uploads failing: Increase client_max_body_size and consider setting proxy_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.1 and proxy_set_header Connection "" are both present in every location that uses the upstream.
tested on:Ubuntu 24.04Debian 12Fedora 40Rocky 9

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