I put the Mac mini behind Cloudflare and now typing feels wet

Last time I got the fleet boring on purpose. Same flake, same CLI verbs, same shell wherever I sat down. That fixed the PATH archaeology. It did not fix the café problem.

The mini is the one that stays on. Claude, Codex, Grok, already parked in zellij under Ghostty at home. The laptop is the one that leaves. I wanted those sessions from somewhere else without treating my router like a public API.

So I drew a hard box. Skip the VPN. Route the path through Cloudflare, because the sites already live there. SSH as the door: shell, attach to zellij, move files, Screen Sharing over a local forward later if I needed a mouse.

The garden deploys as static Workers. Remote access had to sit next to that stack without touching wrangler.toml. One hostname, one tunnel, one Access app, then SSH keys the way they always work. Friends and a custom mobile app stayed on the wishlist. Half the work lived in the Cloudflare dashboard. The other half lived in nix.

I already had Cloudflare for the sites. Remote access meant opening Zero Trust and treating the mini like another application.

Identity first, and it is a two-dashboard job. Cloudflare Access wants GitHub as a login method. GitHub wants an OAuth App (the developer settings kind: homepage URL, callback URL, client ID, client secret). You create that app under the GitHub account or org you actually use, paste Cloudflare's callback URL into it, then paste the client ID and secret back into Zero Trust under Authentication. Leave MFA on the GitHub account itself. Policy on the Cloudflare side stays narrow: my GitHub identity only, no bypass, no service token I would paste into a chat. Session length around eight hours so a workday does not re-auth every coffee, but the next morning still knocks.

Then a self-hosted Access application on the subdomain I chose for SSH. That app is the bouncer. It does not replace the SSH key on the mini. It only decides whether Cloudflare will hand the connection to the tunnel. The first real ssh still opens a browser, hits that GitHub OAuth app, and only then continues.

The tunnel is a named Cloudflare Tunnel for the mini. Create it, download the credentials JSON, run cloudflared tunnel route dns (or set the same CNAME in the DNS UI) so the public name points at the tunnel, not the living-room router.

Somewhere in that flow Cloudflare waves a finish-setup URL with a long token. It looks bookmarkable. It is a one-shot enrollment link. I almost saved it into notes. That would have been a fun secret to leak.

When Access is happy and the connector shows healthy edges, the dashboard work is mostly done. The rest is whether the mini runs cloudflared and sshd without babysitting launchd.

This is where nixup stopped being background lore.

Copy the credential to /var/lib/cloudflared/ as root 0600. We argued about the UUID. First draft almost baked it into a shared Darwin module. Final split matches nixup hosts: tracked modules/darwin/cloudflare-remote-access.nix enables SSH, writes /etc/cloudflared/config.yml, and runs the tunnel under launchd. The gitignored personal host module only supplies tunnelId, hostname, and authorized keys.

Ingress stays tiny:

ingress:
  - hostname: <access-subdomain>
    service: ssh://127.0.0.1:22
  - service: http_status:404

Clients get cloudflared, a key per laptop, and ProxyCommand cloudflared access ssh --hostname %h. First connect opens a browser, the GitHub OAuth app consent screen, then Access, then the host key prompt. id -un prints my username. After two dashboards and a consent screen, success felt almost unfair.

Outside the house, the laptop opens a real shell on the mini. Tunnel healthy, launchd keeps it, home IP changes stop mattering. Zellij attaches over that hop. Files ride SFTP or rsync -e ssh.

Then I typed for real. Every character does a full trip through Cloudflare and back for the echo. Sticky on a good path. Paste-and-pray on mediocre mobile. Access is mostly connect-time (the browser dance you configured in Zero Trust). Typing lag is path length and remote echo.

Local comedy: Nushell ate PasswordAuthentication=n. Module boundaries took longer than the YAML. I almost committed private host values, walked it back. nixup hosts sync --force can still erase a hand-edited host file. Screen Sharing stays off Cloudflare: forward 5901 to loopback 5900 only.

Test latency from a hotspot before calling it finished. Throughput lies. Typing tells the truth. Access plus tunnel is a locked door with audit logs and GitHub MFA. It is a weak daily keyboard. Keep it for break-glass on the same account as the sites.

WARP is Cloudflare's always-on client. We skipped it for plain OpenSSH. Tailscale (or WireGuard) is the honest fix for a keyboard that feels local when a direct path works. Cost is another identity plane.

Where I am leaning: dashboard Access as break-glass, mesh VPN for daily shells. nixup made the machines interchangeable. Zero Trust clicks plus a tiny nix module made the mini reachable. Next bet is a keyboard that does not swim, still near Loki.