Docs › milestone-01-first-deploy

Milestone 01 — First deploy

Goal: one page live on the domain, deploying automatically on every push. Nothing else. No records, no schemas, no styling. Estimated time: 45–90 minutes, mostly waiting on DNS.

Why this first: every unknown in the project lives in the pipeline (repo → build → DNS → live), not in the content. Once a push reliably becomes a page, everything after is adding files to a machine that already works.


Before you start

On the Mac:

node --version    # need 20 or higher
git --version
gh --version      # optional but makes step 1 much easier

If gh is missing: brew install gh, then gh auth login.

Accounts you need: GitHub, Cloudflare (already have — domain is there).

Decide now: apex or subdomain?

  • lab.yourdomain.io is easier — a single CNAME, no interaction with any existing apex records
  • The apex works fine too, but if the domain currently points anywhere, that changes

Recommendation: use a subdomain for milestone 01. Moving to the apex later is a DNS change, not a rebuild.


Step 1 — Repo

Run this yourself rather than delegating it; it’s the one step where a wrong answer is annoying to unwind.

mkdir -p ~/projects/lab-site && cd ~/projects/lab-site
git init
gh repo create lab-site --private --source=. --remote=origin

Private for now. Flip to public when there’s something to show.

Checkpoint: gh repo view --web opens the repo in a browser.


Step 2 — Scaffold (Claude Code)

From ~/projects/lab-site, start Claude Code and give it this brief:

