Nginx Reverse Proxy Setup: Directing Traffic Efficiently
Set up an Nginx reverse proxy with clear routing, correct headers, WebSocket support, timeouts, buffering, and troubleshooting steps.
Nginx Reverse Proxy Setup: Directing Traffic Efficiently
An Nginx reverse proxy setup lets Nginx receive public web traffic and forward it to one or more backend applications. This is useful when your app runs on Node.js, Python, Go, Java, or another service that should not be exposed directly to the internet.
Instead of users connecting to your app port, they connect to Nginx on standard HTTP or HTTPS ports. Nginx handles the public edge, then directs traffic efficiently to the right internal service.
What a Reverse Proxy Does
A reverse proxy sits in front of your application servers. The client talks to Nginx, and Nginx talks to the backend. To the browser, Nginx is the website. To the app, Nginx is the upstream client unless you pass headers that preserve the original request details.
This pattern gives you several benefits:
- You can run apps on private ports like
3000,5000, or8080. - You can terminate TLS at Nginx.
- You can route different hostnames or paths to different services.
- You can add buffering, timeouts, compression, and caching.
- You can hide backend implementation details from the public network.
A basic reverse proxy for an app running on 127.0.0.1:3000 looks like this:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
The proxy_pass directive tells Nginx where to send the request. The proxy_set_header lines preserve useful request context. Without them, your app may log every request as coming from Nginx and may not know whether the original request used HTTP or HTTPS.
If you are new to virtual host structure, review Nginx server blocks before splitting traffic across multiple domains.
Routing Traffic by Host or Path
Reverse proxy rules usually route by hostname, path, or both. Host-based routing is common when separate apps use different domains:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:4000;
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;
}
}
Path-based routing is useful when one domain fronts multiple services:
server {
listen 80;
server_name example.com;
location /api/ {
proxy_pass http://127.0.0.1:4000/;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Be careful with trailing slashes in proxy_pass. In Nginx, proxy_pass http://backend; and proxy_pass http://backend/; can rewrite the forwarded URI differently when used inside a location block. Test the exact URL paths your app expects.
For example, if /api/users unexpectedly reaches your backend as /users or /api/api/users, check the location prefix and trailing slash combination first. That is one of the most common reverse proxy mistakes.
Headers, Timeouts, and WebSockets
Headers make the backend aware of the original request. The Host header matters when the app builds absolute URLs, validates allowed hosts, or supports multiple tenants. X-Forwarded-For helps preserve the original client IP. X-Forwarded-Proto helps apps generate secure links after TLS termination.
If your backend uses WebSockets, add upgrade headers:
location /socket/ {
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;
}
Timeouts should match the behavior of your application. A normal web request should finish quickly. A report export, streaming endpoint, or long polling request may need more time:
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
Avoid setting huge timeouts everywhere just to hide one slow endpoint. Long timeouts can tie up resources and make real outages harder to notice. Tune the location that needs it.
Buffering is another important setting. By default, Nginx can buffer upstream responses before sending them to the client. This is helpful for many web apps, but streaming endpoints may need buffering disabled:
proxy_buffering off;
Use that only where streaming behavior is required. For standard HTML and API responses, buffering often improves stability.
TLS Termination and HTTPS Redirects
In many setups, Nginx also handles HTTPS. That lets the backend app run on a private HTTP port while users get a normal secure site on port 443.
A common shape looks like this:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
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://127.0.0.1:3000;
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;
}
}
The redirect server block is intentionally small. It does one job: move plain HTTP traffic to HTTPS. The HTTPS server block handles the proxying.
If your app sits behind Nginx and still generates http:// links, check whether it trusts X-Forwarded-Proto. Many frameworks need a setting such as "trust proxy" or an allowed proxy list before they use forwarded headers. Do not blindly trust forwarded headers from the public internet at the application layer; make sure only Nginx can reach the app port.
Upstream Groups and Simple Load Balancing
When one backend is not enough, define an upstream group:
upstream app_backend {
server 10.0.1.10:3000;
server 10.0.1.11:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com;
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;
}
}
Open source Nginx uses round-robin load balancing by default. You can also use options such as least_conn when long requests make one backend busier than the others. Health checking in open source Nginx is mostly passive: if a backend fails, Nginx can mark it unavailable for a period based on failure settings. Nginx Plus has active health checks, but do not assume those features exist in every installation.
Keepalive in the upstream block keeps backend connections open for reuse. That helps with lots of small requests, but the backend must be able to handle the number of idle and active connections Nginx may keep.
Containers and Private Networks
Reverse proxy setup often gets confusing in Docker or Kubernetes because localhost changes meaning. If Nginx is running inside one container, 127.0.0.1:3000 points to the Nginx container itself, not a separate app container.
In Docker Compose, proxy to the service name:
location / {
proxy_pass http://app:3000;
}
In Kubernetes, you usually proxy to a Service DNS name, though many Kubernetes deployments use an Ingress controller instead of hand-written Nginx server blocks.
The simple rule is this: test connectivity from where Nginx runs, not from your laptop and not from the backend container. If this fails, Nginx will fail too:
curl -v http://app:3000/
Run that inside the Nginx container or on the Nginx host, depending on your deployment.
Security Boundaries Worth Checking
A reverse proxy should reduce public exposure, not accidentally create more of it. The backend app should normally listen on a private interface, a private subnet, or a container network. If your app listens on 0.0.0.0:3000 on a public VM, users may be able to bypass Nginx entirely by visiting http://example.com:3000.
Check listening ports on the host:
sudo ss -ltnp
If the backend must listen on all interfaces inside a container, use firewall rules, security groups, or container network settings so only Nginx can reach it from outside. This matters because apps often rely on Nginx for TLS, request size limits, rate limits, authentication gateways, or IP allowlists.
Also be careful with forwarded headers. Headers such as X-Forwarded-For are easy for clients to spoof unless Nginx overwrites them and the app trusts only the proxy. The common Nginx pattern is:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
That appends the client address to the chain. Your application or logging pipeline should know which proxy addresses are trusted. Otherwise, rate limiting or audit logs may record the wrong "client" IP.
Request size limits belong in this conversation too. If your app accepts file uploads, set client_max_body_size intentionally:
client_max_body_size 25m;
Do not raise it globally to a huge value unless every route needs that. A profile photo upload endpoint and a JSON login endpoint should not need the same request body limit.
A Practical Deployment Checklist
Before calling the reverse proxy done, test it like a user and like an operator:
curl -I http://example.com/should show the expected redirect or response.curl -I https://example.com/should show the expected status and headers.- The app logs should show the original host and a useful client IP.
- WebSocket or streaming endpoints should be tested separately.
- A wrong path, such as
/api/does-not-exist, should fail in the way your app expects. - Nginx error logs should be quiet during normal requests.
For path routing, I like to test three URLs for each location: the bare prefix, one normal nested path, and one path with a query string. For example:
curl -i http://example.com/api/
curl -i http://example.com/api/users
curl -i 'http://example.com/api/users?page=2'
Those simple checks catch many trailing-slash mistakes before users do.
When you reload, use the same safe sequence every time:
sudo nginx -t
sudo systemctl reload nginx
sudo tail -n 50 /var/log/nginx/error.log
If the app is behind TLS termination, also verify that generated links, redirects, cookies, and callback URLs use HTTPS. Login flows are where this often breaks first, because redirects and secure cookies depend on the app understanding the original scheme.
Common Failure Patterns
502 Bad Gateway usually means Nginx reached the reverse proxy location but could not get a valid response from the upstream. The backend may be down, the port may be wrong, the app may be listening on a different interface, or the connection may be refused by a firewall.
504 Gateway Timeout usually means Nginx connected to something but did not receive a response in time. That can be a slow app, a blocked database query, an overloaded worker pool, or a timeout that is too short for the endpoint. Increasing proxy_read_timeout may be appropriate for a known long-running export endpoint. It is not a fix for a generally slow app.
Redirect loops often come from a mismatch between TLS termination and application trust settings. The browser reaches Nginx over HTTPS, Nginx proxies to the app over HTTP, and the app thinks the original request was plain HTTP. The app redirects to HTTPS, but the same thing happens again. Passing X-Forwarded-Proto is only half of the fix; the app must also trust it from the proxy.
Missing client IPs usually show up as every request coming from 127.0.0.1, a Docker bridge address, or a private load balancer address. Pass X-Real-IP and X-Forwarded-For, then configure the application and logging layer to read them safely.
Broken static assets after path routing often come from apps that assume they live at /. If you mount an app under /admin/, it may still generate links to /assets/app.css. You can sometimes fix this with app base-path settings. Trying to rewrite every asset path in Nginx is usually fragile.
A Small Real-World Example
Imagine one VM running three services:
- A marketing site on
127.0.0.1:3000 - An API on
127.0.0.1:4000 - An admin tool on
127.0.0.1:5000
You might route them like this:
server {
listen 443 ssl;
server_name example.com;
location /api/ {
proxy_pass http://127.0.0.1:4000/;
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;
}
location /admin/ {
proxy_pass http://127.0.0.1:5000/;
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;
}
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;
}
}
This can work, but it has tradeoffs. The API and admin app must both behave correctly under their prefixes. If they do not, separate hostnames such as api.example.com and admin.example.com may be cleaner. Good reverse proxy design is not only about making Nginx accept the config; it is about choosing routing that your applications can live with.
Testing and Troubleshooting the Setup
Always test the configuration before reload:
nginx -t
Then reload Nginx and make a request through the public hostname. Check both the browser and the logs. Nginx access logs show whether the request reached Nginx. Error logs show connection failures, upstream timeouts, and bad gateway details.
A practical example: your Node.js app runs fine on curl http://127.0.0.1:3000, but the public site shows 502 Bad Gateway. That means Nginx is reachable, but it cannot successfully talk to the upstream. Check whether the app is listening on the expected address, whether the port is correct, and whether a local firewall blocks the connection.
Common reverse proxy issues include:
- Wrong upstream port or address.
- Backend bound to
localhostwhen Nginx runs in another container. - Missing WebSocket upgrade headers.
- App rejecting requests because the
Hostheader is unexpected. - Incorrect URI rewrite caused by a trailing slash.
- Timeouts that are too short for a slow endpoint.
For deeper upstream failures, use Nginx 502 troubleshooting.
When to Get Help
Ask a DevOps engineer for help if the reverse proxy spans multiple containers, private networks, TLS certificates, or load-balanced upstreams. These setups can fail in ways that look like Nginx problems but are actually DNS, firewall, container networking, or application health issues.
You should also get help before exposing admin panels, internal APIs, or staging services through a public reverse proxy. Small routing mistakes can create serious access problems.
An Nginx reverse proxy setup is one of the most useful patterns in web infrastructure. Keep the routing clear, pass the right headers, test path behavior carefully, and let Nginx be the stable public entry point for your backend services.