Deploying the site
Live: https://theairead.com · public, no login
Hosting is Cloudflare Workers static assets (Workers Builds). The dashboard steers new projects here rather than to classic Pages; functionally it is the same thing for a static site (files on Cloudflare's CDN) but it needs wrangler.jsonc in the repo naming the assets directory, which Pages inferred from a form field.
Chosen over GitHub Pages because it publishes from a private repo on the free tier, includes a free custom domain with SSL, and needs no plan upgrade. The site is public; the repo behind it is not.
Actual configuration
| Field | Value |
|---|---|
| Repo | builtwithclaude4313/the-ai-read |
| Worker name | the-ai-read (must match name in wrangler.jsonc) |
| Domain | theairead.com, attached in the dashboard (see below) |
| Build command | python3 tools/predictions.py check && python3 tools/predictions.py render && python3 tools/build_site.py |
| Deploy command | npx wrangler deploy |
| Path | / |
| Builds for non-production branches | Off |
The build command deliberately runs the ledger check first: a prediction without a checkable resolution criterion fails the deploy. That is intended, not an inconvenience. It is what stops the ledger from quietly degrading into vibes.
No environment variables and no NODE_VERSION are needed. The build image ships Python 3.11, and the generator has zero third-party dependencies.
GitHub Actions runs CI as a guard in front of the deploy: ledger validation, the watch and timeline libraries, the font sheet, the voice check, a build, and a broken-link sweep. Cloudflare runs the real build.
Two settings in wrangler.jsonc worth understanding
"workers_dev": false. A Worker gets a free <name>.<subdomain>.workers.dev hostname unless you turn it off. Leaving it on would mean the same pages answering at two addresses, which splits inbound links and hands search engines two copies of every page to choose between. The custom domain is the address. Nothing here needs a fallback that costs that.
This setting also closed a real hole during the rename. Renaming a Worker does not move its Cloudflare Access policy: it creates a new Worker with a new hostname and no policy at all. While the site was still gated, that would have published an unprotected copy the moment it deployed. The site is public now so the hole is moot, but the reasoning is worth keeping: Worker name is identity, not a label, and nothing attached to the old name follows the new one.
The custom domain is attached in the dashboard, not in this file. An earlier revision declared it as routes: [{ pattern: "theairead.com", custom_domain: true }], so the hostname would be in version control with everything else. That was the better idea and the worse config. Creating a custom domain edits DNS on the zone, the Workers Builds deploy is not guaranteed to hold that permission, and when the call fails wrangler deploy fails with it, leaving no Worker at all. A hostname recorded only in a doc is a smaller problem than a deploy that cannot complete.
Attaching the domain
Do this once. Cloudflare creates the DNS record as part of it, which is why the zone can have zero records beforehand and still work afterwards.
- Workers & Pages →
the-ai-read→ Settings → Domains & Routes → Add → Custom domain. - Enter
theairead.com. Save. - Cloudflare writes an
AAAArecord on the apex pointing at the Worker, proxied, and issues the certificate. Give it a few minutes.
The equivalent path from the other direction is the Connect Worker button on the zone's Overview page, under "No Workers connected". Same result.
If the-ai-read is not in the Workers & Pages list at all, the deploy failed rather than the domain. Open the build log on the project and read the wrangler deploy step.
Migration: The AI Report to The AI Read
Most of this section used to be an ordered dance around Cloudflare Access, so the site was never publicly readable for even a second between steps. The site is public now, so the dance is gone. What remains:
- Rename the GitHub repo to
the-ai-read, and point the Cloudflare build at it. (Done.) - Create the Worker
the-ai-readand connect it to the repo. The old Worker keeps serving at the old URL throughout, so a failure costs nothing. - Push a commit to
main. This is not optional and it is the step that gets skipped. A Worker created through the dashboard wizard is deployed with that wizard's starter script, and connecting a repo afterwards does not retroactively build it: Workers Builds fires on the next push, not on the connection. Until a push happens the Worker holds Hello World,wrangler.jsonchas never been read, and every setting in it is inert, including the build command andworkers_dev.
How to tell from the dashboard: a version that reads Manually deployed / Dashboard came from the wizard. A real one names the commit.
- Attach the domain per the section above, once a build has actually run. (Done.)
- Verify
https://theairead.comloads and the masthead reads The AI Read. (Done: all 98 sitemap URLs return 200.) - Delete the old Worker. Workers & Pages →
the-ai-report→ Settings → Delete. This also removes the oldworkers.devhostname and the Access policy on it, which is what you want: two addresses serving the same pages splits inbound links and gives search engines two copies of everything. - Send
wwwto the apex. (Done. See below.)
One address: www and plain HTTP both fold into the apex
Three pieces of zone configuration, none of them in this repo, because they are DNS and edge settings rather than Worker config. Recorded here so they can be rebuilt.
1. A DNS record for www. Type A, name www, address 192.0.2.1, proxied.
That address looks wrong and is not. It is TEST-NET-1, reserved by RFC 5737, so it can never belong to a real server. The record exists only so the hostname resolves; because it is proxied, requests stop at Cloudflare's edge and the redirect rule below answers them. Nothing ever connects to that IP.
The tempting alternative is a proxied CNAME from www to the apex. Avoid it. That makes www a working second address for the same pages, so if the redirect rule is ever disabled you silently serve duplicate content at two hostnames. With the placeholder the failure mode is a visible error instead of a quiet SEO leak.
2. A Redirect Rule, www to apex. Built from Cloudflare's Redirect from WWW to root template, unmodified:
| Setting | Value |
|---|---|
| Request URL | https://www.* (wildcard pattern) |
| Target URL | https://${1} |
| Status | 301 |
| Preserve query string | unchecked |
The wildcard matches against http.request.full_uri, which includes the query string, so ${1} captures host, path and query in one go and the rule just deletes the www. prefix. Ticking Preserve query string on top of that appends the query a second time. Leave it off.
3. SSL/TLS → Edge Certificates → Always Use HTTPS: On.
This is not cosmetic. Before it was enabled the apex answered plain http:// with a 200, serving the site unencrypted. It also fixes www over plain HTTP: the rule's pattern begins with a literal https://, so an http://www request did not match it and Cloudflare tried to reach the placeholder origin, returning 522. Always Use HTTPS runs first and upgrades the scheme, after which the redirect rule matches.
Verified end to end:
| Entry point | Hops | Final |
|---|---|---|
http://theairead.com/ | 1 | https://theairead.com/ |
http://www.theairead.com/ | 2 | https://theairead.com/ |
https://www.theairead.com/watch?ref=test&x=1 | 1 | same path and query on the apex |
http://www.theairead.com/predictions/2026-08-06-F3?ref=x | 2 | same path and query on the apex |
Every hop is a 301, so the four variants consolidate onto one indexed address.
The domain sends no mail, and says so
SMTP has no sender verification. A mail server believes whatever a sender claims, so without these records anyone can send phishing as @theairead.com and receiving servers have no basis to refuse it. A domain with a public site on it is worth impersonating.
Four DNS records, all on the zone, none proxied:
| Type | Name | Content | Says |
|---|---|---|---|
| TXT | @ | v=spf1 -all | no server is authorized to send |
| TXT | _dmarc | v=DMARC1; p=reject | reject anything that fails |
| TXT | *._domainkey | v=DKIM1; p= | no signing key exists, for any selector |
| MX | @ | . (priority 0) | this domain accepts no mail |
Nothing sends from here, which is what makes -all and p=reject safe. The usual work of enumerating a mail provider's servers does not apply, and the strictest possible policy costs nothing because there is no legitimate mail to block.
SPF alone would be advisory. Most receivers treat an SPF failure as a header note. p=reject is what makes it binding. The DKIM wildcard closes the remaining gap: without it a spoofer can claim a selector that was never published and some receivers handle the absence ambiguously. Verified by querying an invented selector, selector1._domainkey, which answers v=DKIM1; p= off the wildcard.
No rua= reporting address. The spec requires the reporting domain to authorize external destinations, Google does not publish that record for arbitrary domains, so reports to a Gmail address arrive patchily if at all. They would only confirm what the policy already handles.
If mail ever sends from this domain, change these first. A newsletter sent under v=spf1 -all and p=reject is rejected by every receiver that checks, and the bounces look like a deliverability failure rather than a config one.
The site is public
It was gated by Cloudflare Access with a one-time email PIN. That made sense while it lived at a guessable workers.dev URL with no name on it: not because the contents were secret, but because an unfinished thing with a public address gets found. It has a name now, and a news site nobody can read is not a news site.
So there is no Access application, no workers_dev subdomain, and no auth code in this repo. Being public also changed what the pages need to carry:
- The
noindex,nofollowmeta is gone. It was correct for a private site and would have silently defeated the point of a public one. - A canonical URL on every page, plus Open Graph and Twitter card tags, so a shared link renders as something other than a bare URL.
robots.txtandsitemap.xml, generated at build time. Every page is listed rather than sampled: the archive is the product, and a crawler that only followed links from the home page would miss every prediction page and every timeline event, which is most of the site.
There is no og:image yet, so link previews will show text without a picture.
An earlier revision put HTTP Basic Auth in a worker.js in front of the assets. It was removed once Access was working, and Access is gone now too. If a SITE_PASSWORD secret is still set on the Worker, it is inert and can be deleted along with the Worker itself.
Local preview
python3 tools/build_site.py --serve # http://localhost:8000
Troubleshooting
| Symptom | Cause |
|---|---|
Build fails on predictions.py check | A prediction is missing a field or has a criterion too vague to check. The error names the id. Fix data/predictions.jsonl. |
Build fails on voice.py | Prose carrying an em dash or another hard tell. The error names the file and the line. See VOICE.md. |
| Build fails creating the custom domain | Usually an existing DNS record on the apex: a parked page or an old A record. Delete it in DNS → Records and redeploy. |
| Deploy succeeds, page 404s | Check the assets directory is site, not /site or ./site. |
| CI red but Cloudflare green | CI also checks that PREDICTIONS.md was regenerated after a ledger edit. Run python3 tools/predictions.py render and commit. |
| A page is missing from search | Check it is in sitemap.xml. Every built page except 404.html should be. |
Why not GitHub Pages
It was the first choice and the workflow is in git history. It fails on this repo: Pages for private repos requires GitHub Pro or higher, and the deploy returns 404 – Ensure GitHub Pages has been enabled. Making the repo public would have fixed it.
The repo stays private, and the site no longer links into it. For a while the generator rewrote links to unpublished repo files into GitHub URLs so nothing 404d inside the site. With a private repo every one of those 404d for readers instead, and because GitHub hides private repos rather than admitting they exist, they read as broken links rather than locked doors.
The argument for going public was that a prediction ledger nobody can inspect is a set of assertions. That argument was already satisfied: data/predictions.jsonl ships with the site, so the raw ledger is readable at /data/predictions.jsonl on this domain. The GitHub link was pointing away from a better copy. PUBLISHED_DATA in build_site.py now carries the ledger, the entity list, the watch library and the timeline for the same reason, and rewrite_links() sends those to our own copies and renders references to source code as plain text.
Nothing else about the repo belongs to readers. It is a publication, not an open-source project: no contributors to attract, no issues to triage, and the generator is the work product.