Launch checklist
Plan §9's four steps, in order, because the order is the point. Every item here is something that is either free to get right now or expensive to fix later, and the expensive ones are all in steps 1 and 2: DNS that was incomplete when the first email went out, and a site that indexed under a throwaway hostname.
deploy/CHECKLIST.md is the platform's rendered version of the same ground and covers the mechanics of each job. This document is Circuit's, and it is the one that fixes the sequence. Work top to bottom. Tick in a commit.
Site: circuit · host: <circuit-domain> · env prefix: CIRCUIT_
Step 1 — Register the domain and onboard it onto the standard stack
Before any build work that produces a URL. The domain-onboarding checklist that covers every site in this portfolio is the procedure; this is the Circuit-shaped version of it.
- ☐ Register
<circuit-domain>. Checked available 12 August 2026. Registerwwwas a redirect only, not a second site. - ☐ Cloudflare: add the zone, move the nameservers, set SSL to Full (strict). Leave the proxy on for the apex and
www, off for anything that has to answer on its own IP. - ☐ Microsoft 365 mailbox for
hello@<circuit-domain>, plus the aliases the site publishes (privacy@, andremovals@if used). A forwarding alias is not good enough: replies to an Article 14 notice are subject access requests and objections, and they arrive with a legal clock on them. - ☐ Google Search Console: add as a domain property (DNS TXT), not a URL-prefix property. The domain property covers the apex,
wwwand every subdomain at once, which matters here because there are three sending subdomains. - ☐ Bing Webmaster Tools: add the site, import from Search Console if offered, then submit the sitemap explicitly anyway — the import is not always complete.
- ☐ Meta-monitor: add
circuitto the monitor Worker's site list with the canonical URLhttps://<circuit-domain>, before the first outage rather than after it. Confirm it reaches the health endpoint, that it alarms on refinery staleness, and that the certificate-expiry check covers every hostname Caddy serves including the redirect ones. - ☐ Note the date all of this was done. Index coverage before it is not a signal about anything.
Step 2 — DNS complete on day one
All of it, now, including the records that only matter once mail starts flowing. Half a mail setup fails silently: the send succeeds, the message is filed as spam, and the claim-rate gate reads as a product failure three weeks later when nobody can remember what changed.
2a. The zone
- ☐
Afor<circuit-domain>→ the droplet IP. - ☐
AorCNAMEforwww→ same, and add it toCIRCUIT_REDIRECT_HOSTSso it 301s rather than serving a second copy. - ☐
TXTverification records for M365 and Search Console.
2b. Mail: three domains, three jobs
This is the part that is specific to this site, and it is not optional.
| Domain | Carries | Provider |
|---|---|---|
<circuit-domain> | human mail to and from hello@ | Microsoft 365 |
mail.<circuit-domain> | transactional only: magic links, claim confirmations, replies to forms | Cloudflare Email Sending |
notices.<circuit-domain> | the Article 14 notice campaign | Cloudflare Email Sending |
outreach.<circuit-domain> | venue poster requests | Cloudflare Email Sending |
Why the transactional subdomain is separate from the outreach ones. Sending reputation attaches to the domain. The notices run is the first unsolicited mail this directory ever sends; magic links are the only way anybody gets into their listing. If those share a domain, the first complaint about a campaign takes the login mail down with it, and a business that cannot receive a login link does not report a bug — it gives up, and the claim-rate gate fails for a reason that looks like disinterest. Same rule Makers Map runs on.
- ☐
MXfor the apex → M365. - ☐
SPF(TXT) on the apex, including the M365 include. One SPF record per name, never two. - ☐
SPFon each ofmail.,notices.,outreach.authorising Cloudflare Email Sending. A subdomain that sends without its own record fails alignment however good the rest of the setup is. - ☐ DKIM selectors published for every sending name, and this means finding the actual selector names rather than assuming:
- M365 publishes
selector1._domainkeyandselector2._domainkeyas CNAMEs, and DKIM has to be enabled in Defender before they sign anything; - Cloudflare Email Sending publishes its own (
cf2024-1,cf2024-2at the time of writing) per sending domain.
DNS cannot be asked which selectors exist, so
circuit preflightprobes a list; if the provider uses another name, setCIRCUIT_DKIM_SELECTORSand run it again rather than assuming a pass. - M365 publishes
- ☐ DMARC on the apex: start at
p=none; rua=mailto:...and read the reports for a fortnight before tightening.p=nonereports without rejecting, so a misconfiguration shows up in the reports instead of silently binning the magic links. - ☐ Keep alignment relaxed (
adkim=r, the default).adkim=sfails every subdomain that signs with its own key, which is all three of ours. - ☐ Add
notices.andoutreach.toextra["sending_domains"]in the site config so preflight checks SPF, DKIM and DMARC on them from outside. Thesending_domains_distinctcheck fails if this is missed.
Step 3 — First deploy, with BASE_URL already the real domain
- ☐ Create the separate DigitalOcean project and export
CIRCUIT_PROJECT_ID(seedeploy/README.circuit.md). - ☐
./deploy/provision.sh— one droplet,lon1,s-1vcpu-2gb, taggedcircuit. - ☐ Point the
Arecords at the IP before the first deploy, and run it with the real host:CIRCUIT_HOST=<circuit-domain> ./deploy/deploy.sh <ip>The site must never serve SEO signals from a throwaway hostname. Canonicals, the sitemap, the crawler user agent and the UTM source all derive from
BASE_URL, and a few days indexed under a throwaway IP-based hostname splits every signal the brand is trying to earn. Caddy issues a certificate per hostname independently, so there is no flag day: the name starts working when its DNS resolves. - ☐ If a temporary host was used at any point, put it in
CIRCUIT_REDIRECT_HOSTSand inextra["previous_hosts"], so preflight asserts the 301 rather than trusting it. - ☐
CIRCUIT_DEMO_MODE=1,CIRCUIT_PUBLISH_LIVE=0. Synthetic fixtures behind the demo banner; nothing real is public yet. - ☐ Controller identity into
/srv/circuit/shared/site.env:CIRCUIT_CONTROLLER_NAME,CIRCUIT_CONTROLLER_ADDRESS,CIRCUIT_ICO_REGISTRATION=<ico-registration>. Publishing live data refuses in code while any is empty. - ☐ Re-render the compliance documents and the Worker kits once the address is set, so the committed copies and the
wrangler.jsoncfiles name the controller in full:python -m circuit.compliance --write docs --check python -m placegraph.outreach.workers render --site circuit --dest workers - ☐ Confirm the three timers exist and fire in clock order —
systemctl list-timers | grep circuit→ refinery 02:10, backup 02:40, maintenance 03:20. - ☐
circuit-backup.shhas produced at least one.sqlite.gz, and one has been restored somewhere else. An untested backup is a hope. - ☐ The Datasette password and the admin token are in the password manager and nowhere else.
The Workers, deployed but silent
- ☐
npx wrangler d1 create circuit-noticesandcircuit-outreach, two separate databases, ids pasted into the respectivewrangler.jsonc. - ☐ Migrations applied to both.
- ☐ Both deployed with
SENDING_ENABLEDstill"0". Deploying them sends nothing. - ☐
python workers/export-queues.py --dry-runruns and the split looks right: everycorporate = 1row is a company or an org, and no one-person consultancy you recognise is in it.
Step 4 — Preflight green, then and only then PUBLISH_LIVE=1
- ☐ First real ingest complete; scoring pass run;
circuit publishrun with the gates still off, and the review queue read rather than skimmed. - ☐ The 100-record hand-check done to validation-protocol.md, and the gate met: ≥80% precision overall and ≥80% on presence alone. This is the kill criterion. A directory of registered-office ghosts fails even at 100% on "exists".
- ☐ Retention actually runs. The privacy notice, the LIA and the DPIA all commit to deleting crawled page excerpts after 90 days, and no
circuit purgeverb exists yet, so nothing does. Add it, then re-render the deploy kit withmaintenance=("purge", "pipeline")(seedeploy/README.circuit.md). A promise in a published notice that the system does not keep is the one failure discovered by the person who trusted it. - ☐ Preflight green against the live host:
python -m circuit.preflight --verboseUse the module form until
circuit/cli.pyimports the site wrapper (from . import preflight) rather thanplacegraph.compliance.preflight— the platform module does not carry the four Circuit checks, and a green run that silently omitted them is worse than a red one.Fourteen platform checks and four of ours. The four to read carefully, because they are the ones nothing else catches:
sending_domains_distinct— magic links and campaigns on different domains, and both campaign domains declared for SPF/DKIM/DMARC checking;registered_office_only_hidden— nothing above the ceiling is public, and the ceiling is actually configured;contractor_address_hidden— no contractor record resolves to a street address, in the database or in the served HTML;surrounding_area_only— nothing from Dorset or New Forest appears outside the surrounding-area facet.
- ☐ Every failure fixed, or written down with a reason and a date. A skipped check is not a passed check.
- ☐
/privacy,/your-dataand/about/botall load and name the controller correctly. - ☐ The consent box on
/registeris unticked in the served HTML. Checked by preflight, and worth looking at with your own eyes once. - ☐ Public counts agree with each other — hero, filters, listed total. One definition, computed in one place.
- ☐ Then:
CIRCUIT_PUBLISH_LIVE=1,CIRCUIT_DEMO_MODE=0, redeploy, and re-run preflight against the published site.
After launch, deliberately separate
Neither of these is part of going live, and neither happens on the same day.
- ☐ Sending. The legal review of the notice wording, a live test to an address we control with the headers inspected, then
npx wrangler deploy --var SENDING_ENABLED:1on the notices Worker alone.workers/README.mdhas the full sequence. - ☐ The surrounding-area facet. Dorset and New Forest are configured from day one and published only when the facet ships and
extra["surrounding_area_active"]is set. Until then, preflight fails if one of their records is public — which is the intended behaviour, not an obstacle to route around.