Runbooks & Refs • Operational
Reference: Hardened Reverse Proxy (TLS, Rate Limiting, Security Headers)
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.