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:
astro build— 1,163 pages from the markdown.node scripts/postbuild.mjs— rewrites the notes’ relative.mdlinks into site routes. Without it every cross-link in the prose 404s.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.