Behind a proxy¶
This is the page to read before setting GANGWAY_CLIENT_IP_HEADER. Getting
it wrong does not produce an error — the server starts, answers requests,
and appears to filter by origin. It just is not actually checking who is
calling.
Which header to read¶
| Setup | Setting |
|---|---|
| No proxy | Leave GANGWAY_TRUSTED_PROXIES empty. Headers are ignored entirely — only the TCP peer address counts. |
| Exactly one proxy | GANGWAY_CLIENT_IP_HEADER=x-real-ip — unambiguous, no parsing rule. |
| Several proxies | GANGWAY_CLIENT_IP_HEADER=x-forwarded-for — read right to left, skipping entries from GANGWAY_TRUSTED_PROXIES. |
| Cloudflare outermost | GANGWAY_CLIENT_IP_HEADER=cf-connecting-ip. |
Never put your own proxy, host, or container network in the allowlist
GANGWAY_ALLOWED_PREFIXES (and GANGWAY_REMOTE_LIST_URL) answer one
question: who may call this server — the real, original caller.
They do not answer who may relay a call. Adding your reverse
proxy's own address, the host it runs on, or the Docker network it
shares with Gangway to that allowlist does not narrow anything — it
widens it to everyone who can reach the proxy, because one allowlist
governs every route Gangway serves, not just the one you were trying
to fix. The origin check for the actual endpoint you care about is
quietly defeated, and nothing about the server's behavior reveals
that it happened.
This mistake is easy to reach for. Something behind the proxy
doesn't come back the way you expected, and adding the proxy's own
address to the allowlist looks like the obvious, minimal fix — it
is the wrong one. If a proxy's address needs to be trusted for
anything, that is what GANGWAY_TRUSTED_PROXIES is for: it tells
Gangway whose forwarding header to believe, not who may call. The
two settings look similar and are not interchangeable — one narrows
who reaches the server, the other only decides whose word to take
for who that caller behind them was.
Adding a proxy later invalidates this choice. With x-real-ip behind a
chain, the header carries the address of the last proxy, not the caller —
because each proxy in the chain overwrites it with what it saw immediately
in front of it. And an allowlist that matches every caller coming through
that last proxy is no allowlist at all: it now says yes to anyone who can
reach that proxy, which after adding a second hop is no longer just your
known caller.
The same applies in reverse: removing a proxy, or moving from a single reverse proxy to a chain (a CDN in front of a load balancer, say), is a configuration change on this side too, not just on the proxy's.
How the peer decides whether a header is trusted at all¶
GANGWAY_TRUSTED_PROXIES is checked first, unconditionally: the connection
that reached Gangway is only ever trusted to have written an honest
forwarding header if its own address falls inside that list. A request from
outside GANGWAY_TRUSTED_PROXIES has every header it presents ignored, and
only its own TCP peer address is used — so a caller cannot forge
X-Forwarded-For to impersonate an address the allowlist accepts. This is
why an empty GANGWAY_TRUSTED_PROXIES correctly disables header parsing
altogether: nothing is trusted, so nothing is read.
Traefik¶
services:
gangway:
# ...
environment:
GANGWAY_CLIENT_IP_HEADER: x-forwarded-for
GANGWAY_TRUSTED_PROXIES: 172.16.0.0/12 # Traefik's Docker network
Traefik sets X-Forwarded-For by default. If Gangway and Traefik share a
Docker network, GANGWAY_TRUSTED_PROXIES needs to cover that network's
subnet — not 127.0.0.1/32, since the connection arrives from Traefik's
container address, not localhost.
Caddy¶
Caddy's reverse_proxy also sets X-Forwarded-For by default:
NGINX¶
NGINX does not set X-Forwarded-For on its own — it has to be configured:
location /mcp {
proxy_pass http://gangway:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
With exactly this NGINX instance as the only proxy in front of Gangway,
X-Real-IP is the simpler and unambiguous choice:
Pangolin¶
A Pangolin resource forwards through
its own Traefik instance; the same x-forwarded-for guidance as above
applies, with GANGWAY_TRUSTED_PROXIES covering the network Pangolin's
Traefik container reaches Gangway from — typically the shared Docker
network the resource's target is attached to, not Pangolin's public-facing
address.
Cloudflare¶
Cloudflare sets CF-Connecting-IP to the original client address,
regardless of how many internal hops the request took inside Cloudflare's
own network — that is the point of the header, and why it is the right
choice only when Cloudflare is the outermost proxy:
GANGWAY_CLIENT_IP_HEADER=cf-connecting-ip
GANGWAY_TRUSTED_PROXIES=<Cloudflare's published IP ranges>
Running a streamable-HTTP MCP server behind Cloudflare's proxy (the "orange cloud") works, but three separate settings can each silently break it — none of them related to the header choice above:
Bot protection can drop the call before it is filtered
Cloudflare's Bot Fight Mode (and comparable WAF managed rules) can
silently drop an authenticated, non-browser-looking POST to an MCP
endpoint — a server-to-server call carrying an Authorization: Bearer
header with no browser cookies or fingerprint matches the same pattern
bot detection is built to catch. This is not hypothetical: it is the
documented cause of Anthropic's own
claude-ai-mcp issue #327,
where the entire OAuth exchange succeeded server-side but the following
authenticated callback never reached the origin — no log entry, no
error surfaced to the client beyond a generic "authorization failed".
The fix was an explicit IP Access Rule with action Allow for
Cloudflare's own request-source range. Since an origin allowlist is
already the point of this project, add the same provider IP ranges you
configure in GANGWAY_ALLOWED_PREFIXES as an explicit Bot
Fight Mode / WAF allow rule too — otherwise Cloudflare's own edge can
filter the traffic before Gangway ever sees it.
Response buffering delays or drops streamed events
Cloudflare's default response body buffering (Standard) inspects a
prefix of the response before the rest streams through, which does not
suit a long-lived MCP response stream. Set response body buffering to
None for the /mcp path, via a Configuration Rule. Cloudflare's
own documentation notes the trade-off: with buffering off, WAF and Bot
Management body inspection no longer apply to that path — which is
exactly why the Bot Fight Mode allowlist above still matters even after
disabling buffering.
125-second idle timeout needs a heartbeat
Cloudflare's Proxy Read Timeout is 125 seconds by default (not adjustable below Enterprise) and, per Cloudflare's own definition, is a timeout "between two successive read operations to your origin server" — an idle timeout, not a hard cap on total connection length. In practice this means a streamed MCP connection that goes more than 125 seconds without sending any bytes is liable to be cut with a 524, while one that emits some kind of keepalive at a shorter interval should stay open indefinitely. Cloudflare has not published this as a documented guarantee specifically for SSE or streamable HTTP, so treat it as something to verify against your own long-lived connections rather than as a settled fact.
None of these three interact with GANGWAY_CLIENT_IP_HEADER or
GANGWAY_TRUSTED_PROXIES — they can each break the connection while origin
filtering continues to work exactly as configured, which is what makes them
easy to misdiagnose as an allowlist problem when they are not one.