What is it? #
Your application should never face the internet directly. Nginx takes the public connection, handles TLS, and forwards requests to your application on localhost.
The architecture is the same whatever the language: the application listens on 127.0.0.1 on some port, Nginx listens on 443, and the two talk over the loopback interface.
The three details that cause problems are forwarded headers, timeouts and the upload size limit.
Once this is configured for one application, the same file structure serves every subsequent one.
Think of it like this #
A counter at the front and a workshop behind. Customers never enter the workshop, and the person at the counter passes along everything the workshop needs to know about who is asking.
Simple example #
An application listens on 127.0.0.1:8000. Nginx serves it at your domain over HTTPS, serves static files directly, allows 20 MB uploads and forwards the real client IP.
Code #
User ──HTTPS──▶ Nginx (443) ──HTTP──▶ Application (127.0.0.1:8000)
│ │
├── /static/ from disk └── database, cache
└── TLS, limits, logging
# /etc/nginx/sites-available/app.example.com
upstream app_backend {
server 127.0.0.1:8000 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
return 301 https://<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>h</mi><mi>o</mi><mi>s</mi><mi>t</mi></mrow><annotation encoding="application/x-tex">host</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal">h</span><span class="mord mathnormal">os</span><span class="mord mathnormal">t</span></span></span></span>request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
client_max_body_size 20M;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
location /static/ {
alias /srv/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /health {
proxy_pass http://app_backend;
access_log off; # keep health checks out of the log
}
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # enables upstream keepalive
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 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# Verify each layer
sudo ss -tulpn | grep 8000 # app on 127.0.0.1 only
curl -sI http://127.0.0.1:8000/health # app responds locally
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://app.example.com/ # through the proxy
curl -s https://app.example.com/whoami # shows the real client IP, not 127.0.0.1
# From another machine: the app port must be unreachable
nc -zv app.example.com 8000 # expected: refused or timed out
Common errors and what they mean
502 Bad Gateway Nginx cannot reach the application — is it running?
504 Gateway Timeout the application took longer than proxy_read_timeout
413 Too Large client_max_body_size is lower than the upload
403 Forbidden a directory in the static path lacks execute permission
redirect loop X-Forwarded-Proto missing, so the app thinks it is HTTP
How it works #
The application binds to 127.0.0.1, which means there is no route to it from outside the machine. That is a stronger guarantee than a firewall rule, because it does not depend on configuration elsewhere.
The upstream block with keepalive reuses connections between Nginx and the application rather than opening a new one per request. It requires HTTP/1.1 and an empty Connection header, both set in the location block.
The forwarded headers give the application information it otherwise cannot see. X-Real-IP and X-Forwarded-For carry the client address; X-Forwarded-Proto tells the application the original request was HTTPS.
Without X-Forwarded-Proto, an application that redirects HTTP to HTTPS sees plain HTTP, redirects, receives the same thing again, and loops. This is the single most common problem when putting an app behind a proxy.
Serving static files from Nginx removes those requests from the application entirely, and alias maps the URL prefix to a filesystem path.
Excluding health checks from the access log keeps the log readable; a check every ten seconds otherwise dominates it.
The error table is worth remembering, because each of those codes points directly at one configuration line.
Real-world use #
This file is the template for every application you deploy on a VPS. Hosting several means copying it, changing the domain and the upstream port.
The verification sequence matters. Confirming from another machine that the application port is refused is what proves the proxy is the only entry point.
Read timeouts should be set deliberately. Sixty seconds is generous for a web request; anything slower usually belongs in a background job rather than being waited on.
When the application is restarted during a deployment, Nginx briefly returns 502. With a single server that is a short blip; with two servers behind a load balancer it disappears entirely.
Rate limiting, caching and basic authentication can all be added at this layer without touching the application, which is one of the advantages of having it.
Common mistakes #
- Binding the application to 0.0.0.0, leaving a direct public route.
- Omitting X-Forwarded-Proto and causing an HTTPS redirect loop.
- Leaving the 1 MB body size limit and getting 413 errors on uploads.
- Using a very long read timeout instead of moving slow work to a job.
- Never verifying from outside that the application port is unreachable.
Practice #
Configure Nginx as a reverse proxy for an application on localhost, with static file serving, a 20 MB upload limit and correct forwarded headers. Verify the application sees the real client IP and HTTPS scheme, then confirm from another machine that port 8000 is refused.