Switch Fly proxy to upstream keepalive pools (#337)
All checks were successful
Deploy Fly.io Proxy / deploy (push) Successful in 1m37s

## Summary

- Replace per-request DNS resolution (variable-based `proxy_pass`) with static `upstream` blocks and `keepalive` connection pools
- Reuses TLS connections through the Tailscale tunnel instead of handshaking per request
- Add `mise run fly-reload` for nginx config reload without full redeploy (re-resolves upstream DNS)

## Trade-off

DNS is resolved at config load, not per-request. If Tailscale Ingress pods get new IPs (restart, reschedule), `mise run fly-reload` is needed. A Grafana alert will be added to detect this.

## Still TODO on this branch

- [ ] Grafana alert for upstream unreachable (triggers fly-reload reminder)
- [ ] Docs pass
- [ ] Deploy from branch and verify latency improvement
- [ ] Changelog fragment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #337
This commit is contained in:
Erich Blume 2026-04-17 16:39:52 -07:00
commit fe0e913963
12 changed files with 229 additions and 102 deletions

View file

@ -46,18 +46,32 @@ http {
proxy_cache_path /tmp/cache levels=1:2 keys_zone=services:10m
max_size=200m inactive=24h;
# MagicDNS resolver using a variable in proxy_pass defers upstream DNS
# resolution to request time (not config time). Results are cached for
# 30s per worker to avoid per-request DNS lookups.
# WebSocket-aware Connection header. Only send "upgrade" when the client
# actually requests a protocol switch; otherwise empty string to preserve
# upstream keepalive connections.
map $http_upgrade $connection_upgrade {
default "";
websocket upgrade;
}
# --- Upstream pools with keepalive ---
# DNS is resolved once at config load via MagicDNS. If Tailscale Ingress
# pods get new IPs (restart, reschedule), run `mise run fly-reload` to
# re-resolve. A Grafana alert fires when upstreams are unreachable.
resolver 100.100.100.100 valid=30s;
resolver_timeout 5s;
# WebSocket-aware Connection header. Only send "upgrade" when the client
# actually requests a protocol switch; otherwise "close" (the HTTP/1.1
# default when keepalive pooling is not available).
map $http_upgrade $connection_upgrade {
default close;
websocket upgrade;
upstream forge_backend {
server forge.tail8d86e.ts.net:443;
keepalive 8;
}
upstream docs_backend {
server docs.tail8d86e.ts.net:443;
keepalive 4;
}
upstream cv_backend {
server cv.tail8d86e.ts.net:443;
keepalive 4;
}
# --- docs.eblu.me (static site) ---
@ -76,12 +90,16 @@ http {
internal;
}
location / {
set $upstream_docs https://docs.tail8d86e.ts.net;
proxy_pass $upstream_docs$request_uri;
proxy_pass https://docs_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name docs.tail8d86e.ts.net;
proxy_set_header Host docs.tail8d86e.ts.net;
proxy_intercept_errors on;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
# Cache aggressively static site only.
# Do NOT use these settings for dynamic services.
proxy_cache services;
@ -116,12 +134,16 @@ http {
}
location / {
set $upstream_cv https://cv.tail8d86e.ts.net;
proxy_pass $upstream_cv$request_uri;
proxy_pass https://cv_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name cv.tail8d86e.ts.net;
proxy_set_header Host cv.tail8d86e.ts.net;
proxy_intercept_errors on;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_cache services;
proxy_cache_valid 200 1d;
proxy_cache_valid 404 1m;
@ -187,10 +209,10 @@ http {
location ~ ^/user/(login|sign_up|forgot_password) {
limit_req zone=forge_auth burst=5 nodelay;
set $upstream_forge https://forge.tail8d86e.ts.net;
proxy_pass $upstream_forge$request_uri;
proxy_pass https://forge_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name forge.tail8d86e.ts.net;
proxy_intercept_errors on;
proxy_set_header Host $host;
@ -206,10 +228,13 @@ http {
# Cache release artifact downloads immutable files keyed by tag+filename.
# Avoids hammering Forgejo when crawlers or users re-download the same asset.
location ~ ^/[^/]+/[^/]+/releases/download/ {
set $upstream_forge_releases https://forge.tail8d86e.ts.net;
proxy_pass $upstream_forge_releases$request_uri;
proxy_pass https://forge_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name forge.tail8d86e.ts.net;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_cache services;
proxy_cache_valid 200 7d;
@ -226,10 +251,13 @@ http {
# Selectively cache static assets only
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
set $upstream_forge_static https://forge.tail8d86e.ts.net;
proxy_pass $upstream_forge_static$request_uri;
proxy_pass https://forge_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name forge.tail8d86e.ts.net;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_cache services;
proxy_cache_valid 200 7d;
@ -240,10 +268,10 @@ http {
}
location / {
set $upstream_forge https://forge.tail8d86e.ts.net;
proxy_pass $upstream_forge$request_uri;
proxy_pass https://forge_backend$request_uri;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name forge.tail8d86e.ts.net;
proxy_intercept_errors on;
# NO proxy_cache dynamic content with sessions

View file

@ -11,10 +11,18 @@ tailscale up --authkey="${TS_AUTHKEY}" --hostname=flyio-proxy
until tailscale status > /dev/null 2>&1; do sleep 1; done
echo "Tailscale connected"
# Wait for MagicDNS to be ready — upstream blocks resolve DNS at config
# load, so nginx will fail to start if MagicDNS can't resolve yet.
echo "Waiting for MagicDNS..."
until nslookup forge.tail8d86e.ts.net 100.100.100.100 > /dev/null 2>&1; do
sleep 1
done
echo "MagicDNS ready"
# Ensure fail2ban deny file exists before nginx starts
touch /etc/nginx/forge-deny.conf
# Start nginx — MagicDNS is available, health check passes immediately.
# Start nginx — MagicDNS is available, upstreams resolved.
nginx -g "daemon off;" &
NGINX_PID=$!
echo "Nginx started"