Nginx Reverse Proxy, TLS and Rate Limiting: The Config You Should Actually Be Running
Most Nginx configs I inherit are one line of real work wrapped in 40 lines of cargo cult. Somebody pasted proxy_pass http://localhost:3000;, Certbot bolted on a TLS block, and that was that. It works, until a phone on a slow Safaricom connection uploads a 12MB PDF and gets a 413, or a bot discovers your /api/login endpoint and does 300 requests a second until MariaDB falls over.
My thesis: a reverse proxy is a policy layer, not a forwarding rule. Its job is to decide what a slow client, a large body, a dead upstream and an abusive IP each mean. Nginx's defaults answer none of those questions the way you want. Here is the config I actually run, with the reasoning.
Where the pieces go
On Ubuntu, /etc/nginx/nginx.conf holds the http block and includes /etc/nginx/conf.d/*.conf and /etc/nginx/sites-enabled/*. Rate-limit zones and map blocks are http-level only - putting limit_req_zone in a server block is a config error, and this is the single most common mistake people make when following blog posts. So zones go in /etc/nginx/conf.d/00-limits.conf, and the site goes in sites-available/.
Step 1: the shared http-level pieces
# /etc/nginx/conf.d/00-limits.conf
# Websocket / HTTP upgrade passthrough
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Skip rate limits for our own monitoring boxes
geo $limit_exempt {
default 0;
127.0.0.1/32 1;
10.10.0.0/16 1;
}
map $limit_exempt $limit_key {
0 $binary_remote_addr;
1 "";
}
# 10m of shared memory holds roughly 160,000 IP states
limit_req_zone $limit_key zone=general:10m rate=30r/s;
limit_req_zone $limit_key zone=login:10m rate=10r/m;
limit_conn_zone $limit_key zone=perip:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
# A log format that lets you debug latency
log_format timed '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time uds=$upstream_status';
Two details worth internalising. First, an empty key disables the limit for that request, which is how the geo plus map pair whitelists your own network without a second server block. Second, the memory maths: each state is 64 bytes on 64-bit Linux, so 1MB holds roughly 16,000 addresses and 10MB holds roughly 160,000. If the zone fills, Nginx starts rejecting with 503 and logs it - so do not size these at 1m.
Step 2: the upstream
upstream billing_app {
server 127.0.0.1:8081 max_fails=3 fail_timeout=15s;
keepalive 32;
keepalive_timeout 60s;
}
keepalive 32 keeps 32 idle connections open to your app so you are not paying a TCP handshake per request. It only works if you also set proxy_http_version 1.1 and clear the Connection header - miss either and the directive silently does nothing.
Step 3: the server block
server {
listen 80;
listen [::]:80;
server_name app.example.co.ke;
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name app.example.co.ke;
ssl_certificate /etc/letsencrypt/live/app.example.co.ke/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.co.ke/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:MozSSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000" 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;
access_log /var/log/nginx/billing.access.log timed;
error_log /var/log/nginx/billing.error.log warn;
client_max_body_size 25m;
client_body_timeout 30s;
limit_conn perip 20;
location / {
limit_req zone=general burst=60 nodelay;
proxy_pass http://billing_app;
include /etc/nginx/snippets/proxy-common.conf;
}
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://billing_app;
include /etc/nginx/snippets/proxy-common.conf;
}
location /ws/ {
proxy_pass http://billing_app;
include /etc/nginx/snippets/proxy-common.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
location /static/ {
alias /srv/billing/public/;
expires 30d;
access_log off;
}
}
Step 4: the shared proxy snippet
# /etc/nginx/snippets/proxy-common.conf
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;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on;
proxy_buffers 16 16k;
proxy_buffer_size 16k;
proxy_busy_buffers_size 32k;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
Notice proxy_connect_timeout 5s. The default is 60 seconds, which means if your app process is dead, every request hangs for a full minute before returning 502. Five seconds turns a hang into a fast, honest error. Meanwhile proxy_read_timeout stays at 60s because a slow report query is legitimate - but a slow TCP connect never is.
The header that breaks logins in production
X-Forwarded-Proto is not decoration. If your app framework generates absolute URLs or sets Secure cookies, it needs to know the original request was HTTPS. Without this header, Django, Rails and Frappe will all cheerfully redirect an HTTPS user to http:// and your session cookie disappears. Also make sure the app trusts the proxy: Django needs SECURE_PROXY_SSL_HEADER, Express needs app.set('trust proxy', 1).
TLS without the ritual
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.co.ke -d www.app.example.co.ke
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
Certbot on Ubuntu installs a systemd timer, so you do not need a cron entry - and adding one causes duplicate renewal attempts and rate-limit trouble with Let's Encrypt. The cipher list above is Mozilla's intermediate profile, which supports every client you will realistically see, including older Android handsets that are still very common across East Africa. Do not jump to the modern profile (TLS 1.3 only) unless you know your users' devices.
Verify from outside the box:
curl -sSI https://app.example.co.ke | head -20
openssl s_client -connect app.example.co.ke:443 -servername app.example.co.ke < /dev/null 2>/dev/null | openssl x509 -noout -dates -subject
Rate limiting: burst and nodelay, explained once and properly
With rate=10r/m, Nginx uses a leaky bucket that permits one request every 6 seconds. Request two, arriving one second later, is rejected with 429 - even though you "only" made two requests in a minute. That surprises everyone.
burst=5 adds a queue of 5 slots. Excess requests wait in the queue and are released at the configured rate, which means real users see slow responses instead of errors. Adding nodelay forwards those queued requests immediately while still consuming the slots, so a burst is fast but sustained abuse still hits 429. For an API used by humans, burst plus nodelay is almost always the right pairing. Use plain burst (no nodelay) only when you are deliberately shaping traffic to a fragile backend.
When it fires, your error log says exactly this:
2026/07/20 21:04:11 [warn] 1381#1381: *20482 limiting requests, excess: 5.400
by zone "login", client: 41.90.10.22, server: app.example.co.ke,
request: "POST /api/login HTTP/2.0", host: "app.example.co.ke"
Test your own limits before an attacker does:
for i in $(seq 1 30); do
curl -o /dev/null -s -w "%{http_code} " https://app.example.co.ke/api/login
done; echo
You should see a run of 200 or 401 then a wall of 429.
The Cloudflare trap
If you sit behind Cloudflare or any load balancer, $binary_remote_addr is the proxy's address, not the user's. Every visitor shares one bucket, so either nobody is limited or everybody is. Fix it with the real IP module:
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... the rest of https://www.cloudflare.com/ips-v4
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Only trust X-Forwarded-For from networks you control. If you accept it from the internet, anyone can spoof a header and bypass every limit you just wrote.
The failure modes you will actually hit
- 413 Request Entity Too Large. The default
client_max_body_sizeis 1MB. Set it per-location, not globally at 500m - a global huge value is a free memory-exhaustion tool for attackers. - 502 Bad Gateway with "connect() failed (111: Connection refused)". Your app is down. Nginx is fine. Check the unit, not the proxy.
- 504 Gateway Time-out. Your app is up but slow. Compare
rt=andurt=in the access log: if they are both large, it is the backend; if onlyrt=is large, it is a slow client. - Nginx reload does nothing.
nginx -tpasses but you edited a file insites-availablethat has no symlink insites-enabled. Check withnginx -T | grep server_name, which dumps the fully resolved config. - Rate limits not applying. Only one
limit_reqzone with the same name applies per level, andlocation-level directives fully replace server-level ones. Nested locations do not inherit what you think.
The reload discipline
sudo nginx -t # syntax and file existence
sudo nginx -T | less # the full effective config, includes resolved
sudo systemctl reload nginx # graceful, existing connections finish
Never use restart on a live site when reload will do - reload spawns new workers and lets old ones drain, so nobody sees a dropped connection. And always run nginx -t first: a reload with a broken config leaves the old workers running, but a restart with a broken config leaves you with a dead web server and a phone full of messages.
Put the timeouts in, put the forwarded headers in, put one strict zone on your auth endpoint. That is 30 minutes of work and it removes the three most common ways a small production server embarrasses you.