Tech · Note
A phone admin on Cloudflare Workers
Part of the how this site is built series. I add most posts through Claude sessions, but I wanted a quick way to fix a typo or tweak a quantity from my phone. This is that: a tiny web app that lists the posts, edits the markdown, shows a preview and saves the change to GitHub. Cloudflare Pages then rebuilds the site as for any other push.
I set this up quickly, so this post is partly notes for future me on what each piece actually does.
What it does
- Lists every post in
content/, grouped by section, with a filter box. - Edits the raw markdown, frontmatter included, with Edit, Preview and Tags tabs on a phone (side by side on a laptop).
- The preview uses the same markdown code and the live site’s stylesheet, so it looks like the real thing.
- Validates as you type with the same rules as the build, and won’t save while there are errors. A phone edit can’t break the deploy.
- Keeps unsaved text on the phone, so losing signal or closing the tab loses nothing.
- Save commits straight to
mainas “Edit <title>”. If the post changed somewhere else since I opened it (say a Claude session pushed), the save is refused and I’m offered my version back. - Installs to the home screen like an app.
There are no drafts and no “new post” button yet. It’s for tweaks.
Why this shape
- A Worker, not a server. A Worker is a small piece of JavaScript that Cloudflare runs on request. There is nothing to patch or keep running, and the free tier is far more than one person needs.
- The Worker holds the secrets. The GitHub token lives in the Worker as an encrypted secret and never reaches the phone. The phone only ever talks to the Worker.
- Cloudflare Access instead of writing a login. Login code is easy to get subtly wrong. Access puts a sign-in page in front of the whole app before a single request reaches my code.
- Straight to
main. It’s a low-stakes personal site and every change is in git history anyway. - No build step for the app. Plain HTML, CSS and JS. The Worker is bundled by wrangler, and it imports the site’s own validator and markdown code, so the rules can’t drift apart.
Key concepts
The part I most wanted to understand properly.
Cloudflare Workers. Code that runs on Cloudflare’s network in response to HTTP requests. A Worker can also serve static assets (here, the app’s HTML, CSS and JS) from a folder you upload with it.
workers.dev vs a custom domain. Every Worker can be reached at
<worker-name>.<your-account-subdomain>.workers.dev. To serve it on your own domain instead (like
admin.bla.ckmo.re), that domain must be a zone in your account, meaning Cloudflare runs its DNS.
bla.ckmo.re is only CNAMEd to Pages from my DNS provider, so it isn’t a zone, and the admin lives on
its workers.dev address. That’s fine for an app that sits on my home screen. Moving DNS to Cloudflare
later would allow the nicer name; it’s a two-line config change.
wrangler. Cloudflare’s command-line tool for Workers. It bundles the code, uploads it with the
static files, manages secrets and runs a local dev server. npx wrangler login connects it to your
account once through the browser.
Zero Trust and Access. Zero Trust is Cloudflare’s umbrella for its security products, and Access is the piece that puts a login in front of an app. The free plan covers up to 50 users. You create an Access application for the thing you want to protect, and give it policies that say who’s allowed in. I used the pre-made “Cloudflare account” policy: only members of my Cloudflare account, which is just me.
Team domain. When you first open Zero Trust you choose a team name. Your login pages then live at
https://<team>.cloudflareaccess.com.
The Access token (JWT) and the AUD tag. Once you’ve signed in, Access adds a signed token to every
request it forwards, in the Cf-Access-Jwt-Assertion header. A JWT is three base64 chunks: a
header, a set of claims (who you are, which app, when it expires) and a signature. The AUD tag is
the application’s ID, and it appears in the token’s aud claim. Cloudflare publishes the public keys
for checking the signature at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs.
Why the Worker checks the token too. Access already blocks anyone who isn’t signed in, so this is a second lock. If the Access app were ever deleted or misconfigured, the Worker would refuse every request rather than quietly becoming public. In outline it does this:
const token = request.headers.get('cf-access-jwt-assertion');
// 1. Find the signing key: fetch the team's /cdn-cgi/access/certs and pick the key whose
// kid matches the token header (refetch once if it's unknown, as keys rotate).
// 2. Verify the RS256 signature with crypto.subtle.verify().
// 3. Check the claims: iss is my team domain, aud includes my AUD tag, exp is in the future.
// Anything missing or wrong: respond 403.Secrets vs vars. Worker settings come in two kinds. Vars are plain config in wrangler.jsonc,
committed to the repo: the repo name, the team domain and the AUD tag. None of those is secret.
Secrets are encrypted, set with wrangler secret put, and never appear in the repo or in the
dashboard after they’re saved: the GitHub token.
The GitHub side. The Worker uses the GitHub REST API’s contents endpoints. Reading a file returns
its text plus a sha, a fingerprint of that version. Saving sends the new text with the sha it
started from. If the file has changed since, GitHub answers 409 Conflict instead of overwriting.
That’s the whole conflict check, and it costs nothing to build.
How a save flows
phone ──► Access (signed in?) ──► Worker: check the JWT
├─ validate with site/lib (same rules as the build)
└─ PUT contents/<post>/index.md with the old sha ──► GitHub commit
└─► Pages rebuilds the site
Setting it up
The code lives in admin/ in the repo: wrangler.jsonc, src/ for the Worker and public/ for the app.
-
GitHub token. Create a fine-grained personal access token at GitHub → Settings → Developer settings → Fine-grained tokens. Give it access to only the one repository, with the permission Contents: Read and write. Note the expiry date: when it lapses, saves start failing.
-
The Worker config (
admin/wrangler.jsonc, trimmed):{ "name": "kb-admin", "main": "src/worker.js", "compatibility_flags": ["nodejs_compat"], "workers_dev": true, // serve on <name>.<account>.workers.dev "preview_urls": false, "assets": { "directory": "./public", "binding": "ASSETS", "run_worker_first": true // every request, static files too, hits the Access check }, "vars": { "GITHUB_REPO": "owner/repo", "GITHUB_BRANCH": "main", "ACCESS_TEAM_DOMAIN": "https://<team>.cloudflareaccess.com", "ACCESS_AUD": "<aud tag>" } }run_worker_firstmatters. Without it, Cloudflare serves files frompublic/before the Worker runs, so they would skip the token check. -
Log wrangler in. In
admin/, runnpx wrangler loginand approve it in the browser. -
First deploy, locked. Run
npx wrangler deploywith the Access values still set to placeholders. The Worker refuses every request until they’re real, so it’s never open, even for a minute. -
Zero Trust. Open Zero Trust in the dashboard, pick a team name and choose the Free plan.
-
Turn on Access for the Worker. Go to Workers & Pages → kb-admin → Settings → Domains & Routes, and enable Cloudflare Access on the workers.dev row. Choose All traffic, not “Previews only”, and the Cloudflare account policy. This creates an Access application whose destination is the Worker itself.
-
Find the AUD tag. Go to Zero Trust → Access controls → Applications, open the new app, and look under Additional settings. Put it and the team domain into
vars, and set a long session duration (a month or so) so the phone doesn’t keep asking you to sign in. -
Redeploy and add the token:
npx wrangler deploy npx wrangler secret put GITHUB_TOKENsecret putprompts for the value. I keep the token in a gitignored.envand pipe it in instead:grep '^GITHUB_KEY=' ../.env | cut -d= -f2- | npx wrangler secret put GITHUB_TOKEN. -
On the phone, open the workers.dev address, sign in, and choose Add to Home screen.
Gotchas
- The AUD tag is hard to find. For an Access app whose destination is a Worker, it isn’t on the
overview. It’s under Additional settings. It’s also in the URL Access redirects you to when
you’re signed out: the
kid=parameter is the AUD. - The Access toggle only appears once the Worker exists, hence the locked first deploy.
- Custom domains need a zone. A CNAME from another DNS provider is enough for Pages, but not for a Worker’s custom domain.
wrangler devchanges the hostname. With a custom-domain route in the config, local requests arrive as if they came to the production hostname. That broke my “skip Access on localhost” check until I ran it with--local-upstream localhost.- Deploys are separate from the site. Pushing to git rebuilds the site but not the admin. After
changing
admin/or the sharedsite/libcode, runnpm run deployinadmin/. - Keep the bundle small. Shiki would pull every highlighting grammar into the Worker, so the markdown setup lives in a module without it, and the preview skips code highlighting.
Local development
npm run dev in admin/ starts the Worker on localhost:8787. A gitignored .dev.vars file holds
DEV_SKIP_ACCESS=1 (honoured only for localhost) and a GITHUB_TOKEN. Saving from local dev commits
for real, so be careful.