How to Set Up Nginx as a Reverse Proxy

How to Set Up Nginx as a Reverse Proxy

How to Set Up Nginx as a Reverse Proxy

Almost every application I deploy to production these days — whether it’s a Node.js API, a Python/Django backend, or a Go microservice — ends up sitting behind Nginx acting as a reverse proxy rather than being exposed directly to the internet. It’s one of the most common patterns in modern infrastructure, and once you understand the core proxy_pass directive and a handful of supporting headers, it becomes second nature.

In this guide, I’ll explain what a reverse proxy actually does, walk through a complete Nginx reverse proxy configuration, cover WebSocket support, load balancing across multiple backend instances, and the header and timeout settings that trip people up the most.

What a Reverse Proxy Does

A reverse proxy sits between clients and one or more backend servers, forwarding client requests to the appropriate backend and returning the backend’s response to the client. From the outside, visitors only ever talk to Nginx — they never connect directly to the application server behind it.

This gives me several practical advantages:

Requirements

Step 1: Basic Reverse Proxy Configuration

The core directive is proxy_pass, used inside a location block:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

This alone works, but it’s incomplete — by default, the backend application only sees Nginx’s IP as the source of every request, and it doesn’t know the original Host header the client used. I fix that by explicitly forwarding the right headers.

Step 2: Forwarding Essential Headers

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

Here’s what each of these does, since I’ve seen people copy-paste this without knowing why:

Most frameworks (Django, Express, Rails, Laravel) have built-in support for reading these headers to correctly reconstruct the original request context — but they usually need to be explicitly told to trust the proxy (e.g., Django’s SECURE_PROXY_SSL_HEADER setting).

Step 3: Setting Reasonable Timeouts

Backend applications sometimes take longer to respond than Nginx’s defaults allow, especially for long-running requests like file uploads or report generation. I set these explicitly rather than relying on defaults:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;
}

Step 4: WebSocket Support

If the backend application uses WebSockets (real-time chat apps, live dashboards, collaborative editors), the standard reverse proxy config isn’t enough on its own — WebSockets require an HTTP upgrade that Nginx needs to explicitly pass through:

location /ws/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
}

The map directive is useful here for handling the Connection header correctly across both regular HTTP and WebSocket requests sharing the same server block:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

Step 5: Load Balancing Across Multiple Backend Instances

When I need to distribute traffic across multiple instances of the same application (for scaling or high availability), I define an upstream block:

upstream backend_app {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://backend_app;
        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;
    }
}

By default, Nginx uses round-robin load balancing. Other options I use depending on the situation:

upstream backend_app {
    least_conn;                       # send to the instance with fewest active connections
    server 127.0.0.1:3000 weight=3;   # weighted — this instance gets 3x traffic
    server 127.0.0.1:3001;
    server 127.0.0.1:3002 backup;     # only used if the others are down
}

ip_hash is another useful option when the application needs session persistence (sticky sessions) without a shared session store:

upstream backend_app {
    ip_hash;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

Complete Example Configuration

Here’s a full, production-ready reverse proxy setup combining SSL termination, load balancing, and WebSocket support:

upstream backend_app {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://backend_app;
        proxy_http_version 1.1;

        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_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    location /ws/ {
        proxy_pass http://backend_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
    }
}

Testing the Reverse Proxy

sudo nginx -t
sudo systemctl reload nginx
curl -I https://example.com/

I confirm the backend actually receives the correct forwarded headers by temporarily logging them in the application, or using a simple test endpoint that echoes request headers back.

To test load balancing, I add a distinguishing response (like an instance ID) to each backend instance temporarily and send repeated requests to confirm traffic is distributed:

for i in {1..10}; do curl -s https://example.com/ | grep instance; done

Troubleshooting Common Issues

502 Bad Gateway. This means Nginx can’t reach the backend at all. Common causes: the backend application isn’t running, it’s listening on the wrong port or interface (e.g., bound to 127.0.0.1 when Nginx expects a different address, or vice versa), or a firewall is blocking the connection between them.

sudo systemctl status my-app
curl http://127.0.0.1:3000

504 Gateway Timeout. The backend is reachable but too slow to respond within the configured timeout. I either increase proxy_read_timeout for legitimately slow endpoints, or investigate why the backend itself is slow.

WebSocket connections fail or drop immediately. Almost always a missing Upgrade/Connection header pass-through, or proxy_http_version not set to 1.1 (WebSockets require HTTP/1.1, and Nginx defaults to 1.0 for proxied connections unless told otherwise).

Backend receives Nginx’s IP instead of the real client IP. Missing X-Real-IP and X-Forwarded-For headers, or the application isn’t configured to trust and read them from a proxy.

Redirect loops or incorrect URLs generated by the backend. Usually caused by the backend not knowing the original protocol (HTTP vs HTTPS) because X-Forwarded-Proto isn’t set or isn’t trusted by the application framework.

Security Considerations

proxy_hide_header X-Powered-By;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    proxy_pass http://backend_app;
}

Performance Tips

upstream backend_app {
    server 127.0.0.1:3000;
    keepalive 32;
}

location / {
    proxy_pass http://backend_app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
location /stream/ {
    proxy_pass http://backend_app;
    proxy_buffering off;
}

Real-World Use Cases

Best Practices

Wrapping Up

Reverse proxying is the backbone of how I deploy almost every application today — it decouples SSL, load balancing, and traffic management from the application itself, which makes the whole system easier to scale and secure. The core proxy_pass directive is deceptively simple, but the headers, timeouts, and WebSocket handling around it are where the real configuration work happens. Get those right once, and adding new backend instances or services later becomes a matter of editing an upstream block, not re-architecting anything.

Exit mobile version