Set up a minimal Astro site in this directory.

  • Use npm create astro@latest with the empty/minimal template, TypeScript strict, no extra integrations
  • Astro’s default static output — do not add an adapter, do not enable SSR
  • Replace the index page with a single h1 reading “Lab notebook” and one paragraph of placeholder text. No styling beyond browser defaults
  • Add a content/ directory at the project root with a single file content/hello.yaml containing id: hello and title: First record
  • Define an Astro content collection that loads content/*.yaml and validates it with a Zod schema requiring both id and title as strings
  • Render the collection’s entries as a list on the index page
  • Confirm npm run build succeeds and npm run dev serves the page

Do not add a CSS framework, analytics, fonts, or any dependency not required by the above.

The trivial content collection is deliberate. It proves schema validation works end to end on a two-field record, so when the real schemas arrive you’re extending something known-good rather than debugging two things at once.

Checkpoint: npm run dev, open the localhost URL, see the heading and “First record” in a list.

Second checkpoint — prove validation bites:

# temporarily break the record
echo "id: hello" > content/hello.yaml   # removes title
npm run build                            # MUST fail

If that build succeeds, validation isn’t wired up. Fix it before continuing — this is the mechanism that will later stop Warden committing a malformed record. Restore the file afterwards.


Step 3 — Push

cat .gitignore    # confirm node_modules/ and dist/ are listed
git add -A
git commit -m "Initial Astro scaffold with content collection"
git push -u origin main

Checkpoint: files visible on GitHub, node_modules absent.


Step 4 — Cloudflare Pages

Browser, not CLI — the GitHub authorisation flow needs it.

  1. Cloudflare dashboard → Workers & PagesCreatePagesConnect to Git
  2. Authorise GitHub, select lab-site
  3. Build settings:
    • Framework preset: Astro
    • Build command: npm run build
    • Build output directory: dist
    • Root directory: leave blank
  4. Save and Deploy

First build takes a couple of minutes.

Checkpoint: the *.pages.dev URL Cloudflare gives you shows your page.

If the build fails, read the log — it’s nearly always a Node version mismatch. Fix by adding an environment variable NODE_VERSION set to 20 in the Pages project settings.


Step 5 — Custom domain

In the Pages project → Custom domainsSet up a custom domain → enter lab.yourdomain.io.

Because the domain is already on Cloudflare, the DNS record is created for you. Propagation is usually a minute or two, occasionally longer.

Checkpoint: https://lab.yourdomain.io loads over HTTPS with a valid certificate.


Step 6 — Prove the loop

This is the actual milestone. Everything above was setup.

# change one word in the index page
git add -A && git commit -m "Test auto-deploy" && git push

Watch the deployment appear in Cloudflare, then refresh the live URL.

Milestone complete when a push becomes a live change with no manual step.


What you now have

  • A static site — no server, no database, no patching. Cloudflare serves flat files
  • Schema validation at build time. A malformed record fails the build rather than rendering wrong
  • Git as the entire publishing mechanism, which is what makes Warden’s write path viable later
  • Free hosting with no scaling concerns

Deliberately not done yet

Resist adding these now — each one is a separate failure surface, and the point of this milestone is a pipeline you trust.

Not yet Comes with
Styling and layout After the record types render
Real record schemas Milestone 02
Warden write access After schemas are stable
JSON endpoints / MCP Much later
Public repo When there’s something worth showing

Notes for next session

  • Decide apex vs subdomain permanently before backlinks exist
  • Add the redaction convention and a secrets scan to the repo before any real content lands — current working docs contain a Nabu Casa webhook URL, HA token references, an OAuth client ID and tailnet IPs
  • Milestone 02 is the record schemas from lab-site-design.md, built as Astro content collections extending the pattern proven in step 2

Status — 2026-08-03 (Fable + the operator)

Live: https://lab.ezaxis.io (valid cert, 200) · Worker URL https://lab-site.<account>.workers.dev Repo: headbouyJB/lab-site (private, main) · Local: ~/projects/lab-site

Done

  • Step 2 scaffold — Astro 7.1.6, static, no adapter, no extra deps. Content collection via the modern glob loader (src/content.config.ts) over content/*.yaml, Zod schema requiring id + title.
  • Both checkpoints passed. Renders <h1>Lab notebook</h1> + <li>First record</li>. Removing title fails the build with InvalidContentEntryDataError: title: Required and exit code 1 — validation genuinely bites.
  • Step 3 push — 15 files, node_modules/dist correctly ignored.
  • Custom domainlab.ezaxis.io attached, DNS auto-created.

Deviations from the plan as written

  1. Cloudflare Pages no longer exists as the default path. The dashboard now routes to the Workers flow (“Set up your application / Configure your Worker project”). Consequence: there is no “build output directory” field — the asset path must come from a wrangler.jsonc in the repo root, which Pages did not require:
    { "name": "lab-site", "compatibility_date": "2026-08-03", "assets": { "directory": "./dist" } }
  2. Apex was already in use. ezaxis.io resolves to Cloudflare proxy IPs, so the subdomain recommendation became a requirement, not a preference.
  3. NODE_VERSION=22 (not 20) — Astro 7 wants a newer floor.

✅ COMPLETE — 2026-08-06, verified

Milestone 01 is done. A push became a live change with no manual step, in 63 seconds, commit 340eb16. Deliberately tested clean: the commit was pushed and wrangler deploy was NOT run, so the deploy can only have come from the Git integration.

Nothing was fixed on our side. The configuration had been correct since 2026-08-03 — the Cloudflare Workers Builds incident resolved overnight and the queued builds simply ran. Every build since 21:29 on 5 Aug reports buildOutcome: success.

Worth keeping: for three days the evidence said “our setup is broken” when it was right the whole time. The tell that it was provider-side — three consecutive failures with correct config and no log output past line one — was correct, and acting on it (waiting rather than reconfiguring) was the right call. Recorded as inc-0001 in the colophon, deliberately outside the record set.

⚠️ OPEN — the Git integration does not work (resolved above; kept for the diagnosis)

Milestone 01 is NOT complete. Its actual goal — a push becomes a live change with no manual step — is unmet. Deploys are currently manual:

cd ~/projects/lab-site && npm run build && npx wrangler@latest deploy

Symptom: two consecutive Workers Builds (32955384… cancelled, 24a534c4… queued 10 min+) never progressed past a single log line, Initializing build environment..., with commitHash blank on both. Build and deploy commands were correct (npm run build / npx wrangler@latest deploy). Diagnosis: a blank commit hash means Cloudflare never resolved the branch head — i.e. it never established repo access. Not a build failure; a clone failure. Narrowing the GitHub App from “all repositories” to just lab-site did not change it. Next action: disconnect and reconnect the Git integration from scratch rather than retrying builds, watching the GitHub authorisation step specifically. lab-site is private, so Cloudflare’s GitHub App must be granted access to it explicitly.

Also worth knowing

  • Cloudflare MCP servers are now installed in Claude Code (claude plugin install cloudflare@cloudflare, 5 servers + skills). Build status and logs are readable directly — no dashboard screenshotting. The builds server is OAuth-authorised; the others will prompt on first use.
  • A first deploy takes ~1 min to propagate on workers.dev — a 404 immediately after deploying is normal, not a failure.
  • publish/posts/*.md are marked superseded (pre-2026-08-03 drafts, not site content).
  • Still outstanding from the original notes: a redaction convention + secrets scan before real content lands. Working docs contain a Nabu Casa webhook URL, HA token references, an OAuth client ID and tailnet IPs. This matters more once Warden has write access.

✅ CORRECTION 2026-08-03 14:35 UTC — the Git integration IS configured; Cloudflare was down

The “disconnect and reconnect” action above was partly a red herring. Reconnecting did fix a real problem — commitHash went from blank to resolved (2c36b8dd…, “Prove the deploy loop”, commit author resolved correctly), proving Cloudflare can now clone the repo. But the build still never executed.

Root cause: a live Cloudflare incident.

Cloudflare Workers build failuresInvestigating, 2026-08-03 14:20 UTC. “…an issue with Cloudflare Workers build failures which potentially impacts multiple customers.” Workers Builds: Degraded Performance (Workers and Pages themselves: Operational).

Our three builds were created 13:52 / 14:03 / 14:24 UTC — all inside that window. Symptom in every case: accepted, queued, then frozen at Initializing build environment... with zero execution. The build fleet was not picking work up.

Therefore the configuration is COMPLETE. Build command, deploy command, NODE_VERSION=22, repo link and branch are all correct and verified. When the fleet recovers, build 0886c668-aadf-4df8-b741-330759de7a3d should run and lab.ezaxis.io will show “Deployed automatically from a git push.” — with no further input.

Verify recovery later with:

curl -s https://lab.ezaxis.io | grep -c "Deployed automatically"   # 1 = the loop works, milestone 01 complete

If it’s still 0 once the incident is marked resolved, push an empty commit (git commit --allow-empty) to enqueue a fresh build — the stuck one may need replacing rather than resuming.

Lesson worth keeping: three consecutive failures with correct configuration and no log output past the first line is a provider-side signature. Check the status page before re-doing setup — reconnecting cost us time and confused the diagnosis, and only a genuine fix to a second, unrelated problem stopped it being wasted entirely.