Cole Munz

build log · Android apps

A relay that doesn't need Cloudflare

2026-09-30

Starling's F-Droid listing had a "Self-hostable" heading and told people to "Run your own." That was true in about the narrowest way possible. The relay was a Cloudflare Worker with a D1 table, so running your own meant getting a Cloudflare account. Issue #9 pointed that out, and then made the point that actually stung: the ciphertext is unreadable, sure, but everyone on the default relay was still sending their IP address and timing through Cloudflare, and nowhere did the listing say who hosted it.

They were right on both counts. The disclosure was the easy half, so that shipped the same morning, and the listing, the README and the privacy policy now name Cloudflare as the host and spell out what it can see. While I was in there I pinned request logging off in wrangler.toml, because a privacy page that says the relay's own request logging is off shouldn't be one dashboard click away from being false.

Same code, different socket

I didn't rewrite the relay for this, and didn't want to. It's small: a request in, a request out, one SQL table. relay/server.mjs runs the exact Worker handler under node:http, and relay/d1sqlite.mjs wraps node:sqlite in the shape of D1. My tests were already running the relay against that same wrapper in memory, so a self-hosted server runs the code the tests run instead of a second copy that would slowly drift.

What took thought were the jobs a Cloudflare edge used to do without anyone noticing.

Client addresses were the big one. Rate limits are per address, and behind Apache or nginx every single request comes from the proxy. With TRUST_PROXY=1 the server takes the client from X-Forwarded-For, but only the last entry, since the proxy right in front appended that one; anything earlier in the header is whatever the client decided to type. cf-connecting-ip, the header the Worker actually reads, gets overwritten on every request, so nobody picks their own.

Expiry didn't change. Every row still goes after 24 hours, and busy channels sweep themselves, but a VPS has no cron trigger waiting for this, so an idle sweep runs every ten minutes.

And oversized requests get a clean 413. The Worker already refuses anything over 2 KB. The Node side stops buffering at 64 KB instead of resetting the connection on someone halfway through an upload.

What review caught

This changed the landing page's wording, from "your own Cloudflare account" to "your own server", and that page's service worker serves it cache first. Without a new cache version, everyone who'd visited before would have kept seeing the old sentence until some unrelated change happened to touch sw.js. It's one line, and you'd never see the problem from the machine you test on.

Not verified yet

Nobody has run the Apache and nginx examples in SELF-HOSTING.md behind a real proxy. The tests and a live run cover the server, not those configs. The person who opened #9 offered to try it on their own Apache, and the doc will change with whatever breaks there.

Calling something self-hostable is a promise about someone else's server. It ought to hold up on theirs.