Setting Up a Reverse Proxy with Nginx: A Complete Guide
A reverse proxy is an Nginx server that accepts requests on port 80 and 443 and forwards them to one or more application processes listening on private ports. It gives you one TLS endpoint, one access log, one place to rate-limit, and the freedom to move, restart or scale the application behind it without touching DNS. This guide walks the whole path on a fresh Linux VPS: install, first working proxy, TLS, WebSockets, caching, hardening, and the mistakes that cost people an afternoon.
What a reverse proxy actually does
A forward proxy sits in front of clients and hides them from the internet. A reverse proxy sits in front of servers and hides them from clients. The browser connects to Nginx; Nginx connects to your Node, Python, PHP-FPM, Java or Go process. The client never learns the application port and never talks to it directly.
That indirection buys five things that are awkward to build into an application:
- One TLS termination point. Certificates are renewed in one place instead of in every service.
- Many apps, one IP. Route by hostname or path to different backends on the same server.
- Static files served properly. Nginx serves assets from disk far faster than an application runtime does, with correct caching headers.
- A control point. Rate limits, IP allowlists, request size caps, header rewriting and access logs live at the edge.
- Safe restarts. Deploy a new backend on a new port, flip the
proxy_pass, reload Nginx. No dropped connections at DNS level.
Before you start
You need a Linux server with a public IP, root or sudo access, and an application already running and reachable on localhost. A 2 GB instance is comfortable for Nginx plus a small application; Nginx itself uses only a few megabytes per worker. Our entry Ion KVM VPS (2 GB RAM, 1 vCPU, 40 GB SSD, 1 Gbps unmetered) is sized for exactly this job, and because billing is hourly you can build the whole configuration on a throwaway instance before committing it to production.
Point an A record for your domain at the server's IP before you request certificates. Let's Encrypt validates over HTTP, so DNS must already resolve.
Step 1 — Install Nginx
On Debian and Ubuntu:
sudo apt update
sudo apt install -y nginx
sudo systemctl enable --now nginx
On AlmaLinux, Rocky Linux or CentOS Stream:
sudo dnf install -y nginx
sudo systemctl enable --now nginx
Confirm it answers before you change anything: curl -I http://127.0.0.1 should return HTTP/1.1 200 OK. If it does not, nothing after this step will work either.
Step 2 — A minimal working proxy
Create /etc/nginx/sites-available/app.conf (Debian/Ubuntu) or /etc/nginx/conf.d/app.conf (RHEL family):
server {
listen 80;
listen [::]:80;
server_name example.com www.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;
proxy_set_header X-Forwarded-Host $host;
}
}
On Debian and Ubuntu, enable it and reload:
sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
nginx -t is not optional. A reload with a broken config leaves the old configuration running and prints a warning you may not read; a restart with a broken config takes the site down.
Why those four headers matter
Without proxy_set_header, your application sees every request as coming from 127.0.0.1 over plain HTTP. That breaks rate limiting by IP, breaks audit logs, breaks geolocation, and makes frameworks that build absolute URLs generate http://localhost:3000/... links. X-Forwarded-Proto in particular is what tells an application behind TLS termination that the original request was HTTPS — without it many frameworks will redirect to HTTP in a loop.
Then trust those headers in the application. Express needs app.set('trust proxy', 1); Django needs SECURE_PROXY_SSL_HEADER; Rails needs the request to arrive through ActionDispatch::RemoteIp. Setting the header and not trusting it is the same as not setting it.
Step 3 — Add HTTPS with Let's Encrypt
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot edits the server block in place, adds the certificate paths, and installs a renewal timer. Verify the timer exists rather than assuming it: systemctl list-timers | grep certbot. Test the renewal path with sudo certbot renew --dry-run.
Once the certificate is in place, redirect plain HTTP permanently:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Use 301, not 302. A temporary redirect tells search engines the HTTP URL is still canonical, and you end up with two indexable copies of every page.
Step 4 — WebSockets
WebSocket upgrades fail silently through a default proxy configuration: the handshake returns 400 or the connection drops after 60 seconds. Two additions fix it.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
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;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
The map block goes in the http context (/etc/nginx/nginx.conf or a file in conf.d), not inside server. The long timeouts matter because Nginx's default proxy_read_timeout is 60 seconds, and an idle WebSocket that sends no heartbeat will be closed at exactly that mark.
Step 5 — Serve static assets from Nginx
Every static file your application serves is a request that occupies an application worker. Hand them to Nginx instead:
location /static/ {
alias /var/www/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
Use immutable only for fingerprinted filenames such as app.9f2a1c.js. On a file whose name never changes, a 30-day immutable cache means users keep the stale copy for a month.
Step 6 — Load balancing across several backends
upstream app_backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3002 backup;
keepalive 32;
}
server {
# ...
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
least_conn beats the default round-robin whenever request durations vary, which they almost always do. keepalive plus an empty Connection header reuses upstream TCP connections instead of opening a new one per request — on a busy proxy this is one of the largest single wins available, and it costs two lines.
Step 7 — Rate limiting and request caps
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
client_max_body_size 25m;
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}
client_max_body_size defaults to 1 MB. If uploads larger than that return 413 Request Entity Too Large and your application logs show nothing at all, this is why — Nginx rejected the request before the backend ever saw it.
A note on IP-based limits: if your proxy sits behind Cloudflare or another CDN, $binary_remote_addr is the CDN's address and your rate limit applies to the whole edge. Configure set_real_ip_from with the CDN's published ranges and real_ip_header CF-Connecting-IP first, otherwise you are rate-limiting an entire proxy network as though it were one user.
Step 8 — Caching proxied responses
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:20m
max_size=2g inactive=60m use_temp_path=off;
location / {
proxy_cache app_cache;
proxy_cache_valid 200 301 302 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://127.0.0.1:3000;
}
proxy_cache_use_stale is the line worth understanding: when the backend errors or times out, Nginx serves the last good copy instead of an error page. Combined with proxy_cache_lock, which stops a thousand simultaneous misses from all hitting the backend, it turns a backend restart into a non-event for cached routes. The X-Cache-Status header lets you verify hits with curl -I rather than guessing.
Never cache authenticated responses by accident. Add proxy_no_cache and proxy_cache_bypass on a session cookie, or scope caching to a location that only serves public content.
Step 9 — Harden the edge
server_tokens off;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
The always flag matters: without it, add_header is skipped on error responses, so your 404 and 500 pages ship without security headers. Two further points that are easy to miss — an add_header inside a location block replaces the whole inherited set rather than adding to it, so headers defined at server level silently disappear from that location; and enabling HSTS commits every visitor's browser to HTTPS for a year, so do not enable it until certificates renew reliably.
Finally, make sure the application itself is not reachable directly. Bind it to 127.0.0.1:3000 rather than 0.0.0.0:3000, and close the port at the firewall. A reverse proxy in front of a publicly reachable backend protects nothing — see our beginner's guide to firewalling a VPS for the nftables and ufw rules.
Common failures and what causes them
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Backend not listening, wrong port, or SELinux blocking the connection | ss -lntp to confirm the port; on RHEL setsebool -P httpd_can_network_connect 1 |
| 504 Gateway Timeout | Backend slower than the 60 s default | Raise proxy_read_timeout, and fix the slow request |
| 413 Request Entity Too Large | client_max_body_size default of 1 MB | Raise it in the server or location block |
| Infinite redirect loop | App redirects to HTTPS but does not see X-Forwarded-Proto | Send the header and trust it in the framework |
| All clients share one IP in logs | Missing X-Real-IP, or CDN address not resolved | Set the header; add set_real_ip_from for the CDN |
| WebSocket closes after 60 s | Default read timeout | proxy_read_timeout 3600s plus the Upgrade headers |
| Security headers missing on errors | add_header without always | Append always to each directive |
When Nginx is not the right answer
Being honest about the boundaries: if you need automatic certificate management with zero configuration, Caddy does it in one line and Nginx does not. If your routing decisions depend on container labels that change constantly, Traefik's service discovery is a better fit than rewriting Nginx config and reloading. If you need per-request retries, circuit breaking and detailed upstream telemetry, Envoy is built for it and Nginx open source is not. Nginx wins on raw throughput per megabyte of RAM, on the size of the community answering questions about it, and on the fact that almost every hosting guide you will ever read assumes it — but it is not automatically the correct choice.
Sizing the server
Nginx as a pure reverse proxy is cheap: a few megabytes of RAM per worker and negligible CPU until TLS handshake volume gets high. Your sizing is driven by what runs behind it. A single application plus proxy fits comfortably on 2–4 GB; several containers with a database want 8 GB or more. If the proxy is fronting media or acting as a CDN origin, bandwidth becomes the constraint before CPU does, which is where a 10 Gbps streaming VPS earns its price over a standard 1 Gbps plan. You can model the difference on the VPS cost calculator before committing.
Latency is a real factor for a proxy, because every request pays the round trip twice. Put the proxy in the same region as its users: our Frankfurt and Amsterdam sites sit on DE-CIX and AMS-IX respectively, and ten more locations cover the rest of Europe, Turkey and the US East Coast.
Frequently asked questions
Do I need a reverse proxy if I only run one application?
You need one as soon as you want HTTPS without embedding certificate handling in the application, or want to serve static files efficiently, or want a single access log. For a single application on a single port with no TLS, you can go without — but that describes very few production deployments.
Is Nginx or Apache better as a reverse proxy?
Nginx handles many idle connections at far lower memory cost because of its event loop, which is the dominant factor for a proxy. Apache with mod_proxy and the event MPM works, and if your team already knows Apache well that familiarity may outweigh the difference. For a new deployment, Nginx is the lower-friction choice.
Can I run the reverse proxy on a different server from the application?
Yes, and it is common once you have more than one backend. Point proxy_pass at the private address of the application host and make sure the link between them is private — a proxy that reaches its backend across the public internet has undone most of the security benefit.
How do I reload configuration without dropping requests?
nginx -t && systemctl reload nginx. A reload starts new workers with the new configuration and lets the old ones finish their in-flight requests before exiting. restart does not do this and will drop connections.
Next steps
With the proxy in place, the two things worth doing next are putting a firewall in front of it and making sure the box behind it is tuned. Our guides on VPS performance tuning and server security best practices pick up from here. If you are running containers behind the proxy, Docker on a VPS covers the networking side of that setup.
Every configuration in this guide runs unchanged on a voxa.host KVM VPS with full root access, deployed in under a minute in any of our twelve locations. Plans start at €4/month, billed hourly at €0.0056 and capped at the monthly rate, so a test proxy costs a few cents to build and throw away.
Deploy what you just read about
Deploy your first server now.
No contracts, no minimums. Start on an Ion KVM VPS at €0.0063 an hour and move to a monthly bare-metal server the day you outgrow it.
KVM VPS billed hourly, capped monthly · Dedicated billed monthly · No setup fee
- Free IPv4 + IPv6
- Every VPS
- Unmetered traffic
- 1–10 Gbps
- DDoS mitigation
- 2.5 Tbps
- Root / IPMI access
- Included
- Setup fee
- €0.00
- Minimum term
- None