bla.ckmo.re

Tech · Note

A phone admin on Cloudflare Workers

27 Oct 2026

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 main as “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.

  1. 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.

  2. 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_first matters. Without it, Cloudflare serves files from public/ before the Worker runs, so they would skip the token check.

  3. Log wrangler in. In admin/, run npx wrangler login and approve it in the browser.

  4. First deploy, locked. Run npx wrangler deploy with 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.

  5. Zero Trust. Open Zero Trust in the dashboard, pick a team name and choose the Free plan.

  6. 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.

  7. 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.

  8. Redeploy and add the token:

    npx wrangler deploy
    npx wrangler secret put GITHUB_TOKEN

    secret put prompts for the value. I keep the token in a gitignored .env and pipe it in instead: grep '^GITHUB_KEY=' ../.env | cut -d= -f2- | npx wrangler secret put GITHUB_TOKEN.

  9. 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 dev changes 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 shared site/lib code, run npm run deploy in admin/.
  • 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.

Related