deploy.md

Deploying py-prep to Cloudflare Pages

4 min read source

Deploying py-prep to Cloudflare Pages

The site in site/ is a static Astro build of the notes in this repository, deployed by Direct Upload with the Wrangler CLI. There is no Git integration — Cloudflare never builds anything, which means the build is local and must be made immediately before every deploy.

The four values

Value
Pages project py-prep
Production branch main
Build output site/dist
Credentials .cloudflare.env at the repository root — git-ignored
D1 database py-prep-progress (bf0cdc88-f9bc-42a5-a076-e4acd27a0da9)

Live at https://py-prep.pages.dev.

Deploy

cd site
npm ci                                   # first time, or after a dependency change
npm run build                            # astro build -> postbuild -> pagefind

set -a && . ../.cloudflare.env && set +a  # never echo these
npx -y wrangler@latest pages deploy --branch main

npm run build runs three steps and all three matter:

  1. astro build — 1,163 pages from the markdown.
  2. node scripts/postbuild.mjs — rewrites the notes’ relative .md links into site routes. Without it every cross-link in the prose 404s.
  3. pagefind --site dist — builds the search index from the emitted HTML. Search silently returns nothing if this is skipped.

Then check nothing is broken before uploading:

node scripts/verify.mjs      # every internal link resolves to a real page

Verify the deploy

Wrangler reporting success is not proof the live site changed. Check the API, then the apex:

set -a && . ../.cloudflare.env && set +a
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/py-prep" \
  | python -c "import json,sys; d=json.load(sys.stdin)['result']; \
    print(d['canonical_deployment']['id'], d['canonical_deployment']['environment'])"

# Then confirm the apex serves the bundle you just built:
ls dist/_astro/*.css | head -1 | xargs basename
curl -s https://py-prep.pages.dev/ | grep -o '_astro/[A-Za-z0-9._-]*\.css' | head -1

The two hashes must match. If they do not, wait — do not deploy again. A new deployment takes up to a minute to propagate to the apex, and during that window the apex serves the previous build or returns a 522. Redeploying resets the clock and hides whatever the real problem was.

The three things that bite

Direct Upload means the build is local. Environment variables set in the Pages dashboard do nothing here — they only apply when Cloudflare does the build from a connected Git repo. Anything the site needs at build time has to be present when you run npm run build on your own machine.

--branch must equal the production branch. main, exactly. Any other value produces a preview deployment on its own URL while the live site keeps serving the old build — and the CLI reports complete success either way.

Client bundles are public. Everything under dist/ is served to anyone. The D1 credentials are not in the bundle (the Pages Function reads the binding server-side), but do not add anything secret to the site source.

Keep src/pages/404.astro. Without a 404.html in the output, Pages serves index.html with a 200 for every unknown path — so typos, stale links and removed pages all silently render the home page and get indexed as duplicates. The 404 status is only correct because that file exists.

Keep the /* rule in public/_headers. HTML is served max-age=0, must-revalidate. Without it the edge cached pages for over an hour, which meant a deploy was not visible immediately and a deleted page kept returning 200 from cache long after it stopped existing. If a removed route still answers after a deploy, check CF-Cache-Status before assuming the deploy failed.

The sync database

Cross-device sync for bookmarks and read state is a Pages Function at site/functions/api/progress/[key].ts backed by D1. The binding is declared in site/wrangler.jsonc and applied on deploy.

# Schema (idempotent — safe to re-run)
npx -y wrangler@latest d1 execute py-prep-progress --remote --file=schema.sql

# Look at what is stored
npx -y wrangler@latest d1 execute py-prep-progress --remote \
  --command "SELECT key, length(data), datetime(updated_at/1000,'unixepoch') FROM progress"

There is no account system: the sync key is the credential. Anyone holding a key can read and overwrite that row. That is proportionate for a reading list and is stated on the site’s settings page — but it is why you should not paste a key anywhere public.

The stored blob is versioned. v1 held spaced-repetition cards for the drill deck, which has been removed; v2 holds bookmarks. The Function accepts both so a device that has not reloaded the site can still sync, and the client upgrades v1 to v2 on read — carrying read state across and dropping the cards.

Rotating the token

The token in .cloudflare.env needs Account → Cloudflare Pages → Edit and Account → D1 → Edit. Rotate it at dash.cloudflare.com → My Profile → API Tokens. Put the new value in .cloudflare.env only — never in a commit, never in a chat window.

Local development

cd site
npm run dev        # localhost:4321 — no search index, everything else works
npm run preview    # serves dist/ as built, including search
npx wrangler pages dev dist   # adds the Functions and a local D1 for sync

Search is absent under npm run dev by design: Pagefind indexes built HTML, so there is nothing to search until npm run build has run.