Behind a reverse proxy
The server speaks plaintext HTTP on port 3000 and does not terminate TLS. In every deployment that is not a laptop, something in front of it does.
Three things to get right
Section titled “Three things to get right”TLS terminates at the proxy. Bind the server’s port to loopback (or to a private network) so nothing reaches 3000 from outside, and let the proxy hold the certificate.
ISSUER is the public https URL. Not the internal address, not http://. It is the iss
claim of every token, the base of every endpoint in the discovery document, and the redirect target
of the console client — a client library that fetches discovery will follow whatever it says, so a
wrong ISSUER produces a deployment that half-works in ways that are tedious to diagnose. See the
environment reference.
Decide rateLimit.trustedProxy deliberately. It is the subject of the next section.
rateLimit.trustedProxy has a wrong answer in each direction
Section titled “rateLimit.trustedProxy has a wrong answer in each direction”From the project’s README:
rateLimit.trustedProxyis the one setting with a wrong answer in each direction. Leave it on when anything sits in front of this server (the shippedfly.tomldoes): with it off, every caller arrives as the proxy’s own address, so the whole internet shares one allowance and all traffic is refused within seconds. Turn it off when the server is directly exposed: with it on, any caller can setFly-Client-IPto a fresh value per request and is never limited.
So: behind a proxy, leave rateLimit.trustedProxy
at true — and make sure your proxy actually sets a forwarded header, and overwrites rather
than appends to any the client supplied.
Exactly which header the resolver reads
Section titled “Exactly which header the resolver reads”With rateLimit.trustedProxy set to true, the origin resolver takes the first non-empty value of,
in this order:
Fly-Client-IP- the first comma-separated entry of
X-Forwarded-For X-Real-IP
The value is trimmed and truncated to 64 characters. If none of the three is present, the request
falls into a single shared unknown bucket rather than going uncounted — omitting the header must
not be a way to bypass the limiter.
With rateLimit.trustedProxy set to false, none of those headers is read at all: the transport
peer address is used, and a request with no identifiable peer also lands in the shared bucket.
Forwarded (RFC 7239) is not read. If your proxy only emits that, add one of the three above.
Sample configuration
Section titled “Sample configuration”server { listen 443 ssl http2; server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1;
proxy_set_header Host $host; # $remote_addr, not $proxy_add_x_forwarded_for: this overwrites any header the client sent # instead of appending to it, and the resolver reads the FIRST entry. proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; }
# Never rate-limit or auth-gate the liveness probe. location = /health { proxy_pass http://127.0.0.1:3000; access_log off; }}
server { listen 80; server_name auth.example.com; return 301 https://$host$request_uri;}auth.example.com { reverse_proxy 127.0.0.1:3000 { # header_up REPLACES the header rather than appending, which is the point: Caddy's default # X-Forwarded-For handling appends the peer to whatever the client sent, and this server # reads the FIRST entry — so an appended header would let a caller choose its own origin. header_up X-Forwarded-For {remote_host} header_up X-Real-IP {remote_host} }}Caddy obtains and renews the certificate itself, which is most of why its block is so much shorter.
Checking it worked
Section titled “Checking it worked”curl -sI https://auth.example.com/healthcurl -s https://auth.example.com/.well-known/openid-configuration | jq -r .issuerThe second command must print the public https URL you set as ISSUER. If it prints an internal
address or an http:// URL, fix that before anything else — every client will believe it.
To confirm the forwarded header is reaching the server, watch what happens when you exceed a limit:
a 429 with Retry-After that arrives for your address alone, rather than for everyone at once, is
the resolver working. No RateLimit-* headers are emitted, deliberately — they would tell a caller
probing for the threshold exactly where it is.