Nginx Reverse Proxy skill for Claude Code
Proxy configs proven with curl, not hope. Claude dumps the effective nginx config before editing, writes upstreams with keepalive and correct forwarded headers, and sets up TLS with automated renewal or a short Caddyfile when that fits better. It avoids the add_header inheritance trap, adds rate limits and caching only where safe, and proves each behavior with nginx -t, curl and openssl before reloading.
- name
nginx-reverse-proxy - size241 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Configures nginx and Caddy as reverse proxies — upstreams with keepalive, forwarded headers and real client IPs, WebSockets, TLS with automated certificates, security headers, proxy caching, rate limiting, body size and timeouts — and tests every change with nginx -t, curl, and openssl before reload. Use when writing nginx.conf or a Caddyfile, when debugging 502/504/413 errors, when adding HTTPS or HSTS, when WebSockets fail behind a proxy, or when adding rate limits or caching in front of an app. Try asking
- Put nginx with HTTPS in front of our Node app on port 3000
- WebSocket connections fail behind our nginx proxy. Fix the config
- We get random 502 and 504 errors from nginx; help me find out why
Use it when
- You are writing nginx.conf server blocks or a Caddyfile
- Requests fail with 502, 504, or 413 behind a proxy
- You need HTTPS, HSTS, or security headers on a site
- An API needs rate limiting or response caching at the edge
Skip it when
- You are tuning Cache-Control and CDN behavior at the application level — use http-caching instead
- You need a header policy review beyond the proxy, such as CSP design — use security-headers instead
How to check the result
nginx -t or caddy validate passes, and curl shows the HTTPS redirect, security headers on both 200 and 404 responses, 429s after the burst limit, and the expected certificate dates.
The common mistake it prevents
Adding one add_header inside a location block, which silently drops HSTS and every other header defined at the server level for that path.
A proxy config is done when nginx -t (or caddy validate) passes, the app sees the
real client IP and scheme, TLS grades A with automated renewal, headers survive every
location, and each behavior (redirects, WebSockets, limits, cache) is proven with curl.
Ground rules
- Test before reload:
nginx -t && systemctl reload nginx. Neverrestarta busy proxy whenreloadsuffices. Reloading production requires user confirmation. - Dump the effective config (
nginx -T) before editing; includes andsites-enabledsymlinks hide surprises. - Trust
X-Forwarded-Foronly from known proxy CIDRs (set_real_ip_from). - Default choice: Caddy for a new single-host setup that wants automatic HTTPS with minimal config; nginx when the team already runs it, or needs mature rate limiting, caching, or modules.
Workflow
- [ ] 1. Dump current config and confirm versions
- [ ] 2. Upstream + server blocks with forwarded headers
- [ ] 3. TLS and redirects
- [ ] 4. Security headers (with inheritance in mind)
- [ ] 5. Limits, timeouts, body size, caching as needed
- [ ] 6. Test syntax, reload, verify each behavior with curl
1. Inspect
nginx -v; nginx -T 2>/dev/null | less # full effective config with includes
ls -l /etc/nginx/sites-enabled/ /etc/nginx/conf.d/
tail -f /var/log/nginx/error.log
2. Baseline nginx site
# /etc/nginx/conf.d/app.conf
upstream app {
server 127.0.0.1:3000;
keepalive 32;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25.1+; older: "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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
client_max_body_size 20m;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://app;
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 Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
The map gives WebSocket requests Connection: upgrade and everything else
Connection: close; for maximum upstream keepalive reuse on non-WebSocket paths, use a
separate location with proxy_set_header Connection "";.
Same thing in Caddy:
app.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
request_body {
max_size 20MB
}
reverse_proxy 127.0.0.1:3000
}
Caddy obtains and renews certificates automatically, redirects HTTP to HTTPS, proxies
WebSockets, and sets X-Forwarded-For/Proto/Host by default. Validate with
caddy validate --config /etc/caddy/Caddyfile and apply with caddy reload.
3. TLS
- nginx + certbot:
certbot --nginx -d app.example.comor webroot mode with the ACME location above. Verify renewal:certbot renew --dry-runand that the systemd timer exists (systemctl list-timers | grep certbot). - Certificate lifetimes are shrinking (Let’s Encrypt is moving below 90 days); renewal must be automated and monitored, never manual.
- Let’s Encrypt ended OCSP support in 2025;
ssl_staplingis pointless with its certs. - Use the Mozilla SSL Configuration Generator “intermediate” profile unless clients require otherwise.
4. Headers: the inheritance trap
add_header directives are inherited from the parent level only if the current level
defines none. A single add_header inside a location drops HSTS and every other
server-level header for that location. Either repeat them (an include snippets/security-headers.conf; per location) or keep all add_header at one level.
Always add always so headers appear on 4xx/5xx responses too.
5. Limits, timeouts, caching
Real client IP behind a load balancer or CDN (put in http {}):
set_real_ip_from 10.0.0.0/8; # your LB/CDN ranges only
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Rate limiting:
# http {}
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_status 429;
# server/location
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
}
location = /login {
limit_req zone=api burst=5;
proxy_pass http://app;
}
Micro-caching an upstream:
# http {}
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=appcache:10m max_size=1g
inactive=60m use_temp_path=off;
# location
proxy_cache appcache;
proxy_cache_valid 200 301 10s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_bypass $http_authorization $cookie_session;
proxy_no_cache $http_authorization $cookie_session;
add_header X-Cache-Status $upstream_cache_status always;
Never cache responses for authenticated users unless the cache key includes the user.
proxy_pass path rules
| Config | Request /api/users reaches upstream as |
|---|---|
location /api/ { proxy_pass http://app; } | /api/users (URI passed unchanged) |
location /api/ { proxy_pass http://app/; } | /users (matched prefix replaced) |
location /api/ { proxy_pass http://app/v2/; } | /v2/users |
6. Test
nginx -t && sudo systemctl reload nginx
curl -sI http://app.example.com | head -3 # 301 to https
curl -sI https://app.example.com | grep -iE 'strict-transport|x-content-type'
curl -s --resolve app.example.com:443:127.0.0.1 https://app.example.com/healthz
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates -subject
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/api/ping; done | sort | uniq -c # 429s appear
curl -sI https://app.example.com/ | grep -i x-cache-status # MISS then HIT
For WebSockets: websocat wss://app.example.com/ws or check for 101 Switching Protocols.
Error triage
| Error | Meaning | Check |
|---|---|---|
| 502 Bad Gateway | Upstream refused/closed | App running? Port/socket right? SELinux: setsebool -P httpd_can_network_connect 1 |
| 504 Gateway Timeout | Upstream too slow | proxy_read_timeout; slow endpoint in app logs |
| 413 Request Entity Too Large | Body over client_max_body_size (default 1m) | Raise per location |
| 400 “plain HTTP request was sent to HTTPS port” | Client or LB speaking HTTP to 443 | LB target protocol |
| Redirect loop | App sees http behind TLS proxy and redirects | Forward X-Forwarded-Proto; trust proxy in app |
| Wrong client IPs in app logs | Missing real_ip config or app not trusting proxy | set_real_ip_from; framework trust-proxy setting |
| Stale upstream IP after DNS change | nginx resolved hostname once at startup | resolver + variable in proxy_pass, or resolve on upstream servers (recent versions) |
Gotchas
ifinsidelocationis fragile; prefermapand separate locations.- A missing trailing
;or an unmatched brace in an included file breaks every site on reload;nginx -tcatches it, a blind restart takes everything down. - HSTS with
includeSubDomains(and especiallypreload) breaks any HTTP-only subdomain; confirm all subdomains serve HTTPS first. proxy_buffering offis needed for server-sent events and streaming responses.- Default
proxy_read_timeoutis 60 s; long-polling and WebSockets need more or keepalive pings.
Output
**Files:** /etc/nginx/conf.d/app.conf, snippets/security-headers.conf
**nginx -t:** OK (nginx 1.28.x); reloaded after approval
**Verified:** HTTP->HTTPS 301, HSTS on 200 and 404, WebSocket 101, /api 429 after burst,
X-Cache-Status MISS->HIT, cert valid until 2026-12-30, certbot renew --dry-run OK About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.