Retiring WordPress with a Cloudflare Worker: 301s and 410s
27 Oct 2026 · 8 min read

When WordPress goes, its URLs don’t. Search engines still hold your old permalinks, other sites still link to them, and bots still knock on /wp-login.php long after there’s nothing behind it.
I moved this site from WordPress to a static Astro build served by Cloudflare Workers. Everything that used to be WordPress’s job, from permalinks to feeds to the admin paths, now goes through one small Worker that runs before the static files. It sends a 301 for anything that moved, a 410 for anything that’s gone for good, and hands the rest to the site.
This post is the whole thing: which old URLs need a rule, why the admin paths get a 410 rather than a 404, how I tested it without touching DNS, and the order I cut over in.
Why not the _redirects file?
Most write-ups on this start with Cloudflare Pages and its _redirects file. It’s a good tool, but it only supports the redirect status codes 301, 302, 303, 307 and 308. There’s no way to say “gone”, so people end up adding a Pages Function just to return a 410.
Workers with static assets avoid the split. Set run_worker_first and your Worker runs before any asset is served, so one file can redirect, refuse, or pass through:
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page",
"run_worker_first": true
}
run_worker_first also takes an array of route patterns if you only want the Worker on some paths. I wanted it on everything, because host aliasing (more on that below) has to see every request.
Why 404 is the wrong answer for /wp-admin and /wp-json
A 404 says “I can’t find that”. A 410 says “that existed, it’s gone, and it isn’t coming back”. RFC 9110 is explicit that 410 is for a condition that is “likely to be permanent”, and that it exists so the server can say it wants links to the resource removed.
That’s exactly the situation. There is no admin, no REST API, no XML-RPC endpoint, and there never will be again. So these all return 410:
/wp-admin/,/wp-login.php,/xmlrpc.php/wp-json/and everything under it/wp-content/and/wp-includes/
I want to be honest about what this buys you. Google’s documentation says it treats every 4xx except 429 the same way: the content doesn’t exist, and a previously indexed URL is dropped. So 410 isn’t an SEO trick, and a vulnerability scanner will keep trying wp-login.php whatever you send it.
What it does buy is clarity. A 404 in your logs should mean “something’s broken”; a 410 means “working as intended”. When I look at the requests after cutover, a spike in 404s is a missing redirect I need to write. The 410s are noise I’ve already dealt with, and they cost one regex test and no asset lookup.
One trade-off: /wp-content/uploads/ is in that list, so any site still hotlinking an old image gets a 410. I’d already copied the images into the new build during the import, so I accepted that.
The permalink map
WordPress lets you pick a permalink structure, and I’d used /<category>/<slug>/. So the same post might live at /articles/some-post/ or /tutorial/some-post/ depending on its first category. The new site has one home for every post, /posts/<slug>/, and I kept the slugs identical during the import so the map is mechanical.
Here’s the function, straight from the Worker:
// Real two-part paths on this site; any other /<category>/<slug>/ is an old
// WordPress permalink.
const OWN = new Set(['posts', 'archive', 'tools', '_astro', 'sketches', 'img']);
export const legacy = (path) => {
if (/^\/(wp-admin|wp-login\.php|wp-json|xmlrpc\.php|wp-content|wp-includes)(\/|$)/.test(path)) return 'gone';
// WordPress's sitemap names, which Search Console may still hold.
if (/^\/(sitemap|sitemap_index|wp-sitemap)\.xml$/.test(path)) return '/sitemap-index.xml';
if (/^\/(feed|comments\/feed)\/?$/.test(path) || /\/feed\/?$/.test(path)) return '/rss.xml';
const category = path.match(/^\/category\/([a-z0-9-]+)\/?$/);
if (category) return `/archive/${category[1]}`;
const post = path.match(/^\/([a-z0-9-]+)\/([a-z0-9-]+)\/?$/);
if (post && !OWN.has(post[1])) return `/posts/${post[2]}/`;
return undefined;
};
The rules, in the order they run:
- WordPress internals get a 410.
- Old sitemap names (
sitemap.xml,sitemap_index.xml,wp-sitemap.xml) go to the newsitemap-index.xml, because Search Console may still hold the old sitemap. This one is easy to forget: I added it in a follow-up fix. - Feeds go to
/rss.xml. That covers/feed/,/comments/feed/, and the per-post and per-category feeds WordPress generates, which all end in/feed/. - Category archives at
/category/<name>/go to/archive/<name>. - Anything else with two path segments is treated as an old permalink, unless the first segment is one of the site’s own sections.
The allowlist is the part that bites. Rule 5 is greedy on purpose: I didn’t want to list every WordPress category I’d ever used. The cost is that a new two-part section on the site has to go in OWN, or its pages will redirect to /posts/ and 404. The test below catches that.
Nothing checks that the target post exists. An old permalink for a post I didn’t bring over redirects to a /posts/ URL that returns the site’s 404 page. I think that’s right: it’s a real miss, and it shows up as a 404 I can decide about.
Aliasing www and new to the bare domain
The same Worker handles hostnames. www. and a new. subdomain that an earlier version of the site lived on both 301 to the bare domain with the path and query string intact:
if (url.hostname === `new.${HOME}` || url.hostname === `www.${HOME}`) {
url.hostname = HOME;
return Response.redirect(url.href, 301);
}
This runs before the permalink rules, so www. plus an old permalink takes two hops. Google follows up to 10 redirect hops and treats a 301 as a strong signal, so I kept the code simple rather than collapsing them.
One Worker, two deployments
Both deployments serve the same dist/ folder through the same Worker. The difference lives entirely in wrangler.jsonc:
"env": {
"production": {
"workers_dev": false,
"routes": [
{ "pattern": "danielbroadhurst.co.uk", "custom_domain": true },
{ "pattern": "www.danielbroadhurst.co.uk", "custom_domain": true },
{ "pattern": "new.danielbroadhurst.co.uk", "custom_domain": true }
]
}
}
npm run deploy publishes the default config to a workers.dev address: my preview. npm run deploy:production publishes the production environment to the real domains, with workers_dev off so the site isn’t reachable at two addresses. CI runs the production deploy on merges to main.
The preview is a full copy of the site, so it must stay out of search results. The Worker adds a header on its way out:
if (url.hostname.endsWith('.workers.dev')) {
const preview = new Response(response.body, response);
preview.headers.set('X-Robots-Tag', 'noindex');
return preview;
}
Deciding noindex by hostname means the build can’t get it wrong. There’s no flag to forget when building for production, because the build is the same either way.
Testing redirect rules before touching DNS
The preview can’t test everything. It only answers on workers.dev, so the www. and new. rules never fire there. So the rules live in a plain Node script instead, worker/check.mjs, which imports the Worker and calls it with fake requests and a stub asset binding:
import assert from 'node:assert/strict';
import worker, { legacy } from './index.js';
assert.equal(legacy('/category/aws/'), '/archive/aws');
assert.equal(legacy('/wp-admin/'), 'gone');
for (const own of ['/posts/a/', '/archive/aws', '/tools/template-check', '/about', '/'])
assert.equal(legacy(own), undefined, own);
const assets = { fetch: async () => new Response('page', { status: 200 }) };
const go = (u) => worker.fetch(new Request(u), { ASSETS: assets });
const r = await go('https://www.danielbroadhurst.co.uk/posts/a/?x=1');
assert.equal(r.headers.get('location'), 'https://danielbroadhurst.co.uk/posts/a/?x=1');
No test framework, no Wrangler, no network: Request and Response are built into Node, so node worker/check.mjs is the whole test run. The full version also checks real old permalinks from three of my old categories, that xmlrpc.php returns 410, and that the production host gets no X-Robots-Tag while the preview does.
The loop that matters is the negative one: every one of the site’s own paths must come back undefined. That’s the check that fails when someone adds a section and forgets OWN.
After that, the preview is worth a quick spot-check: curl -I a few old permalinks and /wp-json/ on the workers.dev address to see the real 301s and 410s come back from Cloudflare.
The cutover order
This is the checklist from the repo’s README, and the order is the point:
- Back up WordPress, database and uploads, before anything else.
- Check DNS. Attaching custom domains to a Worker replaces the A/CNAME records for the root,
wwwandnew. TheMXandmail.records are separate and must stay exactly as they are, because email still runs there. - Merge to main. CI deploys production and attaches the domains.
- Sample old URLs on the live domain: a handful of permalinks, a category archive,
/feed/, the 404 page. Add the domain to Google Search Console and submitsitemap-index.xml. - Retire the old host last, and only once email is confirmed working somewhere else.
Step five is the one people rush. The web moves the moment DNS does; mail might not, if the old host was also your mail server. The redirects are reversible; losing a week of email isn’t.
If you run WordPress on your own infrastructure (say, WordPress on AWS with CloudFormation), tear that down after step five too, not before.
If you’re retiring your own WordPress site
- Keep your slugs. If the new URLs reuse the old slugs, the whole permalink map is one regex.
- Send 410 for what’s never coming back. It won’t stop scanners, but it keeps your 404s meaning “fix me”.
- Allowlist your own sections. A greedy legacy rule needs a list of what isn’t legacy, and a test that fails when it’s wrong.
- Test the Worker in Node, not just on the preview. Host rules can’t fire on
workers.dev, but they can in a ten-line script. - Decide noindex by hostname. Same build for preview and production, no flag to forget.
- Move email last. Retire the old host only once mail is confirmed elsewhere.


