Nginx Config skill for Codex

Right headers, right slashes, nginx -t first. Codex writes Nginx server blocks that forward host, scheme and client IP correctly, keep upstream connections alive, proxy WebSockets and SSE, and terminate TLS 1.2 and 1.3 with HSTS. It knows the proxy_pass trailing-slash table and the add_header inheritance trap, adds caching and limit_req where they help, and reloads only after nginx -t passes and you confirm.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- nginx-config

Installs to ~/.agents/skills/nginx-config/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $nginx-config, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and debugs Nginx configuration - reverse proxy with correct proxy_pass slash semantics and forwarded headers, upstream keepalive, WebSocket and SSE proxying, TLS 1.2/1.3 with HSTS, real client IP behind load balancers, proxy and static caching, limit_req rate limiting, SPA fallbacks, and safe nginx -t reloads. Use when putting Nginx in front of an app, fixing 502/504 errors, redirect loops or wrong client IPs, adding HTTPS, caching or rate limits, or reviewing an nginx.conf or sites-available file.

Use it when

  • An app needs a reverse proxy, TLS termination or static file serving
  • Users hit 502, 504, 413 errors or redirect loops through Nginx
  • App logs show the proxy's IP instead of the client's
  • You need caching or rate limiting at the edge of a service

Use something else when

  • You want automatic certificates with minimal config: use caddy-server
  • The issue is certificate issuance or renewal itself: use tls-certificates

How to check the result

nginx -t passes, curl shows the HTTP to HTTPS redirect and HSTS, openssl s_client negotiates TLS 1.3 with a full chain, and app logs record real client IPs.

The mistake it prevents

Adding one add_header inside a location block, which silently drops every security header inherited from the server block.

~/.agents/skills/nginx-config/SKILL.md

--- name: nginx-config description: Writes and debugs Nginx configuration - reverse proxy with correct proxy_pass slash semantics and forwarded headers, upstream keepalive, WebSocket… license: MIT ---

A good Nginx config is small, tested with nginx -t before every reload, passes the original host, scheme and client IP to the app, terminates TLS with modern defaults, and avoids the traps that only show in production: proxy_pass slash rewriting, add_header inheritance, and if inside location.

Inspect first

  1. Find the layout: nginx -V 2>&1 | tr ' ' '\n' | grep -E 'conf-path|with-http_v2|with-http_v3' and nginx -T to dump the full effective config (includes resolved).
  2. Note the version (nginx -v). http2 on; needs 1.25.1+; older versions use listen 443 ssl http2;.
  3. Identify what sits in front (cloud load balancer, CDN) and behind (app port, socket).
  4. Read AGENTS.md for the app’s health endpoint and whether it expects X-Forwarded-Proto to build URLs.

Reverse proxy baseline

upstream app {
    server 127.0.0.1:3000;
    keepalive 32;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    location /.well-known/acme-challenge/ { root /var/www/acme; }
    location / { return 301 https://example.com$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 20m;

    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_read_timeout 60s;
    }
}
  • keepalive in the upstream only works with proxy_http_version 1.1 and a Connection header that is not close; the map sends an empty value for normal requests.
  • Start HSTS with a short max-age (300) when the site is new to HTTPS; add preload only when every subdomain serves HTTPS permanently.
  • Let’s Encrypt stopped OCSP responses in 2025, so ssl_stapling does nothing for its certificates; leave it off.

proxy_pass slash rules

locationproxy_passRequest /api/users reaches upstream as
/api/http://app/api/users (URI passed unchanged)
/api/http://app//users (location prefix replaced by /)
/api/http://app/v2//v2/users
/apihttp://app///users (double slash: match prefix and path slashes)

A variable in proxy_pass (proxy_pass http://$backend;) disables this rewriting and requires a resolver directive for hostnames.

Real client IP behind a load balancer

set_real_ip_from 10.0.0.0/8;          # your LB or CDN ranges only
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Never trust X-Forwarded-For from everywhere; any client can forge it. For Cloudflare, use its published IP ranges and CF-Connecting-IP.

Rate limiting

# http context
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;

# server or location
location /api/   { limit_req zone=api burst=20 nodelay; proxy_pass http://app; }
location = /login { limit_req zone=login burst=5; proxy_pass http://app; }
  • 10 MB of zone holds about 160,000 IPv4 states.
  • Behind a load balancer without the real-IP block above, every client shares the LB’s address and one user exhausts the limit for everyone.
  • Use limit_req_dry_run on; first to log what would be rejected.

Caching

# http context
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=appcache:50m max_size=2g inactive=60m use_temp_path=off;

location /public/ {
    proxy_cache appcache;
    proxy_cache_valid 200 301 10m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
    proxy_cache_lock on;
    add_header X-Cache-Status $upstream_cache_status always;
    proxy_pass http://app;
}

location ~* \.(?:js|css|woff2|png|jpg|svg|webp)$ {
    root /var/www/app/dist;
    expires 1y;
    add_header Cache-Control "public, immutable" always;
}
  • Nginx does not cache responses carrying Set-Cookie or Cache-Control: private; that is usually correct. Never cache authenticated pages by ignoring those headers.
  • immutable only for fingerprinted filenames.

Common locations

location / { try_files $uri $uri/ /index.html; }          # SPA fallback (static root)

location /events/ {                                       # Server-Sent Events
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_read_timeout 1h;
}

location = /healthz { access_log off; proxy_pass http://app; }

Hard rules

  • add_header in a location discards every add_header from the parent level. Repeat security headers in each location that adds its own, or put them in an included snippet.
  • Use always on headers that must appear on error responses too.
  • Avoid if in location except for return and rewrite ... last; use map instead.
  • server_name _; default server should return 444; so unknown Host headers do not hit the app.
  • Do not expose /.git, .env or backup files from a static root: location ~ /\.(?!well-known) { deny all; }.

Debugging

SymptomCheck
502 Bad GatewayApp down or wrong port; error_log shows connect() failed; SELinux httpd_can_network_connect
504 Gateway TimeoutSlow upstream; raise proxy_read_timeout only after confirming the app is not stuck
Redirect loopApp sees http because X-Forwarded-Proto missing, redirects to https forever
413 Request Entity Too Largeclient_max_body_size
Wrong client IP in app logsMissing real-IP config or proxy headers
Old config still servedReload failed silently; run nginx -t and check systemctl status nginx

curl -sv -H 'Host: example.com' https://127.0.0.1/ -k tests a vhost locally; curl -I shows headers.

Apply

sudo nginx -t && sudo systemctl reload nginx

reload keeps existing connections; restart drops them. On a production host, show the diff and the nginx -t result and get the user’s confirmation before reloading.

Verification

  • nginx -t passes; nginx -T shows the intended effective config.
  • curl -I http://example.com returns 301 to https; curl -I https://example.com shows HSTS and expected headers.
  • openssl s_client -connect example.com:443 -servername example.com </dev/null shows TLS 1.3 and the full chain.
  • App logs show real client IPs; a burst test returns 429 at the configured rate.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Nginx Config"
  short_description: "Right headers, right slashes, nginx -t first"
  default_prompt: "$nginx-config Put Nginx in front of my Node app on port 3000 with HTTPS"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc