AI Tools Compared

Nginx configuration has a deceptively simple syntax but a long tail of security and performance pitfalls: misconfigured SSL ciphers, missing security headers, rate limiting that blocks legitimate traffic, and proxy settings that leak internal server details. AI tools that generate nginx configs without understanding the security implications can introduce vulnerabilities.

Table of Contents

The Security Baseline Test

Every AI tool was given this prompt:

“Write a nginx configuration for a Node.js app running on port 3000, with HTTPS, Let’s Encrypt SSL, and production security settings.”

Copilot’s output (selected security issues):

# Copilot — missing critical security headers
server {
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    # Missing: ssl_protocols, ssl_ciphers, HSTS, security headers

    location / {
        proxy_pass http://localhost:3000;
        # Missing: proxy_hide_header, X-Frame-Options, X-Content-Type-Options
    }
}

Claude’s complete configuration:

# /etc/nginx/sites-available/myapp

# Rate limiting zone — 10 requests/second per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

# Upstream definition for health checks
upstream nodejs_app {
    server 127.0.0.1:3000;
    keepalive 32;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    # Allow Let's Encrypt ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS server
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    # SSL configuration
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

    # Modern SSL settings (TLS 1.2/1.3 only)
    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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

    # Hide nginx version
    server_tokens off;

    # Hide upstream server header
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;

    # Logging
    access_log /var/log/nginx/myapp-access.log combined;
    error_log /var/log/nginx/myapp-error.log warn;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    # Client body size limit
    client_max_body_size 10M;

    # API routes with rate limiting
    location /api/ {
        limit_req zone=api burst=20 nodelay;
        limit_req_status 429;

        proxy_pass http://nodejs_app;
        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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # WebSocket support
    location /ws/ {
        proxy_pass http://nodejs_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;  # Keep WebSocket connections alive
    }

    # Static assets with aggressive caching
    location /static/ {
        alias /var/www/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Health check endpoint (no rate limiting)
    location /health {
        proxy_pass http://nodejs_app;
        access_log off;
    }
}

Task 2: Caching Layer Configuration

Prompt: “Add nginx caching for API responses with cache invalidation support.”

# Claude's caching configuration
http {
    # Cache storage location and size
    proxy_cache_path /var/cache/nginx/api
        levels=1:2
        keys_zone=api_cache:10m
        max_size=1g
        inactive=60m
        use_temp_path=off;

    server {
        # Cache API GET/HEAD responses
        location /api/v1/ {
            proxy_cache api_cache;
            proxy_cache_key "$scheme$request_method$host$request_uri$http_accept_language";
            proxy_cache_valid 200 5m;
            proxy_cache_valid 404 1m;
            proxy_cache_valid any 0;

            # Cache bypass conditions
            proxy_cache_bypass
                $http_cache_control    # Bypass if Cache-Control: no-cache
                $http_x_bypass_cache   # Bypass via custom header
                $cookie_session;       # Don't cache authenticated requests

            proxy_no_cache
                $http_cache_control
                $cookie_session;

            # Only cache GET and HEAD
            proxy_cache_methods GET HEAD;

            # Add cache status header for debugging
            add_header X-Cache-Status $upstream_cache_status;

            proxy_pass http://nodejs_app;
        }

        # Cache purge endpoint (restrict to internal IPs)
        location ~ /purge(/.*) {
            allow 127.0.0.1;
            allow 10.0.0.0/8;
            deny all;
            proxy_cache_purge api_cache "$scheme$request_method$host$1";
        }
    }
}

Task 3: Load Balancing Configuration

# Claude's load balancer with health checks
upstream backend_pool {
    # Least connections algorithm for uneven request distribution
    least_conn;

    server 10.0.1.10:3000 weight=2 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:3000 weight=2 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:3000 weight=1 max_fails=3 fail_timeout=30s;

    # Backup server — only used if all primary servers are down
    server 10.0.1.20:3000 backup;

    keepalive 32;
    keepalive_requests 100;
    keepalive_timeout 60s;
}

server {
    location / {
        proxy_pass http://backend_pool;
        proxy_http_version 1.1;
        proxy_set_header Connection "";  # Required for keepalive to upstream

        # Retry on upstream errors
        proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 5s;
    }
}

Task 4: Security-Hardened API Gateway

# Claude's API gateway with auth and injection protection
server {
    # Block common injection patterns in query strings
    set $block_query 0;
    if ($query_string ~* "(union|select|insert|delete|drop|truncate|eval|javascript|vbscript|onload)") {
        set $block_query 1;
    }
    if ($block_query = 1) {
        return 400 "Bad Request";
    }

    # Block user agents that are clearly scanners
    if ($http_user_agent ~* "(sqlmap|nikto|masscan|nessus|dirbuster|wfuzz)") {
        return 403;
    }

    # JWT validation via auth_request to your auth service
    location /api/protected/ {
        auth_request /auth/validate;
        auth_request_set $auth_user $upstream_http_x_auth_user;

        # Pass auth user to upstream
        proxy_set_header X-Auth-User $auth_user;
        proxy_pass http://nodejs_app;

        # Cache auth validation results (5 minutes)
        proxy_cache_valid 200 5m;
    }

    # Internal auth validation endpoint
    location = /auth/validate {
        internal;
        proxy_pass http://auth_service/validate;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header Authorization $http_authorization;
    }
}

Task 5: Geo-Blocking and Bot Mitigation

One scenario where Copilot and Cursor both produced incomplete output was geo-blocking combined with bot mitigation. Claude generated a complete working block using the MaxMind GeoIP2 module:

# Requires: ngx_http_geoip2_module and MaxMind GeoLite2-Country database
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
    $geoip2_data_country_code country iso_code;
}

map $geoip2_data_country_code $allowed_country {
    default         0;
    US              1;
    CA              1;
    GB              1;
    AU              1;
    DE              1;
}

server {
    listen 443 ssl http2;

    # Drop non-allowed countries at the request level
    if ($allowed_country = 0) {
        return 403 "Service not available in your region";
    }

    # Additional bot fingerprinting — real browsers send Accept-Language
    # Known scrapers often omit it
    set $bot_score 0;
    if ($http_user_agent = "") { set $bot_score 1; }
    if ($http_accept_language = "") { set $bot_score "${bot_score}1"; }

    if ($bot_score = "11") {
        return 429 "Too Many Requests";
    }

    location / {
        proxy_pass http://nodejs_app;
    }
}

This configuration demonstrates an important difference between tools: Claude explained that the if directive is safe here because it is used only to set variables and return responses — the two contexts where nginx documentation permits if at the server level. Copilot generated a syntactically similar block but omitted the explanation, leaving engineers unaware of the known footgun with if inside location blocks.

Why Security Context Matters More Than Syntax

All three tools can write syntactically valid nginx config. The differentiator is whether the tool understands the security implications of each directive. A quick evaluation checklist:

Security Check Claude Copilot Cursor
TLS 1.0/1.1 disabled by default Yes Sometimes Rarely
HSTS with preload flag Yes No No
OCSP stapling configured Yes No No
server_tokens off included Yes Inconsistent Yes
Proxy hides upstream headers Yes No No
Rate limiting with burst queue Yes Basic No
Explains if directive risks Yes No No

The pattern is consistent: Claude tends to generate configs that would pass a Mozilla Observatory scan on the first attempt. The others require one or two review cycles to reach the same standard.

Tool Comparison

Feature Claude Copilot Cursor
SSL hardening TLS 1.2/1.3, OCSP TLS 1.2+, basic Basic SSL
Security headers 6 headers including Permissions-Policy 2-3 headers 3-4 headers
Rate limiting Zone + burst config Missing or basic Basic
Proxy timeouts All three (connect/send/read) Often missing Partial
WebSocket config Correct Upgrade headers Sometimes correct Correct
Caching Full with bypass/purge Basic Basic
Load balancing Health checks, retries Round-robin only Partial
Geo-blocking Full GeoIP2 example Incomplete Not attempted

Practical Advice for Using AI with Nginx

Regardless of which tool you use, apply these steps before any AI-generated nginx config reaches production:

  1. Run nginx -t to catch syntax errors before reloading
  2. Test SSL with testssl.sh or the Qualys SSL Labs scanner
  3. Verify security headers with Mozilla Observatory
  4. Load test rate limiting with wrk or vegeta to confirm burst behaviour
  5. Check that proxy_next_upstream settings match your upstream’s idempotency guarantees — retrying a POST on a payment endpoint is dangerous

Built by theluckystrike — More at zovo.one