Blog

Nginx in Front: Turning One Public Port Into Many Services

A reverse proxy lets one Nginx instance terminate TLS, route by hostname, and front a dozen backends, which is exactly how a single VPS hosts many apps cleanly.

March 7, 20266 min readMuhammad Shehzaib
DEVOPSTLSNGINXREVERSEPROXY

If you run more than one app on a single machine, something has to decide which request goes where, and Nginx as a reverse proxy is the workhorse for that job. A reverse proxy sits in front of your application servers, accepts every incoming request, and forwards it to the right backend, hiding the messy internal topology behind one clean public face. On a VPS hosting several Dockerized projects on different subdomains, this is the piece that makes them coexist on ports 80 and 443 without conflict. Coming from running a Node app directly on a port, the shift is that Nginx now owns the public edge and your apps listen only on internal ports. This article covers what a reverse proxy does, how server blocks route by hostname, TLS termination, the headers backends depend on, WebSocket and gRPC quirks, caching, and the configuration mistakes that cause the most confusion.

Forward Versus Reverse Proxy

The word proxy gets used both directions, so it is worth pinning down. A forward proxy sits in front of clients and represents them to the outside world, like a corporate gateway that all employee browsers route through. A reverse proxy sits in front of servers and represents them to incoming clients, so the user talks to the proxy believing it is the application, while the proxy quietly forwards to whichever backend should handle the request. Nginx in this role becomes the single public entry point: it owns ports 80 and 443, and your actual application processes listen on private ports that the outside world never touches directly. This indirection is what lets you swap backends, add servers, terminate TLS in one place, and route many domains through one host, all without the client ever knowing or caring what runs behind the curtain.

Routing By Server Name

Nginx decides which backend handles a request primarily through server blocks matched by the host header. Each block declares a server_name like api.example.com and the ports it listens on, and when a request arrives Nginx compares the requested hostname against those names to pick the matching block. This is exactly how one machine serves portfolio, blog, and api subdomains from a single Nginx process: each subdomain gets its own server block pointing at its own backend. Within a block, location directives route by URL path, so you can send everything under one prefix to one service and another prefix elsewhere. Understanding the matching order matters, because Nginx prefers exact server names over wildcards and longest-prefix location matches over shorter ones. When a subdomain mysteriously serves the wrong app, the cause is almost always a missing or misordered server_name rather than anything in the backend itself.

Passing Traffic Upstream

The directive that actually forwards a request is proxy_pass, which tells Nginx where to send the matched traffic, typically an internal address and port where your application listens. For repeated or load-balanced backends you define an upstream block listing several servers, and Nginx balances across them, defaulting to round robin with options for least connections and weights. On a Docker host, proxy_pass often targets a container by its service name on an internal network rather than a public address, which is why your apps need to expose ports only to Nginx and not to the world. Getting the trailing slash right on proxy_pass changes how the path is rewritten, a subtle source of bugs where requests arrive at the backend with an unexpected prefix. Treat the upstream definition as the seam between your public edge and your private services, and keep backends bound to internal interfaces only.

Terminating TLS Once

Handling HTTPS at the proxy, called TLS termination, means Nginx decrypts incoming traffic and talks plain HTTP to your backends over the trusted internal network. This centralizes certificate management so you renew and configure TLS in one place instead of teaching every app to do it, and it offloads the cryptographic work from your application processes. A typical setup pairs Nginx with automated certificates so each subdomain gets and renews its own without manual steps, and a server block on port 80 that redirects everything to 443 so plain HTTP never serves real content. The consideration is that traffic between Nginx and your backends is now unencrypted, which is fine on a private Docker network on one host but needs rethinking if backends live on separate machines across an untrusted link. For a single VPS, terminating at Nginx is the clean, standard arrangement that keeps your apps simple.

Headers Your Backend Needs

Once Nginx sits in front, your application no longer sees the real client directly, and unless you forward the right headers it will think every request came from the proxy itself. Set X-Forwarded-For so the backend can recover the original client IP for logging and rate limiting, X-Forwarded-Proto so it knows the original request was HTTPS even though Nginx forwarded plain HTTP, and the Host header so name-based logic and generated URLs use the public domain rather than the internal target. Frameworks like Express need to be told to trust the proxy before they will honor these forwarded values, otherwise they ignore them for safety. Forgetting X-Forwarded-Proto is a classic bug: the app believes it is on HTTP, generates insecure redirect URLs or refuses to set secure cookies, and you chase a phantom problem that is just a missing header.

WebSockets And gRPC Quirks

Nginx does not proxy WebSocket and gRPC traffic correctly with its default HTTP settings, and the symptoms are confusing if you do not know why. A WebSocket connection begins as an HTTP request asking to upgrade the protocol, and unless you explicitly forward the Upgrade and Connection headers and tell Nginx to use HTTP/1.1 for that location, it treats the upgrade as a normal request and the connection never completes, so realtime features silently fail. gRPC has its own requirement because it rides on HTTP/2 end to end; you must use the grpc_pass directive rather than proxy_pass so Nginx speaks HTTP/2 to the backend instead of downgrading it. These are exactly the kinds of issues that make a Socket.IO app work locally but break once it goes behind the proxy. When realtime or gRPC traffic dies only in production, the proxy configuration is the first place to look.

Caching And Buffering

Nginx can cache backend responses so repeated requests for the same content are served straight from the proxy without ever touching your application, which dramatically reduces load for anything cacheable like static assets or stable API responses. You define a cache zone and rules for what to store and for how long, and Nginx respects or overrides the backend's cache headers depending on your configuration. Buffering is related but distinct: by default Nginx reads the full backend response before sending it to a slow client, which frees your application quickly instead of holding a worker hostage to a slow connection. That same buffering can hurt streaming responses, where you want bytes flowing to the client immediately, so you disable it for those endpoints. Used well, caching and buffering let a small backend serve far more traffic, but cache the wrong dynamic response and you serve one user's data to another.

Reloads And Debugging

Nginx configuration changes apply through a reload, not a restart, and the distinction matters in production. Always run the config test first; Nginx will tell you about syntax errors and undefined references before you apply anything, and reloading a broken config is a self-inflicted outage. A successful reload swaps in the new configuration while existing connections drain gracefully, so you change routing without dropping live traffic. When something misbehaves, the access and error logs are your first stop: the error log reveals upstream connection failures, permission problems, and the exact line a bad config rejected, while the access log shows which server block and status each request actually hit. A surprising amount of reverse-proxy debugging is just confirming whether the request even reached the backend or died at Nginx. Make logging detailed enough to answer that question instantly, because it narrows every investigation in half.