Moving a Jekyll site from GitHub Pages to Cloudflare Workers
This site has lived on GitHub Pages for years and there was never much reason to move it. Then I wanted three things GitHub Pages simply doesn’t do: custom HTTP headers, preview deployments per branch, and redirects that aren’t a meta refresh. So I moved it, and this is the write-up.
Most of the guides you’ll find point at Cloudflare Pages. Don’t follow them. Cloudflare’s own recommendation changed once Workers learned to serve static assets, and Pages is now the platform that still works but isn’t getting the investment. If you’re moving in 2026, go straight to Workers.
Downtime is under a minute if you sequence it right. The part that will convince you you’ve broken something happens afterwards, and it isn’t Cloudflare — there’s a section on it below.
Ignore step one of the official migration guide
Cloudflare’s Jekyll migration doc opens by telling you to add the github-pages gem to your Gemfile. If you’re running Jekyll 4, that instruction will actively downgrade you: the github-pages gem pins Jekyll to 3.10 along with a frozen set of dependencies.
That step exists for people whose repo has no Gemfile at all, because the classic GitHub Pages build assumes one. If you already have a Gemfile and Gemfile.lock, skip the entire section. The doc says as much, but it’s easy to plough straight through.
While you’re in there, check your Gemfile.lock has x86_64-linux under PLATFORMS. Cloudflare builds on Ubuntu, and a lockfile generated only on an Apple Silicon Mac will fail to install. If it’s missing:
bundle lock --add-platform x86_64-linux
The config files
A few new files at the repo root. First, wrangler.jsonc:
{
"name": "r3id-web",
"compatibility_date": "2026-08-18",
"assets": {
"directory": "./_site/",
"not_found_handling": "404-page"
},
"workers_dev": true,
"preview_urls": true
}
Two things worth calling out. There’s no “build output directory” field in the dashboard the way there was with Pages — Workers reads it from assets.directory here. And not_found_handling is mandatory if you want your 404.html served. Pages used to sniff your output and guess; Workers makes you say it explicitly, which is the better behaviour but will silently serve 200s for missing pages if you forget.
Then .ruby-version, containing whatever the build image ships or the version you develop against:
3.4.4
And check your .gitignore covers the Wrangler additions. Mine already had the Jekyll entries; these are the new ones:
.wrangler/
.dev.vars
The silent failure to watch for
Cloudflare reads custom headers from a _headers file, and redirects from _redirects. Both must end up inside your build output.
Jekyll skips any file or folder beginning with an underscore. So a _headers file sitting in your repo root will never reach _site, your headers will never apply, and absolutely nothing will tell you this happened. The site builds green. The headers just aren’t there.
The fix is one block in _config.yml:
include:
- _headers
- _redirects
exclude:
- README.md
- wrangler.jsonc
The exclude matters too. Jekyll copies unknown root files into the output, so without it your Wrangler config gets published at /wrangler.jsonc.
Verify it actually worked rather than assuming:
curl -sI https://your-site.example/ | grep -i content-security
Headers you couldn’t set before
This is the bit I actually wanted. A _headers file looks like this:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000
/assets/images/*
Cache-Control: public, max-age=31536000, immutable
/assets/css/*
Cache-Control: public, max-age=3600, must-revalidate
Note what’s missing from that HSTS line: includeSubDomains.
That omission is deliberate. Check your zone for subdomains containing underscores before you consider adding it — no public certificate authority will issue a certificate for a hostname with an underscore in it, so on Cloudflare those names work over HTTPS only because they ride the edge wildcard certificate. includeSubDomains tells every browser that visits your apex to refuse plain HTTP across all of them for a year, and removing the header later doesn’t undo it. Browsers cache the instruction, not the header.
It’s one of very few things in web configuration that’s genuinely hard to reverse, so ship it once you’re certain rather than as part of a migration.
Also worth thinking about: site.css and theme.js aren’t fingerprinted on this site, so a long Cache-Control on those would strand people on a stale stylesheet after a redesign. Long cache for images, short cache with revalidation for CSS and JS.
Connecting the repo
Workers & Pages → Create application → Get started next to Import a repository. Pick your repo, set the build command to bundle exec jekyll build and the deploy command to npx wrangler deploy, and add a build variable of JEKYLL_ENV = production.
One trap: the Worker name in the dashboard must exactly match the name field in wrangler.jsonc, or the build fails. It has nothing to do with your repository name. The dashboard prefills from the repo, so the path of least resistance is to name the Worker after the repo and edit wrangler.jsonc to match.
Push your wrangler.jsonc before connecting, incidentally. If Cloudflare finds no Wrangler config, it runs autoconfig, tries to detect your framework, and opens a pull request guessing at your setup.
Future-dated posts need a scheduled rebuild
Worth catching before you delete your old workflow. Jekyll won’t publish a post dated in the future until a build happens after that date, so if you write ahead, something has to rebuild the site on a timer. A GitHub Actions workflow doing Pages deploys usually has a schedule: block quietly handling this.
Workers Builds only fires on push, so that job needs rehoming. The Cloudflare-native version is a Deploy Hook plus a Cron Trigger.
Create the hook first: your Worker → Settings → Builds → Deploy Hooks → Create deploy hook. Give it a name, point it at your production branch, and copy the URL. POSTing to that URL triggers a build.
Then a second, very small Worker to do the POSTing on a schedule:
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(
fetch(env.DEPLOY_HOOK_URL, { method: "POST" })
);
},
};
With a wrangler.jsonc alongside it:
{
"name": "site-rebuild",
"main": "src/index.js",
"compatibility_date": "2026-08-18",
"triggers": {
"crons": ["10 6 * * *"]
},
"workers_dev": false
}
Store the hook URL with npx wrangler secret put DEPLOY_HOOK_URL rather than putting it in the config. Deploy Hooks carry no authentication of their own — anyone holding the URL can trigger builds on your project, so it wants the same care as any other secret.
Deploy with npx wrangler deploy. The output should confirm the schedule back to you, which is your evidence the trigger registered.
Testing it is fiddlier than it looks. npx wrangler dev --test-scheduled runs the handler locally, but local mode can’t see a secret you uploaded to Cloudflare, so the fetch quietly goes nowhere and you learn nothing. Either drop the URL into a .dev.vars file for local runs, or skip the Worker entirely and POST the hook directly:
curl -X POST "https://api.cloudflare.com/client/v4/workers/builds/deploy_hooks/YOUR-ID"
A "success": true response and a fresh build in your site Worker’s history confirms the half that can actually be misconfigured. The cron side is just Cloudflare calling the same handler on schedule.
One thing to be aware of if you do run that curl: the URL is now in your shell history in plain text, and it needs no authentication. Rotating the hook afterwards is quicker and more reliable than hunting the string through history files.
Cron expressions are UTC, same as GitHub Actions, so an existing schedule carries across unchanged. If the hook fires more than once before a build starts, the duplicates are dropped automatically.
This one is easy to forget precisely because it never announced itself — the old workflow just quietly ran every morning. The failure mode is a post that simply doesn’t appear on the day you scheduled it.
While you’re in there
Worth auditing your assets before you migrate. If like me you’ve retired articles over the years, their images are almost certainly still in the repo — comparing every file in assets/images against every reference in the source turned up 27 orphans here, roughly 9MB of it, including a couple of 2.5MB PNGs belonging to work projects that no longer have pages. Three files were still in use.
Every deploy uploads the entire output directory, so dead weight in assets is a cost you pay on every build rather than once.
The DNS cutover
Order matters here more than anywhere else in the migration.
If Cloudflare is already your registrar the zone is live and pointing at GitHub’s four A records. A Custom Domain can’t be created over an existing record at the same hostname, so those have to go first — which means a real, if brief, window where the apex resolves to nothing.
- Verify everything on the
workers.devURL first. Every page, the feed, the sitemap, a deliberate 404. - Delete the four apex
Arecords. Only those. TheMXandTXTrecords share the same name and will end your email if you get click-happy. - Worker → Settings → Domains & Routes → Add → Custom Domain.
- Wait for the certificate. An SSL error in the first few minutes is normal.
Do not delete the CNAME file from your repo yet. GitHub keeps that file and the custom domain setting in sync — if Pages rebuilds and finds no CNAME, it unsets the domain, and you get downtime before the Worker has taken over. Leave it until the migration is finished, then remove it along with Pages itself.
When you do retire Pages, it’s three things: set Source to None in the repo’s Pages settings, delete the Actions workflow, and delete CNAME. If you renamed your default branch around the same time, expect the workflow to start failing with Branch "main" is not allowed to deploy to github-pages due to environment protection rules — that’s the github-pages environment pinned to the old branch name. Deleting the workflow is the fix; adding the new branch to the protection rule just keeps a deployment alive that you no longer want.
Negative DNS caching after the cutover
This is the one to read before you start, because it looks exactly like a failed migration.
Expect your browser to report DNS_PROBE_POSSIBLE or ERR_NAME_NOT_RESOLVED after the cutover, while dig @1.1.1.1 returns the Cloudflare addresses perfectly and a curl with --resolve against those addresses comes back HTTP/2 200 with every header intact.
Both are true at once. The site is live; your resolver cached the absence of a record during that gap between deleting the old records and adding the Custom Domain. How long a negative answer sticks is governed by the zone’s SOA minimum — around half an hour on Cloudflare.
So the outage lasts under a minute and the appearance of one lasts considerably longer. Flushing the system cache isn’t always enough either, because Chrome keeps its own at chrome://net-internals/#dns, and your router may be holding a copy too.
The reliable test is your phone on mobile data. A resolver that has never seen the domain gives you the truth immediately — worth reaching for before you start unpicking DNS changes that were correct all along.
Was it worth it
Yes, though not for the reason I expected. My records were already proxied through Cloudflare, so raw speed barely moved.
What I got was control. Real security headers. Per-branch preview URLs. Rollbacks. Redirects in a text file. Any Jekyll plugin I like rather than GitHub’s approved list. And a path to adding a contact form endpoint without leaving the platform, which is where this started.
The migration itself is about fifteen minutes of work. Budget an hour for the DNS to stop lying to you.