← Back to blog

Setting Up a Reverse Proxy with Nginx: A Complete Guide

Maria Ilinca Bostan

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

SymptomCauseFix
502 Bad GatewayBackend not listening, wrong port, or SELinux blocking the connectionss -lntp to confirm the port; on RHEL setsebool -P httpd_can_network_connect 1
504 Gateway TimeoutBackend slower than the 60 s defaultRaise proxy_read_timeout, and fix the slow request
413 Request Entity Too Largeclient_max_body_size default of 1 MBRaise it in the server or location block
Infinite redirect loopApp redirects to HTTPS but does not see X-Forwarded-ProtoSend the header and trust it in the framework
All clients share one IP in logsMissing X-Real-IP, or CDN address not resolvedSet the header; add set_real_ip_from for the CDN
WebSocket closes after 60 sDefault read timeoutproxy_read_timeout 3600s plus the Upgrade headers
Security headers missing on errorsadd_header without alwaysAppend 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.

Ready in under a minute

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.

$ voxa deploy --plan ion --location amsterdam

KVM VPS billed hourly, capped monthly · Dedicated billed monthly · No setup fee

Included on every plan
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