Admin / Site manual Back to the site

Launch & cutover

The operating manual for traveller.org — kept current with every publish that changes a process. Sits with the CMS portal behind the admin door.

A runbook, in the order you do it. Each step says where you are, what to do, and how you know it worked. Steps marked OWNER need you at a keyboard on someone else's website; CLAUDE steps are done in a session — ask, and they happen. A Done chip means it is finished, with the date on the step.

The order is a dependency chain, not a suggestion. Every step can be started the moment the one above it is finished, and none can be started before — so working top to bottom never leaves you stuck. If a step ever tells you it must wait for a later one, that is a bug in this page: say so and it gets moved.

Nothing is irreversible until step 13. Everything before the DNS change leaves the old site serving exactly as it does today, and after it the records can be pointed back at the old server from the Cloudflare dashboard in minutes (step 13 says how, and why that is faster than undoing the nameservers). After cutover, a bad publish is one commit to revert and redeploys in about two minutes.
This page changes as the cutover does. Every step is updated in the same push as the change it describes. If a step here disagrees with what actually happened, that is a bug in this page — say so.
Where DNS work happens — and it is never a subdomain at Porkbun. You touch Porkbun twice in the whole runbook, and neither time do you create a record:
  • Step 9 — lower the TTL on traveller.org to 600s, which is Porkbun's floor. Porkbun is still the DNS host at that point.
  • Step 13 — change the nameservers to Cloudflare's. After this, Porkbun is registrar-only and its DNS panel stops being read by anything. Every record from then on — including the raised TTL in the last step — is edited in the Cloudflare zone.
images.traveller.org needs no record from you anywhere. R2 creates it itself when you add the custom domain in step 14, and it can only do that once the zone is on Cloudflare — which is why that step sits immediately after the nameserver change and not before it. Adding a CNAME for it at Porkbun would not work and is not a shortcut: R2 serves a bucket under a hostname only for domains attached through its own Custom Domains panel, and nothing else would issue the certificate. So there is no subdomain DNS to do — not before step 13, and not after.

Stage 1 — Now (before anything moves)

Nothing here touches the live site. Do it whenever; it can all sit finished for weeks.

  1. OWNERDone 18 Aug Create the Cloudflare Pages project.
    Cloudflare → Pages → Connect to Git — that link goes straight there, which is the reliable way in (see the warning below). Pick brarob100/traveller.orgBegin setup.
    On Set up builds and deployments (verified against the dashboard 2026-08-18): Project name traveller-org — keep exactly this, it is the name step 2's redirect table was built against; Production branch productionthe one field that must not be left at its default; Framework preset None; Build command npm run build; Build output directory dist. Preset None leaves the last two blank — type them, rather than picking the Astro preset and trusting what it fills in.
    If you navigate by hand, the dashboard will put you in Workers instead — twice over. Cloudflare has demoted Pages to a footnote, so Workers & Pages → Create application lands on a "Create a Worker" panel whose step rail reads Select a method → Select a repository → Create and deploy. It will happily let you choose this repository and go on to a form headed "Set up your application — Configure your Worker project" with a Deploy command box reading npx wrangler deploy.
    Two tells, either one decisive: Pages never says "Worker" anywhere, and Pages has no Deploy command box at all. If you are on the Worker panel, the way out is the grey line under the method list — "Looking to deploy Pages? Get started" — or just use the link above.
    Why it matters, not just which button: that deploy command would read this repo's root wrangler.toml, which belongs to the upload Worker, and deploy that instead of the site — a second deploy path fighting the one GitHub already runs. Worse, the Workers form pins no branch, so it would build the working branch: every commit straight to the live site, which is the one thing this project exists to prevent. It also never asks for the output directory or the five variables below, both of which Pages does.
    Open Environment variables (advanced) on that same screen and set exactly these five (the field truncates long values on screen — click into PUBLIC_R2_BASE and check it ends .r2.dev):
    • SITE_URL = https://www.traveller.org
    • SITE_BASE = a single forward slash
    • PUBLIC_R2_BASE = https://pub-d86b555b7dd7462abd871c743192bcd6.r2.dev — the bucket's own address, for now. It becomes https://images.traveller.org at step 14, which cannot happen until the nameservers move at step 13. Setting the custom name before it resolves builds a site on which every photograph is broken.
    • PUBLIC_LEGACY_IMAGES = off
    • PUBLIC_UPLOAD_URL = https://traveller-upload.brarob.workers.dev
    Done when: the build finishes green, traveller-org.pages.dev shows the site, and a session confirms the build is correctly shaped — green is not the finish line, because it is green either way. Write that address down — step 2 needs it.
    Done 2026-08-18. traveller-org.pages.dev, production branch production, all five variables verified from a session against the deployed site — see the checklist below for what that means and how to ask for it again after a redeploy.
    Expect a site a little behind staging. Pages builds the production branch, which moves only when you press Publish — so this first build shows whatever was last published, not the newest staging. A missing recent change here is the gate working, not a broken build: open the Publish page, check the waiting list, and publish once to bring the two level.
    Then have a session check the shape of the build, because a Pages build succeeds and looks identical from Cloudflare's success screen whether those five variables took effect or not. Ask for it — do not read view-source yourself. traveller-org.pages.dev was added to the sandbox's allowlist and a session verified all five from this side on 2026-08-18, so the check is one sentence to Claude ("verify the deployed Pages site") rather than five things to find in a browser. What it looks at, and which variable each proves:
    • photographs on a real album page come from pub-….r2.dev and the objects fetch 200 image/jpegPUBLIC_R2_BASE reached the build, and its truncated ending survived the paste
    • /robots.txt does not say Disallow: / and names a sitemap — SITE_URL reached it
    • rel="canonical" on the home page says www.traveller.org with no sub-path — SITE_URL and SITE_BASE both landed, so the build is production-shaped
    • /sitemap.xml exists, every URL in it names www.traveller.org, and none is under /admin/
    • an album still on the old server 404s and is absent from the sitemap — PUBLIC_LEGACY_IMAGES=off reached it
    • the Publish page carries the Worker address — PUBLIC_UPLOAD_URL reached it
    Any one of these failing is a variable in the Pages project, fixed at the dashboard and a redeploy — not a code problem. A session can tell you which one and what to set it to, but it cannot set it: Pages environment variables are yours alone.
    Why production and not the working branch: every commit reaches staging (the test site) by itself, but the live site must only change when you press Publish on the Publish page — that button is what moves the production branch, and this Pages project rebuilds from it. Connect the working branch instead and every half-reviewed commit goes straight to traveller.org.
    Why these five: the first two make links, canonical tags, robots.txt, the sitemap and the llms.txt files all say www.traveller.org instead of the test address; PUBLIC_R2_BASE points every photograph at the image host; PUBLIC_LEGACY_IMAGES=off withholds the 0 albums whose photographs are not migrated yet (), so the site never shows a broken frame.
  2. CLAUDEDone 18 Aug Generate the redirect table against the real Pages address.
    NEW_HOST=https://traveller-org.pages.dev python3 migration/build_redirects.py
    This is not optional because the file already exists. The committed migration/htaccess-redirects.txt was generated against a guessed address, traveller-org.pages.dev. If Cloudflare gives the project any other name, every one of its 327 redirects points at a host that does not exist — and it is pasted into the live server at step 6.
    Done when: migration/htaccess-redirects.txt names your Pages address, and both checks pass: python3 migration/verify_redirects.py and python3 migration/test_htaccess.py.
    Done 2026-08-18, and it was a confirmation. Cloudflare assigned traveller-org.pages.dev — the very address the committed table had been generated against from a guess — so regenerating produced a byte-identical file and git diff came back empty. That empty diff is the whole result: it proves the 393 rules about to be pasted into the live server point at a host that exists. Had Cloudflare named the project anything else, the diff would have been the work.
  3. CLAUDEDone 18 Aug Run the cutover audit.
    SITE_URL=https://www.traveller.org SITE_BASE=/ PUBLIC_LEGACY_IMAGES=off PUBLIC_R2_BASE=https://images.traveller.org npx astro build then python3 migration/verify_cutover.py.
    All four variables, not two. Leave SITE_URL and SITE_BASE off and the build still calls itself production — but its canonical tags name the staging address, so the redirect check reports STAGING and refuses to make the strong claim this step exists to obtain, and the audit's own headline passes for the wrong reason (see the note below).
    Then, on that same production build, re-run python3 migration/verify_redirects.py.
    Done when: the audit prints nothing on the public site addresses www.traveller.org, and the redirect check prints checked against a PRODUCTION build with every target found. If the audit prints anything else, stop — those are images that break the moment DNS changes.
    Why the redirect check runs again here, having already passed at step 2: it only ever checked the build sitting in dist/, and at step 2 that is normally a staging build. Production sets PUBLIC_LEGACY_IMAGES=off and withholds the legacy albums, so a redirect aimed at one would pass at step 2 and 404 on the live site. The check now prints which shape it read and says so when the weaker claim is all it can make.
    And why the command grew from two variables to four, 2026-08-18. With only PUBLIC_LEGACY_IMAGES and PUBLIC_R2_BASE set, the audit printed self-references the new site serves: 0 and then nothing on the public site addresses www.traveller.org — safe to cut over. Both lines were true and together they meant nothing: with SITE_URL unset there were no www.traveller.org references in the build at all, so the audit passed by having nothing to look at. Adding the two missing variables gives 1130 self-references and the same pass — which is the pass that means something. That is `tests/README.md`'s second trap ("a passing test can be measuring nothing") landing on the runbook for the second time in one day.
    Run 2026-08-18 with all four: audit clean at 1130 self-references, redirect check checked against a PRODUCTION build, 278/278 targets present, so no redirect depends on a withheld album.
  4. CLAUDEDone 18 Aug Verify the sitemap and structured data. Built 2026-08-18 (same day this step was added for their absence): the sitemap (@astrojs/sitemap, /admin/ excluded, named by production robots.txt), canonical tags and og:url/og:image on every page, and schema.org JSON-LD (WebSite + Person on the home page, ImageGallery on galleries, BlogPosting on journal entries). tests/seo.test.mjs holds it all green.
    Done when: ./tests/run.sh passes — the SEO suite asserts the sitemap exists, leaks no admin page, and robots.txt names it.
    Note that run.sh builds a staging-shaped site, so for the production claim run the step-4 build command above and then node tests/seo.test.mjs against it. Done 2026-08-18 that way: 21 assertions green, 563 sitemap URLs, no /admin/ page listed — and the same three facts confirmed independently against the deployed site at traveller-org.pages.dev, which is the stronger check because it reads what Cloudflare actually served.
    This step began life as "build the sitemap" — the manual had promised one on three pages while nothing generated it. It stays as a verification step because the promise has been broken once already, and a re-check costs one suite run.

