Operational

Reference: Hardened Reverse Proxy (TLS, Rate Limiting, Security Headers)

TL;DR: The reverse-proxy configuration I put in front of self-hosted stacks. Two flavors — Caddy when I want automatic TLS with near-zero config, and hardened nginx with a certbot sidecar when I need tuned TLS, rate limiting, and connection limits. Both terminate TLS, set strict security headers, and keep app services off the public internet.

Every self-hosted app sits behind a reverse proxy that owns TLS and is the only thing exposed to the internet; the app, database, and cache stay on the internal Docker network. I use one of two configurations depending on how much control the stack needs.

Caddy — automatic TLS, minimal config

Best when I want HTTPS to just work. Caddy provisions and renews Let’s Encrypt certificates on its own.

{$DOMAIN} {
    handle /api/*   { reverse_proxy api:8080 }
    handle /media/* { reverse_proxy api:8080 }
    handle          { reverse_proxy web:3000 }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    encode gzip
}

nginx — hardened, with a certbot renewal sidecar

Best when I need rate limiting and tuned TLS. Certbot runs as a sidecar that renews every 12 hours.

# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...:ECDHE-RSA-CHACHA20-POLY1305';
ssl_stapling on;
ssl_stapling_verify on;

# Rate-limit zones: 100 r/s for the API, 50 r/s for the app; cap concurrent conns
limit_req_zone  $binary_remote_addr zone=api_limit:10m rate=100r/s;
limit_req_zone  $binary_remote_addr zone=app_limit:10m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429;

Applied per location, with a burst allowance:

location /api/webhook/ { limit_req zone=api_limit burst=20 nodelay; limit_conn conn_limit 10; }
location /            { limit_req zone=app_limit burst=30 nodelay; }

Security headers and HSTS (note the longer two-year max-age on this stack):

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 "no-referrer-when-downgrade" always;

Gzip is on for text/JSON/JS responses. The rule of thumb: Caddy for simple stacks, hardened nginx when I need rate limiting and fine-grained TLS control — either way, only 80/443 is open and the proxy is the single front door.