Remote access
By default the server only listens on localhost:3333. To reach it from another device or over the internet, you need a tunnel. The setup script (scripts/remote-setup.sh) automates both options below.
./scripts/remote-setup.sh tailscale # or: cloudflareTailscale (recommended)
Tailscale creates a private mesh network between your own devices. No domain, no public exposure, real HTTPS via Let’s Encrypt (required for PWA install and Web Push).
./scripts/remote-setup.sh tailscaleThe script installs Tailscale, authenticates, and configures tailscale serve to forward HTTPS to localhost:3333. Your hub becomes available at:
https://<machine-name>.<tailnet>.ts.netOne manual step: in the Tailscale admin panel, enable HTTPS certificates for your tailnet. Without this, tailscale serve uses a self-signed cert that browsers reject.
Cloudflare Tunnel
For public access under your own domain (free Cloudflare account, domain ~1 €/year).
Install cloudflared
brew install cloudflaredCreate a tunnel
cloudflared tunnel logincloudflared tunnel create penatesThe second command prints a tunnel ID (format: abc123de-...). Keep it: you need it in the next steps.
Route DNS
cloudflared tunnel route dns penates code.yourdomain.xyzWrite the config file
mkdir -p ~/.cloudflarednano ~/.cloudflared/config.ymlPaste the following, replacing TUNNEL-ID, YOUR-USERNAME, and yourdomain.xyz:
tunnel: TUNNEL-IDcredentials-file: /Users/YOUR-USERNAME/.cloudflared/TUNNEL-ID.json
ingress: - hostname: code.yourdomain.xyz service: http://localhost:3333 - service: http_status:404Save with Ctrl+O, Enter, Ctrl+X.
Install as a LaunchAgent
cloudflared service installlaunchctl start com.cloudflare.cloudflaredThe hub is now reachable at https://code.yourdomain.xyz. The tunnel starts automatically after reboot.
Cloudflare Access (Zero Trust hardening)
The Cloudflare Tunnel alone leaves only the bearer token as the auth layer. Cloudflare Access adds an identity gate: every browser visit is redirected to a Cloudflare login (GitHub, Google, one-time email PIN, or others) before it can touch the hub. The hub then validates the resulting JWT. Requests that originate on the Mac itself (Claude Code hooks, API calls) bypass the tunnel and continue to authenticate with the bearer token only.
Prerequisites
- Cloudflare Tunnel is already running (see above).
- Zero Trust is activated on your Cloudflare account (free for personal use; dash.cloudflare.com/zero-trust).
Create an Access Application
- Open the Zero Trust dashboard → Access → Applications → Add an application → Self-hosted.
- Set Application name to
Penates(or anything you like). - Set Session duration to
24h(or longer). - Set Application domain to your tunnel domain (e.g.
code.yourdomain.xyz). - Under Identity providers, enable at least one:
- GitHub OAuth: one click if you already use GitHub.
- One-Time PIN: sends a code to your email; no OAuth app needed.
- Create a Policy: Action =
Allow, Include = one or both of:Emails→ your email address (for the PIN flow)GitHub→ your GitHub username (for GitHub OAuth)
- Save the application.
- On the application overview, copy the Application Audience (AUD) tag (a 64-character hex string).
Configure the hub
Edit ~/penates/.env and add both variables:
CF_ACCESS_TEAM_DOMAIN=yourteam.cloudflareaccess.comCF_ACCESS_AUD=3c994b6913e0ee914f118337173aabdaa7a54a7c82f98e6f2b93b57fa7078db5CF_ACCESS_TEAM_DOMAIN is the team URL shown in the top-left of the Zero Trust dashboard (without https://). CF_ACCESS_AUD is the tag from step 8.
Restart the server:
launchctl kickstart -k gui/$(id -u)/com.penatesVerify
Navigate to https://code.yourdomain.xyz in a browser. You should be redirected to a Cloudflare login page. After authenticating, you land on the hub dashboard.
Check the audit log:
tail -1 ~/.penates/audit.logYou should see an auth.login entry with your email address.
Troubleshoot 401 errors
tail -20 ~/.penates/audit.log | grep auth.fail | jq -creason | Fix |
|---|---|
bad-jwt:no-jwt | Browser did not go through Cloudflare Access. Clear cookies for the domain and reload; the login flow should restart. |
bad-jwt:bad-aud | CF_ACCESS_AUD in .env does not match the AUD tag on the Access Application. Copy it again from the dashboard. |
bad-jwt:bad-iss | CF_ACCESS_TEAM_DOMAIN is wrong. Must be the team URL without https://. |
bad-jwt:expired | JWT has expired. Increase the Session Duration in the Access Application settings. |
bad-bearer | Bearer token in the browser does not match AUTH_TOKEN in .env. Clear it: localStorage.removeItem('penates_token') in DevTools, then reload. |
Disable Cloudflare Access
Remove both variables from .env (or set them to empty strings) and restart the server. No code changes needed; the feature is entirely env-gated.