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. Register www as 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@, and removals@ 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, www and 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 circuit to the monitor Worker's site list with the canonical URL https://<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

  • A for <circuit-domain> → the droplet IP.
  • A or CNAME for www → same, and add it to CIRCUIT_REDIRECT_HOSTS so it 301s rather than serving a second copy.
  • TXT verification 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.

DomainCarriesProvider
<circuit-domain>human mail to and from hello@Microsoft 365
mail.<circuit-domain>transactional only: magic links, claim confirmations, replies to formsCloudflare Email Sending
notices.<circuit-domain>the Article 14 notice campaignCloudflare Email Sending
outreach.<circuit-domain>venue poster requestsCloudflare 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.

  • MX for the apex → M365.
  • SPF (TXT) on the apex, including the M365 include. One SPF record per name, never two.
  • SPF on each of mail., 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._domainkey and selector2._domainkey as CNAMEs, and DKIM has to be enabled in Defender before they sign anything;
    • Cloudflare Email Sending publishes its own (cf2024-1, cf2024-2 at the time of writing) per sending domain.

    DNS cannot be asked which selectors exist, so circuit preflight probes a list; if the provider uses another name, set CIRCUIT_DKIM_SELECTORS and run it again rather than assuming a pass.

  • DMARC on the apex: start at p=none; rua=mailto:... and read the reports for a fortnight before tightening. p=none reports 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=s fails every subdomain that signs with its own key, which is all three of ours.
  • Add notices. and outreach. to extra["sending_domains"] in the site config so preflight checks SPF, DKIM and DMARC on them from outside. The sending_domains_distinct check 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 (see deploy/README.circuit.md).
  • ./deploy/provision.sh — one droplet, lon1, s-1vcpu-2gb, tagged circuit.
  • Point the A records 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_HOSTS and in extra["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.jsonc files 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.sh has 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-notices and circuit-outreach, two separate databases, ids pasted into the respective wrangler.jsonc.
  • Migrations applied to both.
  • Both deployed with SENDING_ENABLED still "0". Deploying them sends nothing.
  • python workers/export-queues.py --dry-run runs and the split looks right: every corporate = 1 row 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 publish run 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 purge verb exists yet, so nothing does. Add it, then re-render the deploy kit with maintenance=("purge", "pipeline") (see deploy/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 --verbose

    Use the module form until circuit/cli.py imports the site wrapper (from . import preflight) rather than placegraph.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-data and /about/bot all load and name the controller correctly.
  • The consent box on /register is 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:1 on the notices Worker alone. workers/README.md has 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.