ck publish end-to-end. How the surface comes up, where it can fail, and how to fix each failure mode. Read this once before your first publish; come back to it when ck publish --diagnose shows a FAIL row.
What ck publish does
ck publish is a single command with three modes — dry-run (default), --execute, and --diagnose.
Dry-run mode (default)
ck publish
Renders the four deploy templates into the project’s .deploy/ directory with token replacement, prints a publish plan, and exits 0. Touches no system state. Always run dry-run first.
The four files rendered are:
| Path | Purpose |
|---|---|
.deploy/serve.py | Static-HTML FastAPI server with Basic Auth and /_system/<path>. Used by projects that compile content to HTML at build time (architecture-style chapter sites). Projects that render content live from MDX (essay-series) ship their own server and ignore this file. |
.deploy/run-service.sh | Wrapper that sources ~/.config/<project>.env and execs the server under uv run. |
.deploy/launchd/com.highline.<slug>.plist | The launchd plist that brings the service up at login and respawns it on crash. |
.deploy/cloudflared/<slug>-rule.yml | The single ingress rule the cloudflared config needs. |
Execute mode
ck publish --execute
Runs the full pipeline:
- Renders the four files (same as dry-run).
- Copies the plist to
~/Library/LaunchAgents/com.highline.<slug>.plist. launchctl bootstrap gui/$(id -u) <plist>.launchctl kickstart -k gui/$(id -u)/com.highline.<slug>.- Splices the cloudflared rule into
~/.cloudflared/config.ymlabove the catch-all 404 service. (If a rule for the surface already exists, it is left untouched and step 6 is a no-op.) launchctl kickstart -k gui/$(id -u)/com.cloudflaredto reload the tunnel.- Polls
https://<surface>/healthonce every 3 seconds for up to 90 seconds. Exits 1 with diagnostics if the surface does not return 200 within the window. - Appends a publish row to
docs/agent-runtime/ledger.md.
Diagnose mode
ck publish --diagnose
Runs the four standard checks (DNS, cloudflared launchd state, auth-pair env file, surface health) without writing anything. Use this when something is broken; the four-check output narrows the problem to a single sub-system.
Pre-flight (one-time per host)
Before the first ck publish --execute on a new machine:
1. cloudflared tunnel exists
cloudflared tunnel list
If empty, create one:
cloudflared tunnel login # browser auth, one-time
cloudflared tunnel create highline-creator # name is conventional; pick anything
This writes ~/.cloudflared/<tunnel-id>.json (the credential) and prints the tunnel UUID.
Required prereq for cloudflared tunnel login: you must already be a member of the Cloudflare account that owns the publishing zone (highline.work for the canonical kit setup) with DNS-edit + Tunnel access. The tunnel login browser flow opens a zone picker; if it shows zero zones, you do not have access yet. Ping patrick@candlefish.ai with the email tied to your Cloudflare account; he sends an invite from the highline.work account dashboard. The “DNS — Edit” plus “Cloudflare Tunnel — Edit” role pair is the minimum scope; “Super Administrator — Read” works too. Without account membership you cannot complete tunnel login, cannot run tunnel create, and the rest of this runbook does not apply.
2. Tunnel config exists
If ~/.cloudflared/config.yml does not exist:
cat > ~/.cloudflared/config.yml <<EOF
tunnel: <tunnel-uuid-from-step-1>
credentials-file: $HOME/.cloudflared/<tunnel-uuid>.json
ingress:
# ck publish splices per-project rules above this catch-all.
- service: http_status:404
EOF
3. cloudflared as a launchd service
Don’t run sudo cloudflared service install. That creates a system daemon at /Library/LaunchDaemons/com.cloudflare.cloudflared.plist running as root, with HOME=/var/root. It cannot read /Users/<your-name>/.cloudflared/config.yml — the daemon comes up with no config and silently does nothing while the runbook keeps walking. Worse, the system daemon’s launchd label is com.cloudflare.cloudflared (system domain), which is invisible to the kit’s gui-domain kickstart calls. (max-agent’s pilot bug #6 documented this end-to-end.)
The kit handles the cloudflared launchd plist for you. On first ck publish --execute, ck-publish installs a per-user gui-domain plist labeled com.highline.cloudflared that runs cloudflared tunnel --config ~/.cloudflared/config.yml run as the current user — same domain as the project’s own service plist, same user-readable config, kickstartable from gui without sudo.
Verify after the first publish:
launchctl print gui/$(id -u)/com.highline.cloudflared >/dev/null && echo loaded || echo "will be installed by ck publish --execute"
If you have a pre-existing system/com.cloudflare.cloudflared (legacy sudo cloudflared service install path), it can co-exist with the per-user plist — the per-user one wins because it’s the only one with a readable config — but you can remove it cleanly with:
sudo launchctl bootout system/com.cloudflare.cloudflared
sudo rm /Library/LaunchDaemons/com.cloudflare.cloudflared.plist
ck publish --diagnose’s “cloudflared tunnel state” check looks for both labels and warns if the legacy system daemon is still present.
4. DNS routing for the surface
Each new surface needs a one-time DNS record on the tunnel:
cloudflared tunnel route dns highline-creator <surface> # e.g. tyler-essays.highline.work
ck publish --execute does not run this step automatically because it is a destructive change to the cloudflare zone (creates a CNAME). Do it once before the first publish for a given surface.
Paved road for invited collaborators: provisioned tunnels via API + Infisical (preferred)
The browser-driven cloudflared tunnel login path above works when the creator is a Cloudflare account admin (Super Administrator). For everyone else — invited collaborators, the next forty creators — the kit ships a server-side path that requires zero dashboard access: an admin provisions the tunnel via the Cloudflare API once, and the creator pulls credentials from Infisical and runs the tunnel locally.
Admin side, one command per creator (about 10 seconds)
Canonical provisioning host: macstudio (Tailscale 100.68.75.122). Run admin operations there, not from a laptop, so cert + creds + Infisical session persist across laptop sleep cycles. From any tailnet-connected machine: ssh macstudio 'cd ~/Work/creator-kit-fresh && ./bin/ck tunnel-provision <slug> ...'. Laptop fallback works in a pinch but the cert.pem on the laptop expires faster (cloudflared re-auths every few months) and credentials JSON ends up scattered.
Pre-requisites on the admin’s machine (macstudio):
cloudflaredinstalled +~/.cloudflared/cert.pempopulated by a one-timecloudflared tunnel login(already in place on macstudio as of 2026-05-04)$CLOUDFLARE_API_TOKENfor the DNS-CNAME step (Zone.DNS:Editscope on the target zone). The tunnel-create step uses cert.pem auth, NOT the API token, so the token only needs zone-DNS scope.$CLOUDFLARE_ACCOUNT_IDset to the target account UUID (eb3f284eb2fc05d0c63b9548f575e3e4for Patrick’shighline.workaccount)infisicalCLI authenticated,$INFISICAL_PROJECT_IDset (envdevworks on macstudio without re-login)
Run:
ck tunnel-provision <project-slug> --surface <fqdn> --zone <zone-id>
# e.g.
ck tunnel-provision max-essays --surface max-essays.highline.work --zone fc8e05752e843d9a45edd03ba54d6924
What it does:
POST /accounts/{account_id}/cfd_tunnelwith name<slug>-tunneland a freshly-generated 32-byte tunnel secret.- Stashes three keys in Infisical (env=
devby default; pass--envto override):CK_TUNNEL_<SLUG_UPPER>_ID,CK_TUNNEL_<SLUG_UPPER>_CREDS(base64-encoded credentials JSON),CK_TUNNEL_<SLUG_UPPER>_SURFACE. - If
--surfaceand--zoneare both supplied: creates the DNS CNAME<surface> → <tunnel-id>.cfargotunnel.com(proxied: true, ttl: 1). - Prints the bootstrap command the creator runs next.
The script is idempotent: re-running with the same slug refuses to clobber an existing tunnel (Cloudflare cannot re-issue creds; rotation is delete + re-create, documented in the warn output).
Creator side, one command total (about 5 seconds)
Pre-requisites on the creator’s machine:
infisicalCLI authenticated to theHighlineorganization (slughighline-nhq-6, idfade147b-bb84-4a3a-80b0-e218107b0706). Patrick’s Infisical login ispatrick.smith@gmail.com(NOTpatrick@candlefish.ai— different identities). When the OAuth org picker shows multiple “candlefish”-named orgs, none of them is the right one — the project lives under theHighlineorg. tyler-agent’s pilot 2026-05-04 surfaced this trap.$INFISICAL_PROJECT_IDset to304164c8-e792-4134-ad25-edf43a6e0e49(theai-platformproject in the Highline org)- cloudflared installed (the kit’s
bootstrap.shhandles this) - If the Highline Infisical org has hit member-limit on its plan (rejecting your invite), see “Air-gapped path: —env-file” below.
Run, before the first ck publish:
ck tunnel-bootstrap <project-slug>
# e.g.
ck tunnel-bootstrap max-essays
What it does:
- Pulls
CK_TUNNEL_<SLUG_UPPER>_{ID,CREDS,SURFACE}from Infisical. - base64-decodes
CREDSinto~/.cloudflared/<tunnel-id>.json(mode 600). - Writes a fresh
~/.cloudflared/config.ymlpointing at this tunnel + a catch-all 404. (If a config already exists pointing at a different tunnel, prints a guarded warning instead of clobbering — creators with their own multi-tunnel configs are not surprised.)
After bootstrap, the creator runs cd ~/Work/<slug> && ck publish --execute and the rest of the runbook works without the creator ever touching the Cloudflare dashboard.
Air-gapped path: --env-file <path>
When the creator can’t authenticate to the Infisical org (member-limit hit, no membership granted, deliberately air-gapped machine), the admin can ship the three keys via a flat env file the creator passes to ck tunnel-bootstrap --env-file:
Admin generates the file (after ck tunnel-provision, on whichever machine has the credentials JSON):
TUNNEL_ID=$(infisical secrets get CK_TUNNEL_<SLUG_UPPER>_ID --env=dev --plain)
CREDS_B64=$(base64 -i ~/.cloudflared/${TUNNEL_ID}.json | tr -d '\n')
SURFACE=<surface-fqdn>
cat > ~/.tmp/<slug>-bootstrap.env <<EOF
CK_TUNNEL_<SLUG_UPPER>_ID=${TUNNEL_ID}
CK_TUNNEL_<SLUG_UPPER>_CREDS=${CREDS_B64}
CK_TUNNEL_<SLUG_UPPER>_SURFACE=${SURFACE}
EOF
chmod 600 ~/.tmp/<slug>-bootstrap.env
Admin transports via AirDrop, Signal, 1Password Secure Note, or any other channel where the file is encrypted in transit and recipient-only at rest. Don’t paste the plaintext contents into a GitHub issue / Slack channel / email.
Last-resort transport: age encrypted to the recipient’s GitHub-published SSH key. When AirDrop and Signal aren’t available and 1Password access can’t be granted in time, the admin can encrypt the env file to the recipient’s https://github.com/<handle>.keys (ed25519) public key via age and post the armored ciphertext into a private-repo issue. Two real caveats — this is a workaround, not the paved road:
- No sender authentication. age encrypts to a recipient but does not sign by a sender. Any repo collaborator with the recipient’s public key (it is public) could edit the comment to substitute a different blob also encrypted to the recipient — recipient decrypts successfully and gets attacker-chosen creds. Mitigation: post an out-of-band sha256 of the ciphertext (in a different channel from the blob itself) before the recipient decrypts.
- Ciphertext persists past consumption. The “delete after consumption” property of the AirDrop/Signal path is impossible when the ciphertext lives in issue history forever. If the recipient’s ed25519 private key ever leaks, all historical age blobs become decryptable retroactively. Mitigation: delete the comment containing the ciphertext once the recipient has decrypted + bootstrapped, AND treat tunnel creds as scoped + revocable (which they already are —
cloudflared tunnel deleterotates the credentials wholesale).
Use the standard transports (AirDrop, Signal, 1Password) when they are available. age-in-issue is the documented escape hatch for “admin and creator are not co-located AND no shared encrypted channel exists” — Tyler’s pilot 2026-05-04 used it once after the Highline Infisical org plan-limit blocked direct grant; aaron-agent’s security read named both caveats above before the pattern got reused.
Creator runs:
chmod 600 ~/Downloads/<slug>-bootstrap.env
ck tunnel-bootstrap <slug> --env-file ~/Downloads/<slug>-bootstrap.env
rm ~/Downloads/<slug>-bootstrap.env # delete after consumption
Same downstream behaviour as the Infisical path — drops creds at ~/.cloudflared/<tunnel-id>.json, writes config.yml, validates JSON shape. The tunnel is identical regardless of which transport delivered the credentials.
When this path is wrong
Stick with the browser cloudflared tunnel login path above when:
- The creator owns their own Cloudflare account (no Infisical sharing needed).
- The creator wants to manage multiple tunnels themselves (the bootstrap script intentionally won’t clobber an existing config).
- The admin doesn’t have an API token with
Cloudflare Tunnel:Editscope (the dashboard-driven path is the fallback in that case).
Lineage
This path closes max-agent’s pilot bug #5 (issue #2). The need surfaced when max-agent walked the runbook on a fresh account: cloudflared tunnel login opened to an empty zone picker (membership pending), then after accept the dashboard’s tunnel-authorize page failed permission checks one at a time (Cloudflare One Networks Write, then Load Balancing: Monitors and Pools Write), each requiring a different role grant, each requiring an admin in the loop. The API path skips the entire whack-a-mole — least-privilege per creator, no dashboard access needed.
Auth-pair env file
The Basic Auth credentials live at ~/.config/<project-slug>.env, mode 600, never committed.
The first-hour runbook generates this file with python3 -c 'import secrets; print(secrets.token_urlsafe(24))'. The format is:
export ARCH_USER=tyler
export ARCH_PASSWORD=...
export ARCH_PORT=18210
run-service.sh sources this file before exec’ing the server. If any of the three keys is missing, ck publish aborts with a remediation message before touching launchd or cloudflared.
To rotate the password without taking the surface down:
PROJECT_SLUG=tyler-essays
NEW_PASS=$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')
sed -i.bak -E "s|^export ARCH_PASSWORD=.*|export ARCH_PASSWORD=$NEW_PASS|" ~/.config/$PROJECT_SLUG.env
rm ~/.config/$PROJECT_SLUG.env.bak
chmod 600 ~/.config/$PROJECT_SLUG.env
launchctl kickstart -k gui/$(id -u)/com.highline.$PROJECT_SLUG
curl -s -o /dev/null -w '%{http_code}\n' -u $USER:$NEW_PASS https://$PROJECT_SLUG.highline.work/ # 200 expected
The full rotation runbook (including tunnel-credential rotation) is at ~/Work/creator-kit/deploy/cloudflared/CREDENTIAL_ROTATION.md.
fonts.candlefish.ai allowlist
The design layer’s tokens.css (examples/<template>/public/_system/tokens.css) declares Geist and Berkeley Mono from https://fonts.candlefish.ai/ as woff2. That worker is CORS-gated: it only serves the font to origins on its allowlist.
Before the first publish from a new surface:
- Confirm the font URL works open-net:
curl -sI https://fonts.candlefish.ai/berkeley-mono/Regular.otfand expectHTTP/2 200. If it does not, the worker is down (escalate to the Candlefish admin) andck doctorflags it. - Confirm the surface’s origin is on the allowlist. If not, the page loads with the system monospace fallback (Menlo or SFMono-Regular). The page works, it just does not look right.
- To add the surface to the allowlist: edit
~/Work/builders-warehouse/infra/fonts-worker/wrangler.toml, add<surface>to theALLOWED_ORIGINSarray, and re-deploy the worker (cd infra/fonts-worker && wrangler deploy). This requires Cloudflare account access; coordinate with the org admin if you do not have it.
The full vendored Candlefish Design System landed in M2 W6 at design/_system/, but the Berkeley Mono @font-face rules in _system.css still source from fonts.candlefish.ai at runtime. The licensed font binary cannot be redistributed in the kit. The CORS-gated worker stays in the loop.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
ck publish --execute exits 1 with “did not return 200 within 90s” | DNS not routed (cloudflared tunnel route dns was never run for this surface), or tunnel did not reload | Run ck publish --diagnose. If DNS check FAILs: cloudflared tunnel route dns <tunnel> <surface>. If tunnel-state check FAILs: launchctl kickstart -k gui/$(id -u)/com.cloudflared. Then ck publish --execute again — it is idempotent. |
[FAIL] auth pair: ~/.config/<slug>.env missing one of ARCH_USER, ARCH_PASSWORD, ARCH_PORT | Env file partially written | Re-run the env-file step from the first-hour runbook. The full file template is in deploy/launchd/com.highline.PROJECT_SLUG.plist.tmpl (top-comment block). |
| Surface returns 401 even with the right password | Trailing whitespace in ARCH_PASSWORD (common when copy-pasting from a password manager) | bash -c 'echo "[$ARCH_PASSWORD]"' < <(. ~/.config/<slug>.env) — the brackets reveal trailing spaces. Re-write the env file. |
| Surface returns 502 from cloudflared | The local server is not running. Either the launchd plist failed to bootstrap, or the server crashed. | tail /tmp/<slug>.err for crash logs. launchctl print gui/$(id -u)/com.highline.<slug> for service state. If the plist itself is malformed, plutil -lint .deploy/launchd/com.highline.<slug>.plist. |
Surface returns 200 on /health but 404 on / | The server is up but the project’s content is missing. For essay-series projects, content/essays/*.mdx must exist. For static-HTML projects, index.html must exist at the project root. | Add content. The 404 message includes a list of pages the server saw at startup; cross-check. |
| Page loads with system monospace instead of Berkeley Mono | Surface origin not on the fonts.candlefish.ai allowlist | See the fonts.candlefish.ai allowlist section above. Page is functional in the meantime. |
ck publish runs cleanly but ck validate --live FAILs on live.routes | The kit’s live-routes contract (docs/contracts/live-routes.json) names a route the project does not serve, or the contract is out of date | Either fix the route or update the contract. The contract is authoritative; the surface should match it, not the other way around. |
When ck publish is the wrong tool
ck publish writes to launchd and ~/.cloudflared/config.yml. That is appropriate when the surface lives on a single Mac (laptop or Mac Studio) and the creator owns the cloudflared tunnel.
It is the wrong tool when:
- The surface needs to live on a Linux box (no launchd). Use systemd instead; the rendered
.deploy/serve.pyplusrun-service.share reusable; the plist is not. - The surface needs to deploy to Cloudflare Pages or Workers. The kit’s deploy templates are for self-hosted launchd-managed services. Cloudflare Pages projects use a different
wrangler.toml-based flow. This site (creator.candlefish.ai) is the first kit-shipped Pages deployment and lives atdocs/site/. - The surface needs HA (more than one host). The launchd plist binds to a single host; cloudflared can route to multiple origins but the kit does not template for it. The v1 ship test does not require HA.
See also
- First-hour runbook — the end-to-end first-publish path. This page is the deep-dive that runbook glosses over.
~/Work/creator-kit/deploy/cloudflared/CREDENTIAL_ROTATION.md— quarterly rotation runbook.bin/ck-publish --help— the man-page-style help text.