Stage 2 — At InMotion (old site still live)

This is the only stage that edits the old server. It sends visitors to the new site before DNS moves, so no old link is dead in the meantime. The old site keeps working for anyone typing the address.

A correction to what this stage used to promise. It said the redirects make "rankings transfer early". Checked against the live site once the block went in (2026-08-18), that is not what happens, and what does happen is better. Every page on traveller-org.pages.dev carries rel="canonical" naming www.traveller.org — so the new host disclaims itself, and search engines keep the www URLs in the index rather than replacing them with pages.dev ones that would all have to be replaced again at cutover. Nothing transfers early because nothing needs to: the addresses that are indexed today are the addresses that serve the site after step 13.
  1. OWNERDone 18 Aug Back up the current .htaccess.
    cPanel → File Managerpublic_htmlSettings → tick Show Hidden Files → select .htaccessDownload.
    Done when: the file is on your machine somewhere you can find it in a hurry. This is the undo button for the whole stage.
    Done 2026-08-18, on the owner's report — this is the one step in the runbook a session cannot confirm, because the evidence is a file on his own machine. Its contents were pasted into a session the same day and are now a fixture in migration/test_htaccess.py, which is the next best thing: the file can be reconstructed from the repo if the download is ever lost.
  2. OWNERDone 18 Aug Paste the redirect block in.
    Yes, there is somewhere to send the traffic — already. Every one of the 393 rules points at traveller-org.pages.dev, not at www.traveller.org, and that host has been live since step 1. This is the whole reason Stage 2 comes before the DNS flip: visitors and search engines start reaching the new site now, and nothing about it waits for step 13. Verified 2026-08-18: all three of step 8's targets answer 200 on the Pages site. If you would rather see that for yourself before editing anything, open https://traveller-org.pages.dev/currency/thailand-20/ in a browser — that is exactly where the third test URL will land.
    Right-click .htaccessEdit. Open migration/htaccess-redirects.txt on GitHub, click Raw, copy everything, and paste it in at the bottom, below everything already there. Save.
    Done when: the file still begins with the #spamming site lines and ends with the redirect block.
    Done 2026-08-18, verified live from a session. The block is in effect: the catch-all answers 301 → traveller-org.pages.dev on an invented URL, which it can only do if the rules are being read.
    The generated table grew after you uploaded (same day) — your copy on the server does not need updating. Nine rules were added for seven albums in the crawl's oldest, pre-content/ layout, which the generator had missed. Every URL they cover already falls through your uploaded catch-all to the new host, where the new machinery catches it — verified live on /luxembourg/1998/. Re-pasting would only shorten a two-hop redirect to one, and the whole file stops mattering at step 13, when this server stops seeing traffic for the domain.
    Confirmed against the real file 2026-08-18 — the owner pasted it into a session and it is now a fixture in migration/test_htaccess.py. It holds only two things, a user-agent block and a PHP handler, and neither one catches URLs, so the redirects can safely run last. Pasting below them means the user-agent block keeps first refusal on the agent it bans; everyone else falls through to the 301s.
    Pasting ABOVE is not merely untidy, it is wrong, and the suite now proves it: a redirect would match first and carry [L], so the banned agent escapes the block entirely and gets a 301 like everyone else. Bottom of the file, every time.
  3. OWNERDone 18 Aug Optional: fix the spam block while you are in there.
    The blocking rule already in the file has never matched anything, and that is now verified rather than argued (2026-08-18, against the file itself). Its pattern is the raw browser string, and Apache reads a RewriteCond pattern as a regular expression — so the parentheses in (Windows NT 10.0; WOW64) and (KHTML, like Gecko) are capture groups, not literal brackets. The rule therefore hunts for a version of the user-agent with its brackets stripped, which no browser sends. If you want the block to work, replace its RewriteCond line with:
    RewriteCond %{HTTP_USER_AGENT} "Chrome/51\.0\.2704\.103" [NC]
    Done when: saved — or skipped. The old site retires in weeks either way, so this is tidiness, not necessity.
    Done 2026-08-18, and it works now. A request carrying that exact user-agent gets 503 from the live server — the first time this rule has ever fired. A normal browser is unaffected, and the redirects below it are untouched.
    Skipping costs the redirects nothing. The suite runs the cutover both ways against the real file: unrepaired, readers get their 301s exactly as before and the banned agent simply gets redirected along with everyone else. It is the block that is broken, never the redirects.
    Leave the AddHandler line alone while you are tidying. The old WordPress site still runs on it until InMotion is cancelled, and the 301s living in this file have to keep working until then. (Its comment says PHP53 and the directive says php56 — the comment is what is wrong, and neither is worth touching.)
  4. OWNERDone 18 Aug Test three redirects.
    This step runs now, not at cutover. The redirects target traveller-org.pages.dev, which is live, so they work the moment step 6 is saved — the old site being still on InMotion changes nothing.
    In a browser, open each and confirm you land on the new site:
    • www.traveller.org/journal/?p=15 → Climbing the Pyramids
    • www.traveller.org/cuba/1996/varadero/ → the Cuba gallery
    • www.traveller.org/currency/thai-notes-20.html → the Thai 20 note
    Done when: all three land correctly. If any 500s, restore the backup from step 5 and say so — the old site is back instantly.
    Done 2026-08-18, verified from a session — and well beyond three. All three test URLs 301 to the right target and finish 200 on the Pages site. Then 14 more were sampled, two from each of the seven redirect categories (banknote, banknote-without-a-record, country, gallery, split-or-retired gallery, journal post, root page): every Location header matched the redirect map exactly, and every target answered 200 on the deployed site rather than merely existing in a local build.
    Reading a failure. Three outcomes, and only one of them is a broken rule:
    • 500 — the block has a syntax error Apache will not accept. Restore the backup; nothing else in this stage matters until it is back.
    • 200, showing the old page — the block is not in effect at all. Not a rule problem: the file was not saved, or it went into the wrong .htaccess (it must be the one in public_html, the same file step 5 downloaded).
    • The right new page — done.
    The one-URL version of that check: open www.traveller.org/definitely-not-a-real-page.html. With the block live, the catch-all rule 301s anything unmatched to the new site. If instead you get a 302 to missing.shtml, that is the old site's own 404 handler answering, which means no rewrite rule ran — the block is not active. Confirmed as the pre-paste signature on 2026-08-18, when the live server was probed from a session and answered exactly that.

