A photo of Geoffrey Hayward

Moving from Cloudflare Pages to Workers

Published May 31, 2026

Illustration representing a Hugo website migration from Cloudflare Pages to Cloudflare Workers with Decap CMS and GitHub authentication.

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:

  • name is the Workers project name
  • compatibility_date pins the Workers runtime behaviour
  • assets.directory tells Workers where Hugo writes the built site
  • not_found_handling tells Workers to use your generated 404.html
  • build.command tells 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.directory to your Hugo output folder, usually ./public
  • set not_found_handling to 404-page if you have a generated 404.html
  • move any Pages build command into build.command
  • keep any _headers and _redirects files that Hugo already copies into public/
  • 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.directory at ./public
  • set build.command to hugo
  • 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.

Related Posts

A simple graph that says configure Terraform Cloud to assume an AWS IAM role via OIDC.

Configure Terraform Cloud to Assume an AWS IAM Role via OIDC

June 11, 2025

Computing

Here is how to configure Terraform Cloud to assume an AWS IAM role via OIDC (OpenID Connect) using only environment variables—no static AWS keys are required.

Continue reading