The site is written in Bun + Hono, runs in a container, and is exposed via Cloudflare Tunnel. Five days ago there were two bugs in a row (be15f5a, 2ea4f53, eight minutes apart), both of which “brought local development assumptions into this topology.”
First: origin is http
When the tunnel sends requests in from the public internet, the hop to the container is http. So anywhere that gets the origin via request.url gets http://....
Most places don’t care; two do:
- the cookie’s
Secureattribute, and OAuth callback URL construction—the callback comes out as http, but the browser treats the return as https, so they don’t match. - generating absolute URLs for external use (share links, links in emails)—what goes out is http, and when users click it they either get redirected or it simply doesn’t work.
Cloudflare includes x-forwarded-host and x-forwarded-proto. So should we restore based on those?
No. x-forwarded-host is a header the client can forge, and when connecting directly to the container port, it’s entirely determined by the requester. Trusting it unconditionally opens another hole for changing the origin.
I wrote a publicRequest(request, configuredUrl) (src/server/public-request.ts, 10 lines total), with these rules:
- If no public domain is configured, return it as-is.
- If the request host doesn’t match the configured host → leave it alone. That means it came in through another entry point (loopback preview, direct intranet connection), and that origin belongs to the user; changing it would actually be wrong.
- If the hosts match but the protocol is already the same → leave it alone. Don’t copy the request for no reason.
- The only remaining case: hosts match, protocols differ → change only the protocol and rebuild it with
new Request().
const configured = new URL(configuredUrl);
const incoming = new URL(request.url);
if (incoming.host !== configured.host || incoming.protocol === configured.protocol)
return request;
incoming.protocol = configured.protocol;
return new Request(incoming, request);new Request(url, request) preserves the method, body, headers, and all other properties, replacing only the origin. In src/server/index.ts, change fetch: app.fetch to go through this handling before forwarding:
fetch: (...[request, bindings, executionCtx]: Parameters<typeof app.fetch>) =>
app.fetch(publicRequest(request, process.env.APP_URL), bindings, executionCtx),This way, “restoring https” only happens when it’s confirmed to be this entry point, and a forged x-forwarded-host is ignored because the host doesn’t match.
The three tests/public-request.test.ts cases cover exactly this: restoring https while keeping the POST body / cookies / query string all intact; not trusting x-forwarded-host: af.saop.cc when the actual connection is 127.0.0.1:31270; and returning the same object (rather than a copy) when the protocol is the same or no public domain is configured.
Strictly speaking, the third isn’t a security requirement but predictability—if you’re debugging locally with curl 127.0.0.1:3000 and suddenly see an https-style origin, you’d be suspicious for a long time.
Second: the container’s 127.0.0.1 is not the host’s
For the container to access the internet, it has to go through the proxy 127.0.0.1:11090 on the host machine.
In compose.yaml it’s written like this:
environment:
HTTP_PROXY: ${OUTBOUND_PROXY:-}
HTTPS_PROXY: ${OUTBOUND_PROXY:-}
NO_PROXY: ${OUTBOUND_NO_PROXY:-}
http_proxy: ${OUTBOUND_PROXY:-}
https_proxy: ${OUTBOUND_PROXY:-}
no_proxy: ${OUTBOUND_NO_PROXY:-}Both cases are written because Bun/undici recognizes lowercase, while many shell tools only recognize uppercase.
One pitfall has to be stated clearly right in .env.production.example:
# 宿主机上的代理,不是容器自己的 127.0.0.1
OUTBOUND_PROXY=http://127.0.0.1:11090
OUTBOUND_NO_PROXY=localhost,127.0.0.1,postgres,redisOUTBOUND_PROXY is set to 127.0.0.1, which points to the host’s loopback. But 127.0.0.1 inside the container is the container itself; if it doesn’t match, nothing on the network works. At the same time, NO_PROXY must include service names like postgres and redis—they’re on the same compose network, so connecting directly is faster than going through the proxy, and it also prevents the proxy from not recognizing those names.
Don’t bake it into the image
The proxy is environment-specific: it exists on the build machine, may not exist on a laptop, and may use a different port in production. So inject it only at runtime via environment, and don’t write a single ENV HTTP_PROXY in Dockerfile.
If you bake it into the image, the image is tied to the network configuration at build time. Moreover, ENV in Dockerfile overrides runtime injection—you’d have to rebuild the image to change it, even though it’s clearly a configuration issue.
What the two have in common
Both bugs have the same shape: the code was written assuming “requests connect directly, network connects directly,” but the deployment topology isn’t like that.
The tunnel and the container each work fine on their own; what’s missing is a layer of translation at their boundary—one translates the inbound origin from http back to https (and only when it should), and the other translates the outbound target from the container loopback to the host loopback.
Things at this kind of boundary have a characteristic: everything works locally, because locally it is both the “public entry point” and the “host machine,” so both assumptions hold at once. It only blows up after deployment, and the blow-up happens far from the change.
deploy/README.md now has explanations for both sections, placed together. Next time someone adds a new environment variable, they’ll at least see the sentence “the container and the host are two different loopbacks.”