Stage 3 — Before you flip DNS

Last look. Still nothing irreversible. Start with the TTL, because it is the only step here with a wait attached — set it first and the other three happen while the clock runs.

  1. OWNERDone 18 Aug Lower the DNS TTL.
    At Porkbun — still the DNS host at this point — set the TTL on the traveller.org records to 600 seconds. Then wait for the old TTL to expire (whatever it was set to — often a day) before step 13.
    600, not 300 — and 300 will be rejected when you press save. Porkbun enforces a minimum TTL of 600 seconds, because Porkbun runs its own DNS on Cloudflare and Cloudflare caps TTLs at 600 for records on its network. Established 2026-08-18, from Porkbun's support desk, after the runbook asked for 300 and the save failed.
    Nothing is lost by it. The point of this step is that a rollback does not have to wait out a day-long cache, and 600 seconds is ten minutes — the difference between 300 and 600 does not change any decision on cutover day.
    Done when: the records show a 600-second TTL, and the old value's worth of time has passed.
    Set 2026-08-18, on the owner's report — a session cannot read Porkbun, and DNS-over-HTTPS is blocked from the sandbox, so this one cannot be confirmed from a session either way. Check the records read 600 before starting step 13c. The wait is the part that matters: do not start step 13 until the previous TTL's worth of time has gone by.
    This is what makes step 13 reversible in minutes rather than hours. Skip it and a rollback is stuck waiting for caches to expire worldwide.
    Why this is the first step of the stage and not the last (moved 2026-08-18): it is the one step whose cost is waiting, and the three after it are reading and checking. Set the TTL, then walk the site and run the checks while the old value ages out — otherwise you finish Stage 3 in an hour and then sit for a day before you can flip.
  2. OWNERDone 18 Aug Walk the Pages site.
    Open each of these on traveller-org.pages.dev — one of every kind of page the site builds: Done when: nothing is a broken image.
    This one is genuinely yours, and the page used to claim otherwise. It said a session could drive it in a browser and report. It cannot: Chromium in the sandbox has no outbound network at all — every host, proxied or not, answers ERR_CONNECTION_RESET — which is why the committed browser suites run against a local server with photographs stubbed. Corrected 2026-08-18, after trying it.
    What a session can do, and did: fetch every page above and every distinct image URL on them over HTTP. On 2026-08-18 that was 213 image URLs, all answering 200 image/jpeg from the bucket, including the four whose filenames carry spaces and brackets. So the addresses are right and the objects exist.
    What that does not prove is what you are looking at. `CLAUDE.md` has this exact warning for a reason: numeric assertions have passed on pages that were visibly broken. A correct URL that renders behind a mis-sized container is still a bad page. Your eyes are the check here; ask a session first if you want the URL-level sweep re-run against a fresh deploy.
  3. OWNERDone 18 Aug Decide about the withheld albums.
    There are none left to decide about. Every album this site knows about is publishable: 195 serve their photographs from the image host, and one — luxembourg/1998 — ships its 23 photographs with the site itself.
    Done when: you have decided — or, as here, there is nothing left to decide.
    Resolved 2026-08-18 rather than decided. This step was written when the number was 32 albums and 4,395 photographs. Thirty-one of those were the deprecated 800px albums, deleted outright at the owner's request (see Still open). The last one, luxembourg/1998, was moved onto the new site the same day: its 23 photographs now ship with the build under /legacy-media/, so it is published like any other album and is not marked deprecated — nothing supersedes it, it is simply the best copy that exists.
    It had to move, not merely deserve to. That album's photographs were hotlinked to www.traveller.org, and step 6's redirects made every such URL 301 to the new site, where they did not exist. The moment the redirect block went live, the album's images broke. Shipping them with the site is what makes the old server switchable-off, which is the whole point of stage 5.
    The counts above are derived from the gallery data at build time, so this step cannot overstate itself again.
  4. CLAUDEDone 18 Aug Final re-run of both checks, in case a gallery imported or a page changed since step 3.
    Done when: verify_redirects.py and verify_cutover.py both pass against a fresh production build.
    Run 2026-08-18, on a production build made the same hour that luxembourg/1998 moved onto the site:
    • verify_cutover.py — 574 pages serve photographs from the image host, 1,133 self-references to www.traveller.org, and 0 references to the moving hostname the new site does not serve. Nothing on the public site addresses www.traveller.org — safe to cut over.
    • verify_redirects.pychecked against a PRODUCTION build, 327 redirects to 278 distinct targets, 278/278 present.
    You do not have to ask for this any more (2026-08-18). Both checks now run on every push that touches the site, the data or the redirect table, as the Cutover checks job in the repository — against a production-shaped build of that exact commit. It is red on failure, not a warning like Docs guard: a redirect aimed at a page that does not exist is not a matter of taste, and it gets pasted into a live server at step 6.
    The job also guards the two ways these checks have already been fooled. If the build is not production-shaped, verify_redirects.py says STAGING and exits 0 — so the job greps for the word PRODUCTION and fails without it. And the audit's headline passes trivially on a build containing no www.traveller.org references at all, which is what happens when SITE_URL is unset — so the job also fails if the self-reference count comes back near zero, where a real build has over a thousand. Both failure modes were reproduced deliberately before the job was trusted.
    What is left for you: nothing, unless it goes red. If it does, the run summary prints both scripts' full output, and the fix belongs in the repository rather than the dashboard.

