Moving from Cloudflare Pages to Workers
Published May 31, 2026
I moved my Hugo site from Cloudflare Pages to Cloudflare Workers. The migration was mostly straightforward, but a few small details were worth writing down.
I recently moved this Hugo site from Cloudflare Pages to Cloudflare Workers.
The site is mostly static, but not completely. Hugo builds the pages, Decap CMS handles editing, and GitHub OAuth is needed for CMS login. That made the migration a good test of the newer Workers static assets model: keep the static site simple, then add dynamic routes only where the site actually needs them.
If you are moving a Hugo site from Pages to Workers, this is the path I followed. Most of it will also apply to other static site generators. The important parts are:
- the site builds into a directory, usually
public/ - Wrangler deploys that directory as Workers static assets
- a Worker script is optional, and only needed for dynamic routes
Why Move?
Cloudflare Pages is still a good product. For a long time it was the obvious Cloudflare choice for static sites.
But Cloudflare’s centre of gravity has shifted toward Workers. Static assets can now be served directly from Workers, and that gives a site one deployment model for both static files and small bits of server-side logic.
For this site, that was the appeal. I did not want Pages for the static files and AWS Lambda for GitHub OAuth. Workers let me keep those pieces together.
Add Wrangler Configuration
The first step is to add a wrangler.jsonc file in the project root.
For a plain Hugo site, the minimum setup is small:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-hugo-site",
"compatibility_date": "2026-05-24",
"assets": {
"directory": "./public",
"not_found_handling": "404-page"
},
"build": {
"command": "hugo"
}
}
There are only a few moving parts here:
nameis the Workers project namecompatibility_datepins the Workers runtime behaviourassets.directorytells Workers where Hugo writes the built sitenot_found_handlingtells Workers to use your generated404.htmlbuild.commandtells Cloudflare how to build the site before deployment
For a normal Hugo site, hugo writes to public/, so assets.directory points
at ./public. Workers then serves that directory as static assets.
Cloudflare documents this as Workers static assets. If you are moving an existing Pages project, their Pages to Workers migration guide is also worth keeping open.
Deploy with:
npx wrangler deploy
If you already have wrangler in package.json, you can wrap deployment in an
npm script:
{
"scripts": {
"build": "hugo",
"deploy": "wrangler deploy"
}
}
That is the generic version. It is the place I would start for any Hugo site on Workers.
My Setup
My site needs a slightly larger build because it compiles Sass, generates redirects, runs Hugo, and purges unused CSS.
In package.json, that looks like this:
{
"scripts": {
"build": "npm run sass && hugo && npm run purgecss",
"redirects": "node ./generateRedirects.js",
"deploy": "wrangler deploy"
}
}
Then wrangler.jsonc calls those scripts from build.command:
{
"build": {
"command": "npm run redirects && npm run build"
}
}
That means Cloudflare runs redirect generation first, then runs the normal site build. The site build itself handles Sass, Hugo, and PurgeCSS.
If you are starting from scratch, do not copy those extra steps unless you need
them. Start with hugo as the build command, then add more only when the site
actually has more to do.
Add a Worker Only When You Need One
A static Hugo site does not need a Worker script. The assets configuration can
stand on its own.
My site does use a Worker script because Decap CMS needs GitHub OAuth endpoints. In that case the configuration also points at the Worker entry file:
{
"main": "./src/worker.js",
"assets": {
"binding": "ASSETS",
"directory": "./public",
"not_found_handling": "404-page"
}
}
When a Worker script is present, the static asset handler still does the heavy lifting. The Worker only needs to handle routes that are actually dynamic.
For example:
if (url.pathname === "/auth") {
return handleAuth(request, env);
}
if (url.pathname === "/callback") {
return handleCallback(request, env);
}
return env.ASSETS.fetch(request);
That final line is the important fallback. It hands normal page, image, CSS, and JavaScript requests back to the static asset binding.
Tell Workers Which Routes Must Run First
One difference from a purely static deployment is routing. If a route does not
exist as a file in public/, but should be handled by your Worker, add it to
run_worker_first.
For my site that looks like this:
{
"assets": {
"binding": "ASSETS",
"directory": "./public",
"not_found_handling": "404-page",
"run_worker_first": [
"/auth",
"/callback"
]
}
}
This is easy to miss. Without it, a request such as /auth can be treated as a
missing static asset instead of reaching your Worker route.
If your site is only static, you probably do not need run_worker_first at all.
Worker Redirects
You do not need Worker code for ordinary redirects.
The Plain _redirects File
Workers static assets supports the same _redirects file used by Cloudflare
Pages. Put a plain text file named _redirects in the static asset directory
that gets deployed with your site. For the simple Hugo setup above, that means
the file needs to end up at:
public/_redirects
In a Hugo project, the easiest way to do that is usually to put the source file at:
static/_redirects
Hugo will copy it into public/_redirects during the build.
The format is one redirect per line:
/old-page/ /new-page/ 301
/dashboard /admin/ 302
/dashboard/ /admin/ 302
The first path is the request path. The second path is the destination. The final number is the HTTP status code.
Cloudflare documents the syntax in the
Pages redirects documentation,
and the
Pages to Workers migration guide
confirms that _redirects files are supported by Workers static assets too.
So the boring version is: create static/_redirects, let Hugo copy it to
public/_redirects, and deploy.
My Decap CMS Setup
My setup has one extra step because I had decided to manage redirects through Decap CMS.
In the CMS config, redirects are exposed as an editable configuration file:
collections:
- name: 'configuration'
label: 'Configuration'
files:
- file: "data/redirects.json"
label: "Redirects"
name: "redirects"
fields:
- label: "Redirects"
name: "redirects"
widget: "list"
fields:
- { label: "Source", name: source, widget: string }
- { label: "Destination", name: destination, widget: string }
- label: "Code"
name: code
widget: select
options:
- { label: "Permanent (301)", value: "301" }
- { label: "Temporary (302)", value: "302" }
- { label: "Notes", name: notes, widget: string }
That gives me a form in Decap CMS rather than asking me to edit _redirects
directly.
The data file it edits looks like this:
{
"redirects": [
{
"source": "/dashboard",
"destination": "/admin/",
"code": "302",
"notes": "The address of the old admin panel to the new one."
}
]
}
A small build script then turns that JSON into the _redirects file Cloudflare
expects:
const redirectLines = jsonData.redirects
.map(r => `${r.source} ${r.destination} ${r.code}`)
.join('\n');
fs.writeFile(outputFilePath, redirectLines, 'utf8', err => {
if (err) {
console.error('Error writing the _redirects file:', err);
}
});
The generated output is the same plain text format as before:
/dashboard /admin/ 302
That still works with Workers static assets, but it means the generated
_redirects file must exist before Hugo builds the site. I just had to make
sure the Worker build still runs:
npm run redirects
before the Hugo build.
If you are migrating from Pages, this is a useful thing to check. Your site may have small build-time habits that are not obvious from the hosting dashboard.
In my case, the redirect rules were not in a handwritten static/_redirects
file. They were generated from CMS data, so the build command had to preserve
that order.
CMS Login Was the Real Dynamic Part
Decap CMS was still using an external AWS Lambda for GitHub OAuth.
That felt odd once the site was on Workers. The only dynamic part of the website was living somewhere else, even though the site now had a Worker runtime next to it.
So the Worker now handles two small endpoints:
/auth/callback
Those start the GitHub OAuth flow, exchange the returned code for a token, and pass that token back to Decap CMS.
One small trap: the repository is private. GitHub login worked, but Decap then reported that the repository could not be found. The fix was not the repository name. It was the OAuth scope:
auth_scope: repo
For a public repository, a narrower scope may be enough. For this private repo,
Decap needed repo.
I am planning to write a post about this, so stay tuned via the RSS feed.
Headers Did Not Need Code
The old Pages setup had code to modify response headers.
For this site, that was overkill. A static _headers file does the job.
Like _redirects, the _headers file needs to be included in the deployed
static assets. For a Hugo site, that usually means placing it here:
static/_headers
Hugo then copies it to:
public/_headers
The file starts with a path pattern, followed by the headers to apply to matching responses. My site applies these headers to every path:
/*
Content-Security-Policy: upgrade-insecure-requests
Strict-Transport-Security: max-age=1000
X-Xss-Protection: 1; mode=block
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()
Cloudflare documents the format in the
Pages headers documentation,
and the
Pages to Workers migration guide
notes that _headers files are supported by Workers static assets too.
After moving it, I checked the site with
Security Headers.
That currently gives https://geoffrey.run an A+ score.
While moving the file, I also trimmed a few old Permissions-Policy directives
that Chromium no longer recognises. They were not breaking anything, but they
made the console noisy.
CMS Images Needed Stable Originals
This was the last awkward detail.
Decap CMS expects global uploads in assets/images to be visible at
/images/....
Hugo’s asset pipeline was doing its job and publishing processed files with names like:
/images/example_hu_abc123.jpg
That is good for the site. It is less helpful for the CMS media browser, which wanted the original stable filename.
The fix was to mount the same folder twice in Hugo: once as assets, and once as
static files. In this site, that configuration lives in
config/_default/config.yaml:
module:
mounts:
- source: "assets/images"
target: "static/images"
- source: "assets"
target: "assets"
Now the site can still use processed images, while Decap CMS can see the
original /images/example.jpg path.
If you are not using Decap CMS, you probably do not need this. It is a CMS compatibility fix, not a Workers requirement.
Local Development
For normal writing and theme work, I still use Hugo directly:
hugo server
For testing the Worker deployment shape locally, build the site and then run Wrangler:
npm run redirects
npm run build
npx wrangler dev
If your site has no redirect generation step, skip the first npm run redirects command.
This is worth doing before deploying if you have Worker routes. It catches
mistakes where a dynamic route is missing from run_worker_first, or where the
Worker fallback to env.ASSETS.fetch(request) is wrong.
Deleting the Old Pages Project from Windows
Once the Worker version was live, I wanted to delete the old Cloudflare Pages project.
There is one catch. Cloudflare documents a
known issue with deleting Pages projects that have a high number of deployments.
The workaround is to delete deployments first with
wrangler pages deployment delete, then delete the project afterwards. The
documented loop uses Unix shell tools such as jq, grep, and wc.
On Windows, I used PowerShell instead.
First, confirm Wrangler can see the old Pages project:
npx wrangler pages deployment list --project-name <PROJECT_NAME> --json
Then run this from PowerShell:
$project = "<PROJECT_NAME>"
$activeProductionDeployment = ""
$deleted = 0
while ($true) {
$deployments = npx wrangler pages deployment list --project-name $project --json |
ConvertFrom-Json
$toDelete = @(
$deployments |
Where-Object { $_.Id -ne $activeProductionDeployment }
)
if ($toDelete.Count -eq 0) {
Write-Output "Done. Active production deployment: $activeProductionDeployment"
break
}
Write-Output "Deleting $($toDelete.Count) deployment(s)..."
foreach ($deployment in $toDelete) {
$output = npx wrangler pages deployment delete $deployment.Id `
--project-name $project `
--force 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $output -match "Successfully deleted") {
$deleted++
continue
}
if ($output -match "active production deployment") {
$activeProductionDeployment = $deployment.Id
Write-Output "Skipping active production deployment $activeProductionDeployment"
continue
}
Write-Output "Could not delete deployment $($deployment.Id)"
Write-Output $output
}
}
Write-Output "Deleted $deleted deployment(s)."
This does the same thing as Cloudflare’s shell script:
- list the deployments for the Pages project
- delete each deployment with
--force - remember the active production deployment, because Cloudflare will not delete that one directly
- loop until only the active production deployment remains
After that, delete the Pages project from the dashboard:
Workers & Pages > Pages project > Settings > General > Delete project
If the dashboard still refuses, wait a minute and refresh the project page. In my case, once the old deployments were gone, the project deletion worked.
The active production deployment does not need to be deleted separately. It is removed when the Pages project is deleted.
Migration Checklist
If you are moving from Cloudflare Pages to Workers, I would check these in order:
- add
wrangler.jsonc - set
assets.directoryto your Hugo output folder, usually./public - set
not_found_handlingto404-pageif you have a generated404.html - move any Pages build command into
build.command - keep any
_headersand_redirectsfiles that Hugo already copies intopublic/ - add a Worker script only for dynamic routes
- add those dynamic routes to
run_worker_first - test with
npx wrangler dev - deploy with
npx wrangler deploy - delete the old Pages project only after the Workers deployment is serving the site correctly
If you are starting a new Workers site, the list is shorter:
- create the Hugo site
- add
wrangler.jsonc - point
assets.directoryat./public - set
build.commandtohugo - deploy with Wrangler
Done
The migration was not a dramatic rewrite. It was a collection of small assumptions made visible:
- redirects need to be generated before Hugo runs
- Decap CMS needs an OAuth endpoint
- private GitHub repos need the right scope
- Worker routes need to run before static asset fallback
- CMS previews sometimes need original files, not just optimized ones
- old Pages projects may need deployment cleanup before deletion
Cloudflare Workers now hosts the static site and the small amount of dynamic code beside it. That feels like a better shape: fewer moving parts, and the moving parts are closer to the site they support.
The main lesson is that Workers does not have to make a static site complicated. Start with static assets. Add a Worker script only when the site needs one.