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.
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
sudo cloudflared service install # one-time
launchctl print gui/$(id -u)/com.cloudflared >/dev/null && echo "loaded" || echo "not loaded"
If the print check returns “not loaded,” check sudo cloudflared service status. The launchd service is what ck publish --execute kickstarts in step 6; without it, the tunnel will not reload after the rule splice.
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.
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 -sIu $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 placeholder Candlefish chrome at examples/essay-series/public/_system/style.css pulls Berkeley Mono from https://fonts.candlefish.ai/berkeley-mono/{Regular,Bold}.otf. 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 actually 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.