Nginx Operations

SkillAI & models

Nginx configuration, reverse proxy, SSL/TLS, load balancing, and performance tuning. Use for: nginx, reverse proxy, load balancer, proxy_pass, ssl certificate, lets encrypt, web server, location block, upstream, server block, nginx config, certbot, hsts, gzip, rate limiting.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Nginx Operations skill

What this skill tells your AI

The instructions your AI receives, as published by 0xdarkmatter/claude-mods in skills/nginx-ops/SKILL.md and read by ahel’s review.

Comprehensive Nginx configuration, reverse proxy patterns, SSL/TLS hardening, load balancing strategies, and performance optimization for production deployments.


Configuration Architecture Quick Reference

nginx.conf (main context)
├── worker_processes auto;
├── worker_rlimit_nofile 65535;
│
├── events {                          # Connection handling
│   ├── worker_connections 4096;
│   └── multi_accept on;
│   }
│
├── http {                            # HTTP server settings
│   ├── include mime.types;
│   ├── default_type application/octet-stream;
│   ├── sendfile on;
│   ├── gzip on;
│   │
│   ├── upstream backend {            # Load balancing pool
│   │   └── server 127.0.0.1:3000;
│   │   }
│   │
│   ├── server {                      # Virtual host
│   │   ├── listen 443 ssl;
│   │   ├── server_name example.com;
│   │   │
│   │   ├── location / {              # Request routing
│   │   │   └── proxy_pass http://backend;
│   │   │   }
│   │   │
│   │   └── location /static/ {
│   │       └── root /var/www;
│   │       }
│   │   }
│   │
│   └── include /etc/nginx/conf.d/*.conf;
│   }
│
└── stream {                          # TCP/UDP proxying (optional)
    └── server { ... }
    }

Directive Inheritance Rules

RuleBehaviorExample
Inherit downChild blocks inherit parent directivesgzip on; in http applies to all server blocks
OverrideChild directive overrides parentgzip off; in location overrides http-level gzip on;
Array directivesNOT inherited - must be redeclaredproxy_set_header in location replaces ALL headers from server
No upwardInner blocks never affect outerlocation-level settings don't affect server

Critical: Array-type directives (proxy_set_header, add_header, proxy_hide_header) are completely replaced when redefined in a child block, not merged. If you set one proxy_set_header in a location, you must redeclare ALL of them.


Reverse Proxy Decision Tree

Need to proxy requests?
│
├─ Single backend server?
│  └─ Use simple proxy_pass
│     proxy_pass http://127.0.0.1:3000;
│
├─ Multiple backend servers?
│  │
│  ├─ Need session persistence?
│  │  ├─ By client IP → ip_hash
│  │  └─ By cookie    → sticky cookie (Nginx Plus)
│  │
│  ├─ Backends have unequal capacity?
│  │  └─ Use weight parameter
│  │     server backend1:3000 weight=3;
│  │     server backend2:3000 weight=1;
│  │
│  ├─ Want fewest active connections?
│  │  └─ least_conn
│  │
│  ├─ Want even random distribution?
│  │  └─ random two least_conn
│  │
│  └─ Default (no special needs)?
│     └─ round-robin (default, no directive needed)
│
├─ WebSocket connections?
│  └─ Add Upgrade + Connection headers
│     proxy_set_header Upgrade $http_upgrade;
│     proxy_set_header Connection "upgrade";
│
├─ gRPC backend?
│  └─ Use grpc_pass grpc://backend;
│
└─ Streaming / Server-Sent Events?
   └─ Disable buffering
      proxy_buffering off;

SSL/TLS Quick Start

Let's Encrypt with Certbot

# Install certbot
sudo apt install certbot python3-certbot-nginx   # Debian/Ubuntu
sudo dnf install certbot python3-certbot-nginx    # RHEL/Fedora

# Obtain certificate (nginx plugin - easiest)
sudo certbot --nginx -d example.com -d www.example.com

# Obtain certificate (webroot - no nginx restart)
sudo certbot certonly --webroot -w /var/www/html -d example.com

# Test auto-renewal
sudo certbot renew --dry-run

Minimal Production SSL Config

server {
    listen 443 ssl http2;
    server_name example.com;

    # Certificates
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Modern TLS (1.2 + 1.3)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # HSTS (2 years)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # Session caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    root /var/www/example.com;
    index index.html;
}

# HTTP → HTTPS redirect
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

Location Matching Order

Nginx evaluates location blocks in a specific priority order, not in the order they appear in the config file.

PriorityModifierTypeExampleBehavior
1=Exact matchlocation = /favicon.icoStops search immediately on match
2^~Prefix (no regex)location ^~ /static/Stops search if this prefix matches (skips regex)
3~Regex (case-sensitive)location ~ \.php$First matching regex wins
3~*Regex (case-insensitive)location ~* \.(jpg|png)$First matching regex wins
4(none)Prefixlocation /api/Longest prefix wins (but only after regex check)

Evaluation Algorithm

  1. Check all prefix locations, remember the longest match
  2. If longest match has ^~ modifier → use it, stop
  3. Check regex locations in config-file order → first match wins
  4. If no regex matches → use the longest prefix from step 1
  5. = /path is checked first and wins immediately if matched

Example

location = /             { }  # Only exact "/"
location /               { }  # Catch-all prefix
location /api/           { }  # Prefix: /api/*
location ^~ /static/     { }  # Prefix, skip regex: /static/*
location ~ \.php$        { }  # Regex: any .php file
location ~* \.(gif|jpg)$ { }  # Case-insensitive regex: images
Request URIMatched LocationWhy
/= /Exact match (priority 1)
/index.html/Longest prefix, no regex match
/api/users/api/Longest prefix, no regex match
/static/logo.png^~ /static/^~ skips regex check
/app/index.php~ \.php$Regex beats prefix
/photos/cat.jpg~* \.(gif|jpg)$Regex beats prefix

Common Configurations

SPA Routing (React, Vue, Angular)

server {
    listen 80;
    server_name app.example.com;

    root /var/www/app/dist;
    index index.html;

    # Serve static files directly, fall back to index.html for SPA routes
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache static assets aggressively
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

WebSocket Proxy

location /ws/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_read_timeout 86400s;  # Keep WebSocket alive for 24h
    proxy_send_timeout 86400s;
}

Rate Limiting

# Define zone: 10MB shared memory, 10 requests/second per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        # Allow burst of 20, process excess without delay up to burst
        limit_req zone=api burst=20 nodelay;
        limit_req_status 429;

        proxy_pass http://backend;
    }
}

Gzip Compression

http {
    gzip on;
    gzip_comp_level 5;          # Balance CPU vs compression (1-9)
    gzip_min_length 256;        # Don't compress tiny responses
    gzip_vary on;               # Vary: Accept-Encoding header
    gzip_proxied any;           # Compress proxied responses too
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        application/xml+rss
        image/svg+xml;
}

Static File Serving

location /static/ {
    alias /var/www/static/;     # Note: alias, not root (includes /static/ path)
    expires 30d;
    add_header Cache-Control "public, no-transform";

    # Disable access log for static files
    access_log off;

    # Enable open file cache
    open_file_cache max=1000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
}

CORS Headers

location /api/ {
    # CORS headers
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
    add_header Access-Control-Max-Age 86400 always;

    # Handle preflight requests
    if ($request_method = OPTIONS) {
        return 204;
    }

    proxy_pass http://backend;
}

Docker Patterns

Nginx as Reverse Proxy in Docker Compose

# docker-compose.yml
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app
    networks:
      - webnet

  app:
    build: .
    expose:
      - "3000"    # Internal only, not published to host
    networks:
      - webnet

networks:
  webnet:
# nginx.conf for docker-compose (use service name as hostname)
upstream app_backend {
    server app:3000;    # Docker DNS resolves service name
}

server {
    listen 80;
    location / {
        proxy_pass http://app_backend;
        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;
    }
}

Multi-Stage Build with Static Assets

# Stage 1: Build frontend
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# nginx.conf for containerized SPA
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # SPA routing
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Health check endpoint
    location /health {
        access_log off;
        return 200 "OK\n";
        add_header Content-Type text/plain;
    }

    # Cache busted assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Common Gotchas

GotchaWhyFix
Trailing slash in proxy_passproxy_pass http://backend keeps /api/users as-is; proxy_pass http://backend/ strips the matched location prefixBe intentional: with / to strip prefix, without to preserve
Missing proxy headersBackend sees nginx's IP, not the client's. Breaks auth, logging, and geo detectionAlways set X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, and Host
Buffer size errors (502)Large headers (cookies, JWTs) exceed default buffer sizesIncrease proxy_buffer_size 8k; and proxy_buffers 4 16k;
worker_connections too lowDefault is 512 or 1024; each client uses 2 connections (client + upstream)Set worker_connections 4096; and raise worker_rlimit_nofile
try_files with proxy_passtry_files and proxy_pass in the same location don't work as expectedUse try_files $uri @backend; with a named location for proxy
"if is evil"if inside location creates an implicit nested location, breaking directivesUse map for variable-based logic; reserve if for return/rewrite only
Resolver for dynamic upstreamsVariables in proxy_pass (e.g., $upstream) bypass startup DNS resolutionAdd resolver 127.0.0.11 valid=30s; (Docker) or resolver 1.1.1.1;
Missing index directiveReturns 403 Forbidden when accessing a directory instead of index fileAdd index index.html; in server or location block
Permission denied on socketNginx worker can't read the upstream Unix socketEnsure nginx user is in the socket's group; chmod 660 the socket
Duplicate Content-Encoding with gzipUpstream already compresses + nginx gzip double-compressesUse gzip_proxied carefully or proxy_set_header Accept-Encoding "";
add_header not inheritedAdding ANY add_header in a location discards ALL parent add_header directivesRedeclare all headers in the child block, or use include for shared headers
alias vs root confusionroot appends the location path; alias replaces it. /img/ + root /data = /data/img/; alias /data/ = /data/Use alias when location path shouldn't appear in filesystem path

Reference Files

FileContentsLines
reverse-proxy.mdUpstream blocks, load balancing, proxy caching, WebSocket/gRPC, timeouts, real-world configs~650
ssl-security.mdTLS config, Let's Encrypt, HSTS, OCSP, security headers, rate limiting, mTLS~550
performance.mdWorker tuning, compression, caching, HTTP/2+3, static files, monitoring~550

See Also

Signals

GitHub stars
36
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
nginx-ops
Source
github.com/0xdarkmatter/claude-mods