Stage 4 — Cutover day

  1. OWNERDone 19 Aug Point the domain at Cloudflare.
    Nameservers are hostnames, not IP addresses, and Porkbun's nameserver field takes hostnames. If you are looking at a box that wants an IP, you are in the A-record or glue-record screen — back out. Nothing in this step types an IP address anywhere.
    And no one can print the pair here in advance, including a session. Cloudflare assigns two nameservers per zone, drawn from a large pool, at the moment the domain is added — they look like amy.ns.cloudflare.com and bob.ns.cloudflare.com but the two words are yours alone. Copying a pair from any documentation, including this page, sends the domain to nameservers that are not authoritative for it and the site goes dark. Step 13a is where yours appears; use those two and nothing else.
    Before any of it: press Publish and let the Pages build finish. The live host serves the production branch, and the post-cutover redirect machinery (step 18) only exists on it after a publish — the 100-rule discovery of 2026-08-18 was made on the deployed site, and the fix reaches it the same way. Then ask a session to re-verify the three legacy URLs on traveller-org.pages.dev before 13c; they must 301 or forward, not 404.
    This step is five screens, in this order. The first is the one the runbook used to skip, which is why "the pair Cloudflare gives you" had nowhere to come from:
    1. 13a — Add the domain to Cloudflare. Cloudflare → Add a domain → type traveller.org → choose the Free plan. Cloudflare then scans Porkbun's existing DNS and imports what it finds. This changes nothing yet — the domain keeps resolving through Porkbun until 13c.
    2. 13b — Check the imported records before you go further. On the DNS screen Cloudflare shows what it copied. The MX records are the ones that matter: mail follows DNS, and between this step and step 15 your email must keep flowing to InMotion exactly as it does now. If the scan missed them, add them by hand from Porkbun's DNS page before continuing. A missing A or CNAME costs a page; a missing MX costs mail.
    3. 13c — Copy the two nameservers Cloudflare shows you, then at Porkbun → Domain Managementtraveller.orgAuthoritative Nameservers (some accounts label it NS) → replace both existing entries with Cloudflare's two → Save. This is the moment the switch is thrown.
    4. 13d — Tell Cloudflare to look. Back on the Cloudflare overview, press Check nameservers now. The zone flips from Pending to Active when the change has propagated — usually minutes, occasionally an hour or two.
    5. 13e — Point the site at the domain. Only once the zone is Active: Cloudflare → Workers & Pagestraveller-orgCustom domains → add www.traveller.org, then add traveller.org. Cloudflare creates the records and issues the certificates itself.
    Why nameservers and not just an A record at the new IP? Because there is no "the new IP". traveller-org.pages.dev answers on 172.66.44.108 and 172.66.47.148, and those are Cloudflare's shared edge, not this site's address. The edge decides what to serve from the hostname, not the IP — so an A record pointing at it serves your visitors nothing until Cloudflare has been told that hostname belongs to this project, and the only place to tell it is the Custom domains panel above, which needs the zone in the account.
    Demonstrated 2026-08-18, not assumed. One TLS connection to that exact address, asked twice, differing only in the hostname:
    • traveller-org.pages.dev200, the site
    • www.traveller.org403 Forbidden, from server: cloudflare
    That 403 is what every visitor would get. Three more reasons stack on top: the apex cannot be a CNAME (DNS forbids it alongside the zone's own SOA and NS records — Cloudflare only gets away with traveller.org by flattening it, which requires hosting the zone); no certificate — Cloudflare issues the padlock for a custom domain once it is attached, so an A record alone fails TLS before it ever reaches the 403; and those addresses are Cloudflare's to change, so a hand-copied A record is a bomb on a timer.
    And even if all of that were solved, the cutover would stall one step later: steps 14, 15, 16 and 20 need the zone in the Cloudflare account regardless of how the website itself is reached.
    Done when: the Cloudflare zone reads Active, both custom domains are attached, and https://www.traveller.org shows the new site with a valid padlock. Certificates can take a few minutes.
    A session can confirm it in one probe — ask, and it fetches www.traveller.org and reads the response headers. Server: cloudflare with a CF-RAY means the request is being answered by Cloudflare; Server: Apache means it is still InMotion and the change has not landed yet. That is exactly how the pre-cutover state was established on 2026-08-18. What a session cannot do is read the nameserver records themselves: cloudflare-dns.com and dns.google both answer 403 on CONNECT from the sandbox, so the authoritative check on the NS pair is Cloudflare's own Active badge.
    This is the irreversible-feeling step, and it is not — but the fast way back is not the obvious one.
    To roll back, edit the records in Cloudflare, do not revert the nameservers. Once the zone is Active, Cloudflare answers every query for traveller.org, so pointing www and the apex back at InMotion's address (216.194.165.246, the value they resolve to today) takes effect within the record TTL — minutes: Cloudflare serves proxied records with a ~5-minute TTL of its own, and the records its scan imported carry the 600 seconds set in step 9. Reverting the nameservers at Porkbun instead means waiting on the .org registry's delegation TTL, which is not yours to set and is commonly a day: resolvers that have cached the delegation keep asking Cloudflare regardless of what Porkbun now says. Same intent, two very different clocks.
    Step 9's 600-second TTL is what keeps the record clock short, which is the one you control.
    This is also the step that unlocks the rest of the day. Everything from here needs the zone to be in the Cloudflare account, and nothing could be done before it: the R2 custom domain (step 14), Email Routing (15), Resend's records for Gmail send-as (16) and Zero Trust on /admin/ (20) all require it.
    What actually happened, 2026-08-19. The zone went Active and both custom domains attached without trouble; www.traveller.org answered server: cloudflare with a CF-RAY, serving the new site, and the live home page counted the same 60 countries as the data. One thing broke, and it was not the website.
    The import at 13b copied the MX record and Cloudflare orange-clouded what it pointed at. The apex MX read traveller.org MX 0 traveller.org — aimed at the apex, which is proxied — and the orange cloud carries HTTP and HTTPS only. A proxied name resolves to Cloudflare's edge, which does not speak SMTP, so inbound mail had nowhere to land. Nothing in the dashboard says so; the record looks fine.
    The repair, applied the same day: an A record mail216.194.165.246 set DNS only (grey cloud), the apex MX re-pointed at mail.traveller.org, and cpanel and ftp greyed for the same reason. Confirmed from a session: mail.traveller.org answers server: Apache with no CF-RAY — the grey cloud is working — while www answers as Cloudflare.
    The rule to carry forward: anything that is not a web page must be grey. Mail, cPanel, FTP, SSH. 13b's advice to check the MX records was right and not enough — it is not whether they were imported, it is whether their target is proxied.
  2. OWNERDone 19 Aug Give R2 a custom domain.
    Set, but not yet visible on the live site (2026-08-19). images.traveller.org is attached to the bucket and works, and the owner reports PUBLIC_R2_BASE changed — but the live build still asks for every photograph from pub-….r2.dev. A variable is read at build time only, so the site keeps the old value until a build runs having seen the new one. The same symptom sits on step 18's Turnstile keys, from the same cause — which is what makes this one build problem rather than two variable problems. Nothing is broken meanwhile: both addresses serve the same bucket.
    Needs step 13 done first — R2 attaches a custom domain only for a zone already in this Cloudflare account, which traveller.org's becomes the moment the nameservers move. Confirm it in one click before starting: left sidebar → Domains must list traveller.org. (Until then + Add has nothing to offer you — which is why this step is here and not up in Stage 1.)
    Not urgent, and nothing is broken while it waits. The site keeps serving every photograph from the pub-….r2.dev address set in step 1. If mail needs attention first, do step 15 and come back — the two do not depend on each other.
    Cloudflare → R2 Object Storagetraveller-imagesSettingsCustom Domains+ Addimages.traveller.org. It is the first panel on the Settings page, above the fold, and while it is empty it reads "There is no custom domain assigned to this bucket."
    Do not confuse it with "Public Development URL", the panel directly below — and do not press Disable there. That panel holds https://pub-d86b555b7dd7462abd871c743192bcd6.r2.dev, which is the value PUBLIC_R2_BASE is set to right now, so disabling it breaks every photograph on the site the moment it is clicked. Searching the page for "public" lands on that panel and not on the one you want. Cloudflare's own warning under it — rate-limited, not recommended for production, Access and Caching unavailable — is the whole reason this step exists. It stays enabled until PUBLIC_R2_BASE has been switched over and a redeploy has proved the new name works.
    Done when: a photograph opens over that name, e.g. https://images.traveller.org/thumbs/vietnam/2009/halong-bay/…any file listed in the bucket — and Pages → traveller-orgSettingsEnvironment variables has PUBLIC_R2_BASE changed from the pub-….r2.dev address to https://images.traveller.org, with a redeploy so the build picks it up.
    Change PUBLIC_R2_BASE after the custom domain resolves, never in the same sitting as adding it — between the two, every photograph on the site is being served from a name that may not have propagated, and the Pages build cannot tell you that. Add the domain, open a photograph over it in a browser, and only then edit the variable and redeploy.
    Dashboard wording, verified 2026-08-18: this used to say Public access → Connect Domain. There is no "Public access" section on the Settings page today; the control is Custom Domains → + Add, and "public" now names the development URL that must be left alone. Cloudflare renames these panels — if what you see disagrees with this step, that is a bug in this page, and saying so in a session gets it fixed in the same push.
    This step used to be number 2, sitting in Stage 1 — Now among things that can be done today, with a warning that it could not actually be started for another twelve steps. The owner called that wrong and asked for it to be moved (2026-08-18): a runbook whose order is not the order you do things in is not a runbook. It now sits directly after the step that unlocks it.
  3. OWNERDone 19 Aug Move the email forwarders to Cloudflare.
    “Existing non-Cloudflare MX records conflict with Email Routing.” This is the error the first attempt got (2026-08-19), and it is worth knowing what it means before you start, because the obvious suspect is the wrong one.
    It is not Resend. Email Routing manages the MX for the apex and nothing else. Resend's MX lives on send.traveller.org — a different hostname, which Email Routing neither reads nor cares about. Do not delete it: it is Resend's bounce and complaint return path, and removing it breaks sending, which is the half you are keeping.
    The conflicting record is the apex MX 0 mail.traveller.org — the one written at step 13 to repair inbound mail while it was pointed at a proxied name. That was right then and it is exactly what Email Routing replaces now. Delete the apex MX, then press Activate; Cloudflare writes its own three route1/2/3.mx.cloudflare.net. Leave the mail A record alone — it is not an MX, and it is how you reach cPanel and webmail afterwards.
    Do not let the wizard replace your SPF record. Onboarding offers v=spf1 include:_spf.mx.cloudflare.net ~all for the apex. You already have an apex SPF covering InMotion's sending; accept the offer as written and InMotion's mail stops passing SPF. Merge the two into one apex TXT — every include you have plus Cloudflare's, one ~all at the end — and edit the record by hand afterwards if the wizard has overwritten it. The InMotion include comes back out at step 24, when that plan is cancelled.
    Two more records on the apex are inert and can stay or go: a v=DMARC1 TXT (DMARC is only ever read at _dmarc.traveller.org) and a v=DKIM1 TXT (DKIM is only ever read at <selector>._domainkey). Neither does anything where it sits. That the DMARC one is inert is lucky rather than fine: it asks for strict alignment, which Resend's send. return path would fail if anything ever read it.
    Today InMotion forwards eight @traveller.org addresses to Gmail. The moment the nameservers change, mail follows DNS — so this step happens the same day, right after step 13. Cloudflare → the traveller.org zone → EmailEmail RoutingGet started:
    • Add both destination addressesbrarob@gmail.com and reynaldh@gmail.com. Cloudflare emails each one a verification link; both must be clicked before any rule can point at them.
    • Recreate the forwarders as custom addresses: brarob, brobinson, dns, hiole, privacy, support, theboys → brarob@gmail.com, and reynald → reynaldh@gmail.com. Add dmarc → brarob@gmail.com alongside them — it is not one of InMotion's eight, but step 17's reports are sent to it and bounce without it.
    • theboys@ goes to both inboxes today, and a Cloudflare rule forwards to one destination only — so its rule is different: set the action to Send to a Workertraveller-email. That Email Worker is already written and deploys itself (cloudflare/email-worker.js); it hands the same message to both Gmail inboxes. It still needs both destinations verified first.
      Where the two addresses actually are, since the routing rule does not show them. The rule says only “hand it to traveller-email”; the Worker decides where it goes, from a list in its own code:
      • To read them: Workers & Pages → traveller-email → the code view. The line reads const INBOXES = […], near the top.
      • To change them: edit cloudflare/email-worker.js in the repository and push — it redeploys itself. Not in the dashboard editor: that works until the next auto-deploy overwrites it. Asking in a session is the short way.
      • The one screen that does list them: Email Routing → Destination addresses. Both must read Verifiedforward() refuses an unverified destination, so an unverified inbox means theboys@ quietly reaches one person instead of two. Check it here before activating.
    • Set the catch-all to brarob@gmail.com, so an address missing from this list still lands somewhere instead of bouncing.
    • When Cloudflare offers to replace the MX records, accept — that is the moment mail stops flowing through InMotion.
    Done when: a test email sent from an outside account to each address arrives in the right Gmail inbox — and a test to theboys@ arrives in both.
    Why here and not Porkbun or Resend: Porkbun's free forwarding only works while the domain uses Porkbun's nameservers, which it no longer does after step 13; Resend only sends mail, it cannot receive or forward it. Email Routing is free, unlimited, and lives in the dashboard the DNS just moved to. Sending from the domain is the next step.
  4. OWNERDone 19 Aug Keep sending as @traveller.org from Gmail.
    Replying as brobinson@traveller.org (or any address on the domain) must not depend on InMotion's mail server. Resend takes over as the outgoing relay — the domain is already verified there, because the import emails send from pipeline@traveller.org:
    • First confirm the move of DNS carried Resend's records: resend.com → Domains must still show traveller.org as Verified. If it does not, add the DNS records it lists to the Cloudflare zone — they came from InMotion's DNS and Cloudflare's import usually copies them across.
    • Create a second API key at resend.com → API Keys, named something like gmail-smtp. Do not reuse the pipeline's key — separate keys mean rolling one never breaks the other.
    • Gmail → SettingsAccounts and ImportSend mail asAdd another email address. Name as you like, address brobinson@traveller.org. SMTP server smtp.resend.com, port 587, username resend, password = the new API key, TLS.
    • Gmail emails a confirmation code to brobinson@traveller.org — it arrives in brarob@gmail.com through the forwarding set up in step 15, which is why this step comes after it.
    • Repeat the “Send mail as” entry for any other identity wanted (brarob@traveller.org, support@…) — the same SMTP credential serves them all.
    Done when: a mail sent from Gmail with the From set to brobinson@traveller.org arrives showing that address — not “via gmail.com” — and lands in the inbox, not spam.
    “Why not just use smtp.gmail.com for all of them?” (owner, 2026-08-19 — the honest answer is longer than the old one here, which is why the question kept coming back.)
    It sends. The mail leaves, arrives, and looks right in your Sent folder. What it does not do is prove it came from traveller.org. Sent through Google's servers, the mail is signed d=gmail.com and its bounce address is your gmail.com account — so SPF and DKIM both authenticate gmail.com, while the From line says traveller.org. DMARC's whole test is whether those two agree, and here nothing authenticates the domain in the From line. Through Resend the message is DKIM-signed d=traveller.org, which is the same thing said properly.
    Three consequences, in the order you would notice them:
    • Gmail recipients see “brobinson@traveller.org via gmail.com” in the header. For a domain whose whole point is that the mail is from you and not from a free account, that line undoes it.
    • Strangers' filters treat it worse over time. Nothing rejects it outright today, because traveller.org publishes no enforcing DMARC policy. Outlook, Yahoo and corporate filters weight an unauthenticated From-domain heavily, and the direction of travel since 2024 has been one way.
    • It forecloses the next step. Publish the DMARC record in step 17 and Gmail-sent mail claiming to be traveller.org starts failing your own published policy. You would be choosing, permanently, between protecting the domain and sending from it.
    It may not even be offered: the “Send through Gmail” option is a Workspace feature, and a free Gmail account adding an address on a domain it does not host is generally sent straight to the SMTP form. Resend costs one paste of an API key, and the domain is already verified there because the pipeline sends from pipeline@traveller.org. Free tier: 100 emails a day, shared with the import notices — plenty.
    If one address sends and another does not, with “the same settings” (owner, 2026-08-19: theboys@ and hiole@ worked, brobinson@ did not) — Gmail stores the SMTP server per address, not once per account. The screen that lists them shows only the address, so three entries can look identical and be pointed at three different servers. An address you set up years ago against InMotion keeps InMotion's server and password until you change it, and mail.traveller.org still resolves, so the failure is a credential error rather than an obvious one.
    Check it: Gmail → Settings → Accounts and Import → Send mail as → edit info beside the failing address → Next → read the SMTP Server box. Anything that is not smtp.resend.com on port 587 with username resend is the bug; retype it and re-send. If it already says Resend, the next suspects are an address showing (unverified) in that list — Gmail will not send as an address whose confirmation code was never clicked — and an API key created with sending access scoped to one domain.
  5. OWNERDone 19 Aug Publish a DMARC record — monitor only.
    With Email Routing receiving and Resend sending, the domain finally has a settled list of who is allowed to send as it. DMARC is the record that says so, and tells you who else is trying. One TXT record in the Cloudflare zone:
    TypeNameContentTTL
    TXT_dmarc v=DMARC1; p=none; rua=mailto:dmarc@traveller.org; fo=1 Auto
    • p=none is deliberate. It changes what happens to nobody's mail; it only asks receivers to report what they saw. Read a few weeks of reports, confirm everything legitimate passes, and only then consider p=quarantine. Starting at enforcement is how a forgotten sender — a booking site, an old form, a mailing list — disappears into spam with no warning.
    • Leave the alignment flags off. Omitted, they default to relaxed, which is what this domain needs: Resend's return path is on send.traveller.org, a subdomain, and relaxed alignment accepts that while strict would fail it. The old apex record carried adkim=s; aspf=s — do not copy them across.
    • Delete the old apex v=DMARC1 TXT at the same time. DMARC is only ever read at _dmarc, so the apex copy has never done anything; leaving it there invites a future tidy-up that copies its strict flags onto the real record.
    • dmarc@traveller.org has to exist, or the reports bounce. Add it as a custom address in step 15's list, forwarding to brarob@gmail.com. Keep the address on traveller.org — pointing rua at a gmail.com address instead requires Gmail to publish an authorisation record for this domain, which it does not, so most senders would silently refuse to report at all.
    • Expect volume, and do not read them by hand. Daily XML from Google, Microsoft, Yahoo and anyone else who received mail. A filter into its own label is enough; the reports matter in aggregate, not one by one.
    Done when: _dmarc.traveller.org answers with the record (any DNS lookup tool), the apex copy is gone, and within a few days a report has arrived at dmarc@.
    Why now and not at the start: DMARC is a claim about who may send as the domain, and until step 15 and step 16 were done that list was still changing. Publishing it earlier would have been publishing a claim that was about to become wrong — and it is the record that makes the Gmail-SMTP shortcut in step 16 permanently unusable, so it is placed after the decision it forecloses rather than before it.
  6. OWNERDone 20 Aug Switch the contact form on.
    Done 2026-08-20. The live page carries the real data-sitekey, the Turnstile script and the live endpoint, and the contact Worker answers past its own secret check. It came right a day after it looked broken, and the reason is worth keeping: the page the owner was testing was staging, whose build carried neither key and therefore drew the display-only form — a grey “Test build” line and a picture of a checkbox — while the live site had been sending all along. Staging now builds with both keys too, so the two agree.
    If it ever looks dead again, the checks below still apply, and the first of them is now: which site am I on?
    The one-line check, from any browser: open https://www.traveller.org/contact/, view source, search for data-sitekey. Empty means the build had no key, whatever the dashboard shows. Then, in order:
    • Is the panel showing Production? Variables are kept per environment and the panel has a Production/Preview switch at the top. A variable added under Preview is invisible to the live site and looks entirely present.
    • Is the name exactly PUBLIC_TURNSTILE_KEY? Case-sensitive, no trailing space. Astro only substitutes a variable whose name starts with PUBLIC_, and an unrecognised name is not an error — it is an empty string.
    • Did the newest deployment succeed? On a failed build Cloudflare keeps serving the last good one, so the site looks unchanged and healthy. Deployments → the top entry should be green and newer than the moment you added the variables.
    • Was it a redeploy or a rollback? Retry deployment builds again and reads the variables; Rollback re-serves an old build and does not.
    The same symptom is on step 14's PUBLIC_R2_BASE, which is the useful clue: two unrelated variables both missing points at the build, not at either variable.
    The form and its CAPTCHA are built and deployed; they are dormant until these keys exist.
    • Cloudflare → TurnstileAdd widget. Name it traveller.org contact, hostnames traveller.org and www.traveller.org, widget mode Managed. It gives you two keys.
      Add brarob100.github.io to that list as well (the widget → SettingsHostname Management → Add). The staging site builds with the same public key since 2026-08-20, so the form works there too — but Turnstile renders only on hostnames the widget names, and on any other it draws nothing at all. That empty space is indistinguishable from a broken key, and it is neither.
    • The site key is public: it goes on the Pages project as an environment variable. Add PUBLIC_TURNSTILE_KEY, and alongside it PUBLIC_CONTACT_URL = https://traveller-contact.brarob.workers.dev. Then redeploy — a variable on its own changes nothing until a build reads it.
      “Where is the Pages area?” (owner, 2026-08-19). There is no menu item called Pages on its own, which is what makes it hard to find: Pages projects and Workers share one list. Use the link, not the sidebardash.cloudflare.com → Workers & Pages resolves to that list whatever the nav happens to be called this month (it has been Workers & Pages, and Compute with the projects inside it). traveller-org is the entry tagged Pages, not the traveller- Workers beside it.
      From there: Settings → the variables panel — labelled Variables and secrets or Environment variables depending on the week. Two things to get right on that screen:
      • Add them under Production, not Preview. The panel keeps a separate set for each, and a variable added to Preview is invisible to the live site while looking entirely present.
      • Nothing happens until a build runs. Variables are read at build time, so the live site keeps the old values until you go to Deployments, open the three-dot menu on the most recent production deployment, and choose Retry deployment. Pressing Publish also works when there is something new to publish; retrying does not need there to be.
      While you are on that screen, step 14's other half is the same panel: change PUBLIC_R2_BASE from the pub-….r2.dev address to https://images.traveller.org and one redeploy finishes both.
    • The secret key is not: Cloudflare → Workers & Pagestraveller-contactSettingsVariables → add TURNSTILE_SECRET (encrypted), and RESEND_API_KEY — a third Resend key, its own, so rolling it touches neither the pipeline nor Gmail.
    Done when: you send yourself a message from https://www.traveller.org/contact/ and it arrives in your inbox — and hitting Reply addresses it back to the address you typed, not to the form.
    Two ways to test this and get a wrong answer.
    Wait for the build before testing at all. Setting the variables does nothing to the live site; Cloudflare's own note (“active on next deploy”) is telling you it will not happen by itself. The signal that the build has landed is on the page: the grey line “Test build: the form is display-only” under the form disappears, and a CAPTCHA box appears. While that line is still there, nothing you type is being sent anywhere and the failure means nothing.
    Test on a hostname the widget listswww.traveller.org or brarob100.github.io, not traveller-org.pages.dev. The Turnstile widget is bound to the hostnames in its Hostname Management. On any other name it refuses to render — which looks exactly like a broken key, and is not.
    And check which site you are on. Staging carried no contact keys at all until 2026-08-20, so its form was display-only while the live site's was working: the owner met the grey “Test build” line and an unclickable checkbox on brarob100.github.io and reasonably read it as a broken CAPTCHA on a form that was, on the live domain, already sending.
    Until both halves are set the form stays visibly display-only rather than pretending to send, and the Worker refuses with a message that never says which key is missing — a form that says "your message was sent" into a void is the one failure nobody would ever notice.
  7. OWNERDone 19 Aug Check the three redirects again, now that the name points somewhere new. Same three URLs as step 8, but the machinery serving them is the new host's now, not InMotion's — and it is three different mechanisms, which is why all three URLs are worth re-opening rather than trusting one:
    • /journal/?p=15 — the journal index resolves the WordPress post id with a script, because Pages redirects cannot see a query string.
    • /cuba/1996/varadero/ — a pre-rendered forwarding page at the old address, one of 172 the build writes for every exact old URL.
    • /currency/thai-notes-20.html — a forwarding page too; the wildcard rules in _redirects cover only the ~4,600 photo wrapper pages, whose filenames cannot be enumerated.
    Done when: all three land on the right page.
    Why forwarding pages and not one _redirects file (2026-08-18): the deployed Pages host was observed honoring exactly the first 100 rules of that file and silently dropping the other 125 — rule 100 fired, rule 101 did not, bisected live against traveller-org.pages.dev, with no bad byte at the boundary. Every plain gallery URL was in the dropped half, so they would have 404ed the moment DNS moved, with every build green. Cloudflare's documentation permits far more; the site is built to the limit that was observed, with a guard in build_redirects.py that refuses to emit more than 100 rules and a suite (tests/redirect-stubs.test.mjs, also in the Cutover checks job) holding every exact old URL to having its forwarding page.
  8. OWNERDone 19 Aug Confirm search and answer engines are switched on.
    Open https://www.traveller.org/robots.txt, then https://www.traveller.org/llms.txt.
    Done when: robots.txt does not say Disallow: /, and every link in llms.txt names www.traveller.org, not the old test address. The staging site blocks indexing and writes test-host links; a production build drops both automatically. If either survives, the build did not pick up SITE_URL.
    The pair travels together because one variable drives both: robots.txt is what lets Google in, and llms.txt / llms-full.txt are what AI answer engines read. AI crawlers are deliberately not blocked — the Pipeline page has the whole LLM-and-search inventory, including what is deliberately not built.
  9. OWNERDone 20 Aug Lock the admin door.
    In the Zero Trust dashboard: Access controlsApplicationsAdd an applicationSelf-hosted. That opens Create new self-hosted application, which is four things on one long page.
    1. Destinations → Public hostnames. Three boxes: Subdomain, Domain, Path. Put www in Subdomain, choose traveller.org as the Domain, and type admin in Path.
      The Subdomain box is the one that catches people. Left empty it protects traveller.org/admin and not www.traveller.org/admin — and www is where the site actually lives, so the door would look configured and guard an address nobody uses. Add a second public hostname with the Subdomain empty if you want the apex covered too; it costs nothing and closes the back way in.
    2. Access policies — the panel that says No policy associated. This is the step that cannot be skipped: without a policy the application protects nothing. Create new policy → name it something like Owner only → Action Allow → under Include choose the selector Emails → save. Back on the application, the policy now appears in the list.
      More than one person. Three ways, and which to use depends on how often the list will change:
      1. Several addresses in the one selector. The value field takes more than one: type an address, press Enter (or a comma) and it becomes a chip, then type the next.
      2. Several Include rows. If that field will only hold one, press + Add include and add a second Emails selector. The effect is identical.
      3. A reusable list, once the names outlast the afternoon: Reusable componentsLists → create one of type Emails and put everyone in it, then point the policy's Include at the list instead of naming addresses. After that, adding or removing somebody is one edit in one place, and the next application you protect can reuse it.
      The shape, which outlasts the labels: Include rows are ORed, and so are the values inside one of them — any address that appears anywhere in Include gets in. If the wording on your screen differs from the wording here, that sentence is what you are looking for.
      Two things before you add anybody. Every address on the list has to be a real inbox somebody reads — Cloudflare mails the one-time PIN to whatever is typed at the login screen, and the code only works if that address is on the list.
      And this is not a read-only door: it opens the whole of /admin/, including Publish and the upload pages. Anyone you add can put the site live. Keep the list to people you would give the repository to.
    3. Authentication — leave Accept all available identity providers switched on and add nothing. With no identity provider configured, Cloudflare falls back to its built-in one-time PIN: the visitor types their email, Cloudflare sends a code, and the code lets them in if the address is on the list. That is the login this step promises, and it needs no setup at all.
    4. Details — the Name box auto-fills to traveller.org; change it to something you will recognise in a list, like traveller.org admin. Session Duration 24 hours is fine. Then Create.
    Done when: an incognito window at https://www.traveller.org/admin/docs/ asks for an email and a code before showing this manual — and your own browser, already signed in, still opens it normally. Both held on 2026-08-20: an anonymous request is 302'd to the one-time-PIN login, the JSON feeds the pages read stay public, and the owner signed in with the code the same day.
    Decided 2026-08-18 (this used to say "optional"): the admin pages and this manual hold no secrets, but there is no reason for them to be public on the live domain when one free login screen closes them. Two boundaries this deliberately does not move: staging stays public-but-unlisted on github.io — a GitHub Pages site cannot take a login, and moving staging behind one was offered and declined; and Access covers only this hostname, not the Worker's own address at traveller-upload.brarob.workers.devnor www.traveller.org/api/*, where the same Worker is routed (since 2026-08-20) so the admin pages can call it on their own domain. Putting Access in front of either breaks every interactive admin page, which is the fault this route was added to end (2026-08-20). Its door is the passphrase — which is why that gate rate-limits failed attempts (about ten wrong tries a minute, then a one-minute wait; the Pipeline page has the details). Remember that robots.txt and noindex keep pages out of searches, not away from visitors — they are politeness, not locks.
    This step used to be three lines — "add an application covering www.traveller.org/admin* with an email allow-list of one" — which describes the outcome and not the screen. The owner reached the create form and could not get past it (2026-08-19): the form asks for a subdomain, a domain and a path as three separate boxes, and the policy that does the actual work sits in a panel further down that reads No policy associated and is easy to read as a status rather than a task. Both are written out above now. Same lesson as the Pages panel and the Shortcuts variable strip: name where the control is, not just what it achieves. It was still short an hour later: the rewrite said “add any others on separate lines”, describing a text area the form does not have, and the owner asked how to list more than one address. The lesson generalises past labels — describe the semantics a reader can verify on whatever screen they have, which here is that Include rows are ORed.

Stage 5 — After

  1. OWNER Tell Google the site moved.
    Search Console → add https://www.traveller.org as a property if it is not already → submit https://www.traveller.org/sitemap.xml.
    Needs step 4 done first — until the sitemap is built, that address 404s and this step cannot be completed.
    Done when: the sitemap reads as Success.
    The old site's sitemap lived at exactly this address, so Google already knows it — the new one is served under the same filename and gets discovered on its own. Submitting is confirmation, not introduction.
  2. OWNER Leave InMotion alone for a month.
    Nothing depends on it any more — every image it once served has been copied into the repository, and email moved to Cloudflare in step 15 — but the 301s living there still catch anyone reaching the old server directly by IP or an old hostname.
    Done when: a month has passed, search traffic looks normal, and forwarded email has been arriving all the while.
  3. OWNER Cancel the InMotion plan.
    Before you do, take a full backup (cPanel → BackupDownload a Full Account Backup) and keep it. The repository already holds the crawl, but the backup has the WordPress database, which the crawl does not. Email is already off InMotion (step 15), so cancelling ends only the old web server.
    Done when: the backup is downloaded and the plan is cancelled.
    The two journal entries that resisted migration are only recoverable from that database, so this backup is the last chance at them.
  4. OWNER Raise the DNS TTL back to an hour or a day, now that nothing is going to change suddenly.
    In Cloudflare, not at Porkbun — the zone moved at step 13, so Porkbun's DNS panel no longer feeds anything. Cloudflare → the traveller.org zone → DNSRecords. (Records left Proxied show TTL as Auto and need no change; the setting only applies to unproxied records.)
    Done when: Cloudflare shows the longer TTL. This is housekeeping; nothing breaks if it is forgotten.

Still open, and not blocking any of the above

  • Done 14 Sept The uploader has a plain address: upload.traveller.org (your call, 2026-09-14). Declared as a Worker custom domain in wrangler.toml and created by the deploy that already runs on every push — no dashboard, no hand-written DNS record, no credential in the conversation. Nothing was clicked, which is the point: the dashboard cannot do this. Adding it there puts you in Cloudflare’s Connect your domain flow — add-a-new-zone onboarding, which will not register a subdomain of a zone it already hosts — and it spins on “processing” and gives up silently, which is exactly what happened on 2026-08-20.
    Verified the minute it deployed: the name resolves to Cloudflare, / and /m both serve the uploader carrying that deploy’s own build stamp, /galleries answers 403 with no passphrase (the Worker itself talking), and the certificate verifies. The deploy token turned out to already carry the Zone · DNS · Edit row this needs — the run was green on the first push, so nothing had to be added.
    All three addresses are live and none replaced another. www.traveller.org/api/ is still what the admin login should read, because it is the only same-origin one; traveller-upload.brarob.workers.dev still carries the iPhone Shortcut, so the Shortcut needs no change.
  • Done 13 Sept A guide that publishes itself is now checked against a build first (found 2026-09-12, closed 2026-09-13: the sync rebuilds the site before it commits, and pushes nothing if the build fails, so a broken guide reaches neither the test site nor the live one). The history, kept because the failure was invisible rather than loud: When an app pushes only guide pages, the sync copies them in and publishes them without anyone looking. It asks whether anything else is waiting — it does not ask whether the site still builds. On 2026-09-12 one app wrote a date without quotation marks, the build failed, and because Cloudflare keeps serving the last good version, everything reported success while the new page answered “not found” for hours. The two causes are fixed (the reader is tolerant now, and the copy quotes as it goes), but the gap itself stands: the sync should build the site before it publishes, and hold if the build fails. It needs a fuller checkout than the sync currently takes, which is why it is a deliberate job rather than a quick patch. Until it is done, a broken guide page shows up as a page that 404s on the live site while every record says published.
  • The Amapas 353 Website & Admin guide is written and held back (your call, 2026-09-10: “hold on the amapas353.com documentation until we get that site completed. it’s changing too much”). Six pages covering the booking site and every admin screen exist in that app’s own repository under docs/guides-site/; they are not on the shelf. The reason is worth keeping: the guide went stale between being drafted and being pushed — the Rates screen was redesigned, promo codes went from two kinds to three, and a Reviews screen appeared, all in the hours it took to write. To bring it back: delete the hold line from that app’s row in src/data/guides.json, then run the Sync app guides workflow. Before you do, have that repository’s session re-read the six pages against the finished site — the sentence most likely to be wrong by then is the one saying the permanent web address is not switched on yet.
  • A pre-flight for iCloud links — “12 in this link, 5 already in San Andrés: fetch the 7 new / replace all 12” before anything starts (planned and set aside by you on 2026-09-13: “save for future”, then “record it”). Today the link intake decides overlap by rule on the runner, after the button is pressed — an original already in the album at its size is kept, a different photograph with a taken name is numbered, the pixel-hash check holds near-duplicates — and the page shows no count and offers no choice, because it never opens the link. The drag-in and phone paths do pre-flight names and ask skip / replace. The plan: a Worker route that resolves and lists the link itself (the fetch’s first two calls, small JSON, no downloads), plans names by the same rule as the run, compares them with the album folder, and answers new / same / differs / movie counts plus the link’s expiry; both pages show the sentence and two buttons (fetch the new is the dark one; replace all rides the dispatch as direction: replace, which the fetch honours by overwriting a taken name); the phone reuses its existing duplicates sheet. Tests pin the classification’s parity with the Python, both pages’ POSTs, and the replace flag. About half a day of session time. Reuse points: icloudGuid and the destination handling on /dispatch, the /photos listing, master_name / plan_names in icloud_fetch.py, the #dupsheet on /m. Nothing is built until you say so.
  • Cloudflare Pages is in maintenance mode — a move to Workers is on the table, and deliberately not taken yet (checked and deferred 2026-09-09, your call: “no dev”). Cloudflare has put all new investment into Workers; Pages still runs, still builds, and has no announced shutdown date, so nothing is broken and nothing is urgent. Two things here sit on Pages: this site (built from production, with five Pages Functions) and the Spec Intake form (two functions, on spec-intake.pages.dev). The four Workers — upload, contact, email and the recipe pipeline — are already Workers and are unaffected, as are Netlify (Expense Tracker) and Railway (the Amapas 353 ChatGPT app).
    What a move would cost: _redirects, _headers, custom domains, environment variables and secrets all carry over, and Git builds become Workers Builds — but the functions/ folder is not supported natively and must be compiled or rewritten. On this site that folder is the 301 layer answering every old address plus the friends door, which is the machinery least safe to disturb while the Search Console reports are still draining.
    When it is wanted: do Spec Intake first as the rehearsal — two functions, no custom domain, no Access door, and a broken deploy costs a form rather than the live site. Only then this site.
  • Done 14 Sept The guides shelf has its door — a second Access application on www.traveller.org/guides*, allow-list of two (you and Reynald), one-time PIN, created by you on 2026-09-14 and verified from outside the same day: the shelf and every page under it answer with the login, the public site is unaffected. The app addresses went onto the cards in the same change, which the door is what makes safe. The history, kept because the ordering mattered (deferred by you on 2026-09-05: the shelf went live open, unlinked and unindexed but readable by anyone with the address, and every guide is written as if public). The door is a Cloudflare Access application, and it locks the shelf with no change to the site. In the Zero Trust dashboard, Access → Applications → Add → Self-hosted: domain www.traveller.org, path guides (covers /guides/ and everything under it), one policy Allow with an email allow-list of just your address, one-time PIN as the login method — a second application, not a new path on the admin one, so its list can later take an app’s users without opening the admin to them (owner’s choice: just you for now, widen later). Then check that a private window asking for www.traveller.org/guides/ is answered with the PIN login, not the shelf. That rule — nothing private on the shelf until the door exists — held until 2026-09-14, and is why the addresses waited.) Separately, and not waiting on the door: the sync’s read token exists — you created it on 2026-09-05, under your own name for it, CENTRAL_DOCS_GIT_TOKEN (credentials table on the pipeline page) — so the nightly sync can read the app repositories.
  • The friends door is ON — built 2026-08-27, and you set FRIEND_KEY on the Pages project and deployed on 2026-08-28. Rotating the password (which signs every friend out) is one edit to that same setting followed by a deploy; the manual’s Friends section says what to send people.
  • The 31 deprecated 800px albums were deleted on 2026-08-18 at your request — their records, repo thumbnails and home-page references are gone, and every old link 301s to the replacement gallery where one exists, else the country page. Their photographs still await re-scanning (the full-size scans stay on the old server and your drives); each future import recreates its album from scratch, exactly as before. One album remains as-is: luxembourg/1998, whose country has no high-res batch yet.
  • The Home page manager shipped on 2026-08-18 — the hero carousel is now managed at the top of Cards & strips: drag to reorder, caption each slide, remove, add from any gallery, per the approved design (screen 4, Home page manager). The manual's Updating the site page walks it through.
  • Decap CMS at /admin is not built. Editing is by session or by editing JSON on GitHub. The Journal editor (2026-08-18) now covers the letters — prose, inline photographs, the pull-quote — which was the largest piece of what a CMS was for.
  • 53 journal photographs vanished from the old server years ago; each is listed with its entry in migration/journal-missing-images.csv in case a scan turns up.
  • No film is flagged for the home page or its country page, so the home page's film row renders empty. The Films manager sets those flags.