Perron reviews every chat post with AI. The first choice is Cloudflare's Clef model on Workers AI, which is free up to a daily allowance. When that allowance is spent, the next choice is a guard model we run ourselves, Qwen3Guard, on an M1 Max laptop that also does a dozen other jobs for us.
That laptop sits behind an ordinary router. It has no public IP address, no port forwarding, and we had no intention of giving it either. A machine that answers on the open internet is a machine that gets scanned, probed and eventually attacked, and this one also does our iOS builds.
So the question was how a Cloudflare Worker, running in some data centre we don't control, can call a server on that laptop, and how to make sure nothing else can. The answer has three layers, and we think all three earn their keep.
The shape of it
Worker ──HTTPS──► mod.example.com ──► Cloudflare Access ──► Tunnel ──► cloudflared on the Mac ──► 127.0.0.1
(service token) (bearer token)
Cloudflare Tunnel makes an outbound connection from the Mac to Cloudflare. Nothing listens on the internet, and the router doesn't need to know anything about it.
Cloudflare Access sits in front of the hostname. A request without a valid service token is turned away at Cloudflare's edge and never reaches the house.
The server on the Mac then checks a second, completely independent bearer token. It also only does one thing, which we'll come back to, because it's the most important decision of the lot.
Layer one: a tunnel that knows one route
We use a remotely managed tunnel, which means its configuration lives in the Cloudflare dashboard (under Zero Trust, Networks, Tunnels) and not in a YAML file on the Mac. That's convenient, and it also means the Mac only ever holds a run token, never a credential that could change the routing.
The tunnel has one public hostname, pointing at the model server on localhost. Anything else that arrives at the tunnel gets a 404:
mod.example.com -> http://127.0.0.1:<port>
(catch-all) -> http_status:404
The model server binds to 127.0.0.1 only. It isn't reachable from the home network either, so a compromised smart TV on the same Wi-Fi can't talk to it. The tunnel is the only way in.
On macOS, cloudflared runs as a LaunchDaemon so it starts at boot, before anyone logs in. The tunnel's run token lives in the System keychain. A tiny wrapper script reads it at start-up and hands it to cloudflared in the TUNNEL_TOKEN environment variable:
#!/bin/bash
TUNNEL_TOKEN="$(security find-generic-password -s <service-name> -w)"
export TUNNEL_TOKEN
exec /opt/homebrew/bin/cloudflared tunnel --no-autoupdate run
That keeps the token out of any file on disk, and out of the command line, where every user on the machine could read it with ps. It's a small thing, but run tokens tend to end up in dotfiles and shell history otherwise, and from there into backups.
We use the same pattern on our server for two relays that fetch German train data. Perron's Worker calls them, they sit behind a Tunnel, and the server they run on has no inbound ports open for them. Once you've set up one tunnel, the second takes minutes.
Layer two: Cloudflare Access with a service token
A tunnel on its own publishes the server to anyone who finds the hostname. Hostnames aren't secret. They leak through DNS, logs, certificates and screenshots, and you should assume somebody will find yours. Access is what closes the door.
The setup has three parts. In Zero Trust you create a service token, which gives you a Client ID and a Client Secret. You choose its lifetime when you create it, and it's worth putting the expiry date in your calendar, because an expired service token fails in exactly the same way as a revoked one. Then you create an Access application for the hostname. Finally you give that application a policy whose action is Service Auth, allowing only that service token.
That last part trips people up. With a normal Allow policy, Access answers an unauthenticated request with a login page, which is great for a human and useless for a Worker. Service Auth is the action that accepts the two token headers and nothing else.
On the Worker side, the token goes in two headers on every request. The names come from Cloudflare's service token docs:
const res = await fetch(`${env.MOD_GATEWAY_URL}/v1/moderate`, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-access-client-id": env.MOD_ACCESS_CLIENT_ID,
"cf-access-client-secret": env.MOD_ACCESS_CLIENT_SECRET,
authorization: `Bearer ${env.MOD_GATEWAY_TOKEN}`,
},
body: JSON.stringify({ policy, items }),
signal: AbortSignal.timeout(150_000),
});
All three values are Worker secrets, set with wrangler secret put and fed on stdin, never written into vars where they'd sit in the config file and the deploy logs. The gateway URL itself isn't secret, but we keep it as a variable so staging can leave it empty. A staging Worker with no gateway URL simply skips this provider.
The timeout is long, two and a half minutes, because a batch of a hundred posts on a laptop that's busy with something else can take a while. We'd rather wait than give up on a batch the Mac was about to finish.
Layer three: a second key on the server
Access already stops strangers, so why bother with a bearer token too?
Because two independent keys mean a single mistake isn't enough. Access policies are edited in a dashboard by humans, and humans click the wrong thing. Service tokens get pasted into places they shouldn't. If either of those ever happens, the server on the Mac still refuses the request, because it has its own check that knows nothing about Access.
The server never stores the token, only its SHA-256 hash. If someone got hold of the server's configuration, they'd have a hash they can't send. The comparison is constant-time, and it runs against every configured hash without stopping early:
got = hashlib.sha256(header[7:].strip().encode()).hexdigest()
ok = False
for h in TOKEN_SHA256: # no early exit: every hash is compared every time
ok |= hmac.compare_digest(got, h)
The list of hashes is what makes rotation painless. Add the new token's hash to the server, set the new token as the Worker secret, deploy, then remove the old hash. At no point is there a window where the Worker holds a token the server won't accept.
The important part: expose one operation, not a model
Here's the decision we'd make again before any of the above. The server on the Mac isn't a proxy to Ollama or any other model runner. It doesn't accept prompts. It exposes exactly one operation: here's a moderation policy and a list of texts, tell me which ones break it.
That changes what a leak means. If both keys were stolen at once, the attacker gets the ability to moderate some text, using a model we chose, with a prompt we wrote. They don't get "run any prompt on the owner's machine", which is a very different thing to lose.
The server also looks after itself, because it shares a laptop with real work. It caps the size of a request and the number of items in it, and trims each text to a sensible length. It runs one model job at a time; a second caller waits briefly and then gets a 503 with Retry-After if the first job is still going. It has a simple per-minute budget for calls and answers 429 above it. Failed authentication attempts are rate-limited separately, so somebody guessing tokens can't make the Mac do work.
It logs one line per call with counts and timings. It never logs the text. The posts it judges are people's chat messages, and a moderation log full of them would be a privacy problem waiting to happen. The texts that matter for improving moderation are already kept, with their verdicts, in an R2 bucket with a 180-day lifecycle rule, and nowhere else.
Planning for the laptop being off
A laptop sleeps, reboots for updates, and loses Wi-Fi when somebody unplugs the wrong thing. The Worker has to assume this provider won't always be there.
Any error, timeout or unparseable reply counts as a provider failure. A failure opens a two-minute circuit breaker, during which the Worker doesn't even try the Mac. Without that, a dead laptop would cost a full timeout on every batch, and with a 150-second timeout that adds up fast. With it, a dead laptop costs one timeout every two minutes at most.
The batch itself doesn't get lost. Perron publishes posts first and reviews them after, so a batch that nobody can judge just stays queued and retries with backoff. Posts stay up in the meantime. Nobody notices the laptop's asleep except us, in a dashboard.
We also send a share of the batches Workers AI already judged to the Mac as a shadow review, logged but never acted on. It's a cheap way to compare the two models on real traffic, and it means the tunnel gets exercised every day, even on days when the free neurons last until midnight.
Why not just open a port?
We get asked this. It'd be simpler: forward a port, put TLS on it, check a token. Some reasons we didn't:
A home connection's IP changes, so you'd need dynamic DNS, and every change is a little outage. An open port gets found by scanners quickly, and from then on you're running a public server on a laptop. The token check on the server is then your only defence, and any bug in it is exposed to the whole internet instead of to whoever already got past Access.
With Tunnel and Access, an attacker has to beat Cloudflare's edge before they can even send a byte to the Mac. The laptop has no inbound attack surface at all. And it cost us nothing: Tunnel is free, and Access is free for small teams.
When this pattern fits
It's a good fit when you've got hardware that's better or cheaper for one job than a cloud service, like a Mac with lots of unified memory running a local model, or a box with a GPU, and the caller can tolerate that machine disappearing now and then. A fallback provider for batch moderation is close to the ideal case: the work can wait, and there's another provider in front of it.
It isn't a replacement for a server in a data centre for anything that has to answer every time. If the Mac's availability ever became part of what users see, we'd move the job.
If you're doing something similar, the order we'd build it in is the order of this post, backwards. Decide on the single operation first. Then the server's own key. Then Access. Then the tunnel. The tunnel is the easy part, and it's the part people tend to stop at.
More from the same pipeline: AI moderation with Workers AI and Clef and metering the free Workers AI allowance. If you also protect an admin panel with Access, see verifying Cloudflare Access JWTs in a Worker.