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.

  • namenginx-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.

~/.claude/skills/nginx-reverse-proxy/SKILL.md

--- name: nginx-reverse-proxy description: Configures nginx and Caddy as reverse proxies — upstreams with keepalive, forwarded headers and real client IPs, WebSockets, TLS with… license: MIT ---

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. Never restart a busy proxy when reload suffices. Reloading production requires user confirmation.
  • Dump the effective config (nginx -T) before editing; includes and sites-enabled symlinks hide surprises.
  • Trust X-Forwarded-For only 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.com or webroot mode with the ACME location above. Verify renewal: certbot renew --dry-run and 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_stapling is 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

ConfigRequest /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

ErrorMeaningCheck
502 Bad GatewayUpstream refused/closedApp running? Port/socket right? SELinux: setsebool -P httpd_can_network_connect 1
504 Gateway TimeoutUpstream too slowproxy_read_timeout; slow endpoint in app logs
413 Request Entity Too LargeBody 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 443LB target protocol
Redirect loopApp sees http behind TLS proxy and redirectsForward X-Forwarded-Proto; trust proxy in app
Wrong client IPs in app logsMissing real_ip config or app not trusting proxyset_real_ip_from; framework trust-proxy setting
Stale upstream IP after DNS changenginx resolved hostname once at startupresolver + variable in proxy_pass, or resolve on upstream servers (recent versions)

Gotchas

  • if inside location is fragile; prefer map and separate locations.
  • A missing trailing ; or an unmatched brace in an included file breaks every site on reload; nginx -t catches it, a blind restart takes everything down.
  • HSTS with includeSubDomains (and especially preload) breaks any HTTP-only subdomain; confirm all subdomains serve HTTPS first.
  • proxy_buffering off is needed for server-sent events and streaming responses.
  • Default proxy_read_timeout is 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.

esc