Series: From WordPress to Astro — Part 5 of 5
By cutover day, the difficult content work should already be finished. The repository builds from a clean clone, the migration contract passes, and the pages.dev deployment has survived human review. The remaining job is operational: connect the correct Cloudflare product, move the public domains without disturbing mail, and keep a credible path back to WordPress.
This final part also covers the work that is easy to forget after the homepage appears: deleting a mistaken Worker, revoking temporary credentials, freezing the old site, restoring analytics with consent, and adopting a repeatable pull-request publishing workflow.
Series contents
- From WordPress to Astro, Part 1: Choosing the New Platform
- From WordPress to Astro, Part 2: Auditing the WordPress Backup
- From WordPress to Astro, Part 3: Converting Content, Media and URLs
- From WordPress to Astro, Part 4: Proving the Site Before Cutover
- From WordPress to Astro, Part 5: Cutting Over Cloudflare Pages and Cleaning Up (you are here)
Choose Pages, not the similarly named Worker flow
Cloudflare now presents Workers and Pages close together, and both products can connect to Git. That makes the first setup screen an important architecture check.
For this static Astro site, the correct dashboard path is:
Workers & Pages
→ Create application
→ Pages
→ Import an existing Git repository
After authorising the GitHub App for only the required private repository, the build settings are deliberately small:
| Setting | Value |
|---|---|
| Production branch | main |
| Build command | npm run build |
| Build output directory | dist |
| Root directory | Repository root |
| Node version | 22.12 or newer |
There is no application deploy command, API token, R2 key or Worker entry point. Pages installs dependencies and runs npm run build, which generates dist and then audits its metadata, routes, links, headers, sitemap and redirects. Only a successful command is published. Cloudflare’s Astro deployment guide documents the same production branch, build command and output directory; the repository-owned audit is the extra release gate inside that command.
The wrong flow is recognisable. Its screen says that it will configure a Worker project, asks for a Deploy command and may suggest:
npx wrangler deploy
I initially reached that flow. The Astro build completed successfully, then Wrangler failed because there was no Worker script entry point. The error showed that the Astro build had passed and only the unnecessary Worker deployment had failed.
The fix was not to invent a Worker entry point or change the static architecture to satisfy the form. I returned to the Pages tab and created the Git-integrated Pages project. I then deleted the mistaken Worker. The next feature-branch push started only the Pages and GitHub checks, confirming that the redundant integration was gone.
This distinction remains worth checking against the current Pages Git integration documentation, because dashboard labels can evolve while the product boundary remains the same.
Prove the pages.dev deployment first
Cloudflare assigns a project hostname under pages.dev. That is the safest place to test the production branch before the public domain moves.
I checked the same areas used in the branch preview: homepage, current and historical posts, archives, search, RSS, sitemap, media, redirects and the branded 404 page. I also inspected canonical and Open Graph metadata. The browser may be on the temporary hostname, but canonical URLs should already point to the intended public domain.
Do not attach DNS to a failed or partially reviewed deployment. The temporary hostname separates application readiness from domain routing, which makes both easier to reason about.
There is an SEO detail to close after the custom domain is live. Cloudflare adds
X-Robots-Tag: noindex to branch preview deployments by default, but not to the stable project
hostname. Canonical metadata is useful, but it is not the same as an indexing directive. I added
an absolute-host rule to public/_headers so the second production-shaped hostname remains useful
for testing without becoming a duplicate search result:
https://PROJECT.pages.dev/*
X-Robots-Tag: noindex
Cloudflare Pages supports absolute URL patterns in _headers. The rule applies only to that host,
so https://veducate.co.uk remains indexable. After deployment, verify the response rather than
only checking that the file exists in Git:
curl -sSI https://PROJECT.pages.dev/ | grep -i '^x-robots-tag:'
curl -sSI https://veducate.co.uk/ | grep -i '^x-robots-tag:' || true
The first command should print noindex; the canonical host should not receive that header.
That header is crawler guidance, not authentication. Put confidential branch previews behind
Cloudflare Access and verify the challenge from a signed-out client. Once the custom domain is
stable, redirect the stable project hostname to the canonical host when it is no longer needed as a
separate test surface; preserve the path and query string and keep host-specific noindex as
defence in depth until the redirect is proven.
Record DNS before changing it
The domain was already using Cloudflare DNS, so there was no nameserver migration. Even so, my cutover notes covered more than the two obvious web records:
- Apex and
wwwweb records, including proxy state and TTL. - MX records and SPF, DKIM and DMARC-related records.
- Verification records and unrelated subdomains.
- Redirect, Page, Cache, Origin, Transform and Configuration Rules.
- Worker routes and custom hostnames.
Those notes and screenshots were enough to carry out and check the initial change. The stronger, reusable practice is to create a protected DNS and rules export before changing DNS, then capture an updated export after acceptance. A screenshot of one web record is not enough if an old origin rule can still intercept traffic or an accidental bulk edit can break mail.
Sensitive infrastructure exports belong in protected operational storage, not in the website repository. The public Git history needs the procedure and non-sensitive outcome, not every account detail.
Attach the apex through Pages
For a zone already managed by Cloudflare, I used the Pages project’s Custom domains workflow to attach the apex domain. Cloudflare can then manage the site association and certificate rather than relying on an invented IP address or an arbitrary manual record.
The sequence was:
- Open the Pages project and its custom domains.
- Add the apex domain.
- Allow Cloudflare to identify the conflicting old web record.
- Replace only that known web destination.
- Wait for the custom-domain and certificate status to become active.
- Test the apex before changing anything unrelated.
Cloudflare documents the supported process in its Pages custom-domain guide. The important discipline is to keep mail and unrelated service records out of the change set.
Make www redirect, not duplicate
The canonical site uses the apex domain. A DNS record that makes www reach Pages does not by itself make www redirect. Without an explicit rule, both hostnames can serve the same HTML and create an unnecessary duplicate-host problem.
At cutover, a proxied CNAME sent www traffic to Cloudflare and a permanent edge redirect sent it
to the apex hostname:
https://www.example.com/<path>?<query>
→
https://example.com/<path>?<query>
The rule captures the wildcard path and inserts it into the apex destination. Query strings must be
preserved as well. A legacy Page Rule
uses $1 for its first wildcard; other redirect products use different replacement syntax.
For a new setup, follow Cloudflare’s current Pages www redirect guide, which uses a Bulk Redirect with Subpath matching, Preserve path suffix and Preserve query string enabled. A Single Redirect is another valid choice when you want the rule expressed directly. Its wildcard replacement is ${1}, not Page Rules’ $1. Copying replacement syntax between products without checking is risky. Whichever redirect product you use, the www DNS record must be proxied so the request reaches Cloudflare’s rule engine.
I tested four combinations—HTTP and HTTPS on both apex and www—with the homepage, a deep article path and a query string. The canonical HTTPS apex should return directly. Each non-canonical form should reach it in no more than one intentional redirect, without a chain through several hostnames.
Never hand-edit the generated redirect file
The hostname redirect above belongs at Cloudflare’s edge, but historical path redirects are stored with the site in public/_redirects. That deployed file is generated from three reviewed sources:
- 33 redirects discovered during the WordPress import;
- two compatibility routes for each of the ten category archives; and
- four feed/comment-feed plus five sitemap compatibility routes.
That produces 62 deployable rules. The first line of the file says where it came from:
# Generated by npm run generate:redirects — edit scripts/manual-redirects.json, not this file.
When a future article moves, add a reviewed entry to scripts/manual-redirects.json. This is an
illustrative example:
[
{
"from": "/old-guide/",
"to": "/replacement-guide/",
"status": 301,
"reason": "Article moved to its permanent replacement route"
}
]
Then regenerate and inspect both the input and output:
npm run generate:redirects
git diff -- scripts/manual-redirects.json public/_redirects
npm run verify
The generator rejects unsupported status codes, malformed or non-root-relative source paths, absolute destinations containing embedded username/password credentials, dot-path traversal, self-redirects and conflicting definitions for the same source. It also requires a meaningful reason for every hand-written rule, even though that explanation is not emitted into the compact Pages file. The separate repository scan handles several recognisable token and private-key patterns; the redirect parser is not a general secret scanner.
Do not add a second rule by editing public/_redirects directly. It will disappear next time the generator runs, and the source of the redirect will be missing from review history.
Verify the public service after DNS
Pre-cutover validation proved the files. Post-cutover verification proves the service that readers actually reach.
Useful first checks are intentionally simple:
curl -I "https://example.com/"
curl -I "https://www.example.com/an-existing-article/?source=test"
curl -I "https://example.com/feed/"
curl -I "https://example.com/definitely-not-a-real-page/"
Quote URLs that contain ? or & so the shell does not treat them specially. Also remember that
curl example.com requests HTTP; a 301 from that command is normally the expected HTTPS
redirect, not proof that the final page failed. During DNS changes, compare the operating system’s
answer with a public resolver if they disagree:
dig +short example.com
dig +short @1.1.1.1 example.com
dig +short @1.1.1.1 www.example.com
A recently missing hostname can remain in a local negative DNS cache after the public record is
already correct. dig is an optional DNS troubleshooting tool; use an equivalent resolver query
if it is not installed. Test from another resolver or network before editing a working record
again.
For the real cutover I expanded that into a public acceptance pass:
- HTTP reaches HTTPS, and the apex homepage returns 200.
wwwredirects once while preserving path and query string.- Current posts, old posts and pages return their intended canonical URLs.
- Every URL in the generated sitemap returns 200 without an unexpected redirect.
- RSS and sitemap XML are syntactically valid and use the apex host.
- Representative old slugs, categories, paginated archives and legacy feed paths return 301 to a real 200 destination.
- Referenced WordPress media paths still return the expected files and cache headers.
- A nonsense URL returns the branded 404 page and is not rewritten to the homepage.
- The edge certificate verifies, TLS 1.2 and 1.3 work, and TLS 1.0 and 1.1 fail.
- MX and SPF records remain present.
Add a real send-and-receive mail test and check at least one unrelated subdomain. DNS records being present is useful evidence, but it does not replace an end-to-end service test.
Cloudflare normally provides and renews a publicly trusted edge certificate; its SSL/TLS guide explains the certificate and encryption layers. A dashboard status is helpful, but a browser and command-line client still need to prove the certificate presented on the actual hostname.
The honest 404 test is particularly important after WordPress. Some WordPress setups send unknown URLs to the homepage with a 200 status. That looks friendly but misleads users, crawlers and monitoring. A static migration should preserve valid legacy routes through explicit redirects and let everything else be genuinely absent.
Keep two rollback plans
“Rollback” can mean two different failures.
If a new Pages deployment is bad but domain routing is healthy, keep DNS attached to Pages. Promote or roll back to the last known-good production deployment, then revert the bad Git change with a new commit so production and the repository agree.
If the custom-domain association, TLS or Cloudflare routing is wrong, detach or disable the Pages custom domain if it still claims the hostname, then restore the recorded WordPress web records and rule state. Re-test WordPress and its certificate. Do not touch mail records during a website rollback.
These options only exist during a deliberately planned rollback window. Freeze the old service at cutover rather than continuing to accept posts or comments, and keep any rollback access time-limited, monitored and documented outside the public article. Choose the duration from the site’s change rate, risk and hosting terms instead of copying another site’s dates.
Revoke credentials created during migration
Git-integrated Pages does not need a Cloudflare API token or R2 credentials for ordinary publishing. Any powerful credential created during investigation is therefore a liability once the task is over.
The cleanup list included:
- Revoke temporary Cloudflare API tokens.
- Revoke any R2 or S3-compatible access keys when R2 is not part of the final design.
- Check for and revoke a token created by the mistaken Worker flow.
- Review the Cloudflare GitHub App and keep its repository access narrow.
- Confirm that no token, key, deploy hook or credential entered Git history or build variables.
Deleting the Worker did not automatically prove that every token was gone. Provider-side revocation was a separate explicit task. Deleting a key from a document would not make an exposed credential safe; rotation or revocation removes its authority.
Decommission WordPress deliberately
At the end of the rollback window, repeat a smaller version of the launch audit: crawl the sitemap, sample media and redirects, check the 404, confirm Pages deployments and verify that no traffic rule still depends on the WordPress origin.
Only after that gate passes is it reasonable to cancel the old hosting. If a checksum-verified source backup is retained for historical recovery, keep it under protected access, outside Git and away from public web paths. Remove obsolete origin and cache rules after recording why they are no longer needed.
The final shutdown should also remove old administrator accounts, plugin integrations, scheduled backups and monitoring that only targeted the WordPress service. Otherwise the retired platform continues to create alerts, credentials and cost even when it no longer serves the site.
Restore analytics as a separate privacy decision
The content migration did not require analytics. I restored Google Analytics 4 afterwards as a small Astro feature rather than copying three overlapping WordPress plugin configurations into the new site.
The implementation uses Google Basic Consent Mode: the Google tag does not load and no request goes to Google before the visitor explicitly allows analytics. Declining keeps it off, while a permanent settings control allows the decision to be changed later. Advertising storage, advertising user data and personalisation remain denied. Google’s consent-mode overview distinguishes this basic model from advanced consent mode, which can send cookieless measurements before consent.
The tag is also restricted to the canonical production hostname. Local development, pages.dev and branch-preview traffic do not contaminate the production reports. Query strings and fragments are removed from measured page locations, the local search route is excluded, and the public privacy policy explains the provider, choice and expected cookies.
I reused the intended existing GA4 property and web stream to preserve reporting continuity. The client-side measurement ID is not a password, but old plugin OAuth tokens and Measurement Protocol secrets are credentials and must not move into source code.
Some analytics work cannot be proven from Git. In Google Analytics Admin, review the intended stream and website URL, choose a justified retention period, keep unnecessary signals and advertising links off, and disable collection features that could capture local search, browser-history page changes or form interactions.
The account review should also confirm that custom events and dimensions do not capture user-entered text, retire stale streams and their secrets, remove obsolete access, and review property users. Google explains where to confirm the web-stream identifier in its measurement ID guide. Sharing a Google password with a build process is neither necessary nor appropriate.
Repository tests prove the hostname, URL-sanitisation and consent logic, not Google’s account-side interpretation. Use a fresh browser profile, Developer Tools, Tag Assistant and the intended GA4 property’s Realtime report to run the production matrix:
- Make no choice and confirm there are no Google Analytics requests or cookies.
- Choose Decline, reload and confirm the refusal persists without collection.
- Choose Allow, confirm exactly one intended tag loads and confirm the page appears in the intended property’s Realtime report.
- Visit
/search/?q=testand confirm it sends no analytics request. - Withdraw consent, reload and confirm that subsequent page views are not collected.
- Repeat the host check against localhost, the stable
pages.devhostname, a branch preview andwww; only the canonical production hostname should be measured.
Provider-level observation is the acceptance test for consent behaviour.
Use Search Console as post-cutover evidence
Search Console is not part of the build and its reports lag behind DNS, so I did not use first-day clicks as a cutover gate. I did use it immediately afterwards to check that Google saw the same canonical host, sitemap and routes that the repository intended.
The first pass should be small and repeatable:
- Keep the existing domain and URL-prefix properties rather than creating a new site identity.
- Submit the new canonical sitemap index and remove obsolete WordPress sitemap submissions.
- Inspect the homepage, a current post, an old post, an archive and a redirected legacy URL.
- Compare indexed and submitted URLs over time, excluding dates Google still marks incomplete.
- Record query and page evidence before deleting or noindexing old archives.
For vEducate, that audit found that the initial 1,837-URL sitemap was dominated by thin tag
archives. I did not blanket-noindex every tag: Search Console showed that some had earned clicks.
The remediation kept those useful tag roots indexable, left the remaining archives reachable with
noindex,follow, and reduced the indexable sitemap to 909 URLs. It also exposed stale WordPress
sitemap submissions, internal links through redirects and one migrated description that referred
to the wrong platform.
Availability was therefore only the first SEO checkpoint. Repeat comparisons after reporting data has matured, using complete dates and watching canonical selection, submitted versus indexed URLs, clicks and impressions rather than expecting rankings to settle on launch day. Compatibility redirects can keep old WordPress sitemap filenames working, but remove obsolete submissions from Search Console; a redirect is not a reason to leave obsolete configuration indefinitely.
The reusable lesson is that a successful crawl is not the same as a useful index. Preserve route compatibility, but let real search evidence help decide which archive pages deserve to compete in search results. Keep any API credentials outside Git and commit only sanitised, aggregate evidence that is safe for the repository.
Publish future articles through Git
The replacement for WordPress’s Publish button is a short, inspectable workflow:
- Update local
mainand create a content branch. - Write the article under the authored-post directory and add its media under a stable public path.
- Use
status: "draft"anddraft: truewhile it should not render. - Change to
status: "publish"anddraft: falsewhen it is ready for a shareable branch preview. - Run
npm run verify, review the staged diff and push the branch. - Open a pull request and wait for GitHub validation and the Cloudflare Pages preview.
- Review the article, archives, search, feed, sitemap, media, mobile layout and canonical metadata.
- Merge into
mainonly when it is ready for production. - Watch the production Pages deployment and verify the live route.
The matching command-line workflow is:
SLUG="my-article-slug"
# Stop and deal with any output before creating the article branch.
git status --short
git switch main
git pull --ff-only
git switch -c "content/$SLUG"
# Create src/content/posts/authored/$SLUG.md and public/images/$SLUG/.
git diff --check
npm run verify
git status --short
git add "src/content/posts/authored/$SLUG.md" "public/images/$SLUG"
git diff --cached --stat
git diff --cached
git commit -m "Add $SLUG article"
git push -u origin "content/$SLUG"
gh pr create --fill
gh pr checks --watch
If the article has no media, omit that directory from git add. If you do not use GitHub’s gh command, create and monitor the pull request in the GitHub web interface. Neither route merges automatically; review the Pages URL and the GitHub Actions checks described in Part 4 first.
The accepted published frontmatter pair is status: "publish" with draft: false. The draft
boolean controls whether Astro renders the entry; status records the editorial/migration state,
and repository validation requires the two fields to agree. The branch boundary is what keeps a
preview article away from production. A future date is not a scheduler, and a private GitHub
repository does not make the Pages preview confidential; anyone given that URL may be able to
open it.
No deployment token is needed in this routine. Cloudflare’s Git integration builds branches for review and main for production. The repository becomes the durable source of truth, the pull request becomes the review record, and an old deployment remains available if a release needs to be rolled back.
Scheduled content data should follow the same boundary. A narrowly scoped workflow can write a
reviewable aggregate on a bot-owned branch and open a pull request without merging or publishing
directly. Keep provider credentials in the CI secret store, expose only the minimum aggregate, and
let the normal validation and preview gates decide whether the update reaches main.
If your GitHub plan supports rulesets or branch protection for the repository, require the
validation checks and block direct pushes to main. Where technical enforcement is unavailable,
document pull-request review and no-force-push procedures as a temporary, weaker control rather
than implying that a green workflow is enforced.
Closing the migration
The public milestone was a working custom-domain deployment and a repeatable Git-native content path: branch, tests, hosted preview, pull request, merge and production verification. Protected exports, rollback exercises, provider-account reviews, search follow-up and source-platform retirement belong in the operational runbook with owners and evidence. A successful article should not be used to infer the current status of those private controls.
The larger benefit of moving from WordPress to Astro is inspectability. Content, transformation rules, validation, privacy behaviour, deployment and recovery are explicit enough to inspect and reproduce without depending on a collection of administrator plugins.