Skip to content

Reverse Proxy with nginx

A reverse proxy sits in front of your application servers and handles TLS, routing, caching, and access control so the apps behind it can stay simple HTTP services on localhost. nginx is the workhorse of self-hosting: fast, battle-tested, and configurable to a fault.

This page covers the patterns you actually need in production. For a zero-open-ports alternative that tunnels out to Cloudflare’s edge, see Reverse Proxy with Cloudflared; for a Docker-label-driven proxy, see Reverse Proxy with Traefik.

Terminate TLS at nginx and forward plain HTTP to the upstream. Redirect all HTTP to HTTPS, and pass through the headers your app needs to know its real client and scheme.

/etc/nginx/sites-available/app.conf
upstream app_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri; # force HTTPS
}
server {
listen 443 ssl;
http2 on;
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;
location / {
proxy_pass http://app_backend;
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;
}
}

Obtain and auto-renew the certificate with Certbot or acme.sh. Generate modern ssl_protocols/ssl_ciphers settings with the Mozilla SSL Configuration Generator rather than hand-rolling them.

Set protective response headers once in a shared snippet and include it in each server block:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
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;

Throttle abusive clients with a shared-memory zone. Define the zone at http scope, apply it where needed with a small burst:

# http { } context
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# location { } context
limit_req zone=api burst=20 nodelay;

Cache upstream responses to absorb load and speed up repeat requests:

# http { } context
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=site:10m max_size=1g inactive=60m;
# location { } context
proxy_cache site;
proxy_cache_valid 200 10m;
add_header X-Cache-Status $upstream_cache_status; # HIT / MISS for debugging

WebSockets need the Upgrade/Connection handshake headers forwarded, or the connection drops to plain HTTP. Use a map so the Connection header is correct for both upgrade and normal requests:

# http { } context
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# location { } context
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # long-lived sockets

Always test the config and reload (not restart) so live connections aren’t dropped:

Terminal window
nginx -t && systemctl reload nginx
nginxCloudflare Tunnel
Inbound portsMust open 80/443 to the internetNone — outbound tunnel only
TLS certsYou manage (Certbot/acme.sh)Cloudflare-managed at the edge
DDoS / WAFSelf-managedCloudflare edge included
Works offline / LAN-onlyYesNo (depends on Cloudflare)
Best forFull control, on-prem, high throughputHiding origin IP, no port-forwarding

Many self-hosters run both: Cloudflare Tunnel for public ingress, nginx behind it for routing and caching across local services.