Setting Up Nginx as a Reverse Proxy for Multiple Apps

What a reverse proxy does?

  • Sits in front of one or more backend apps/services, forwards incoming requests to the right one based on domain, path, or port
  • Client only ever talks to the proxy. Backend processes never need to be exposed directly to the internet

So why do we need reverse proxy?

  • Single entry point on port 80/443 instead of exposing each app on its own raw port
  • TLS/SSL termination handled in one place instead of configuring it per app
  • Can route multiple apps or services through one server, each reachable by its own domain or subdomain
  • Backend ports stay closed to the outside world, only reachable from the proxy itself (localhost)

Architecture

Each backend binds only to localhost/127.0.0.1, nginx is the only process exposed externally

Core config structure

  • One server block per domain/app, distinguished by server_name
  • location block(s) inside each, matching the paths to forward
  • proxy_pass directive pointing at the backend address (http://127.0.0.1:PORT)

Headers worth setting on proxied requests, and why

  • Host: passes the original requested domain through to the backend
  • X-Real-IP: preserves the actual client IP (otherwise every request looks like it came from the proxy itself)
  • X-Forwarded-For: chain of IPs if there are multiple proxies/hops
  • X-Forwarded-Proto: tells the backend whether the original request was http or https. Without this, an app can generate broken redirect URLs (redirecting to http even when the client is on https)

The problem

  • Symptom: everything works through the proxy except a feature relying on a persistent connection (live chat, real-time notifications, websockets). It loads fine, then silently stops updating
  • Why: nginx doesn’t forward the Upgrade and Connection headers by default, so the backend never sees the HTTP upgrade request that starts a WebSocket connection. The connection quietly falls back or fails
  • Fix: add these two lines inside the relevant location block
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Example config file

server {
    listen 80;
    server_name app-two.example.com;

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

What actually makes this a reverse proxy

Compare it to a plain nginx server block that just serves static files or hands off to PHP-FPM directly. What’s different here, and what each piece is doing:

  • proxy_pass http://127.0.0.1:8080;: this is the one line that defines it. Instead of nginx serving a file or handing off to a local process handler (like fastcgi_pass for PHP), it forwards the entire request to a separate running service and relays that service’s response back to the client. Nginx isn’t the thing answering the request, it’s relaying to something else that is.
  • proxy_set_header lines: not strictly required for the proxy to function (it would forward requests without them), but required for the backend app to behave correctly. Without them the backend sees every request as if it came from nginx itself, on the wrong protocol, with no idea what domain was actually requested.
  • No root or try_files directive: a static/PHP-serving block needs those to find files on disk. A reverse proxy block doesn’t touch the filesystem at all, everything after proxy_pass is the other service’s job.
  • The backend address is 127.0.0.1, not 0.0.0.0 or a public IP: the backend only needs to be reachable from nginx itself, not from the internet directly.

If a config has proxy_pass pointing at another address, that’s the defining trait. Everything else (headers, timeouts, buffering settings) is tuning around that core behavior, not what makes it a reverse proxy in the first place.

Takeaway

  • If a proxied app has any real-time feature and it stops working only through the proxy (not when hitting the backend directly), check the Upgrade/Connection headers before anything else

Book a Free Consultation

Haven’t found the right solution yet?
Tell us about your needs, and let’s discuss the best solution together.