External Publication
Visit Post

3 Ways to Deploy Static to Cloudflare: Pages, Workers Assets or R2 from GitHub actions

Ana's Dev Scribbles April 22, 2026
Source

I went through all stages of hosting static on Cloudflare. There's been some change on Cloudflare since my latest deployment tutorial. Cloudflare now favors workers over pages. You can see that when you create a new project...see the little, almost invisible option in the footer?

Workers over Cloudflare pages

Using workers is free for static asset requests, to migrate you need to create a new project, wire your custom domains, and wrangler.toml configuration is slightly different.

Deploying from Cloudflare CI

You can choose to use Cloudflare CI and connect to it your github repo. Every time there's a change, Cloudflare will pick it up and trigger a new deployment.

You can define in settings for Cloudflare CI to listen only to specific paths...

Define Cloudflare CI watch paths

That's not what I like. I want to control it from GitHub actions.

That's why I need a Cloudflare API Token

Go to https://dash.cloudflare.com/profile/api-tokens and create a new custom token.

For pages you need this:

Permissions for CF Pages

For workers, some extra permissions...

Permissions for CF Workers

When deploying a CF Page that isn't created yet, you can do so with wrangler cli.

Example from GH actions:

- name: Ensure Pages project exists
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  run: npx wrangler pages project create ${{env.PROJECT_NAME}} --production-branch=main || true

- name: Deploy
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: pages deploy website/build --project-name=${{env.PROJECT_NAME}} --branch main

Deploying a worker with static assets

You'll need to create a new project, and everything can be done from GitHub actions.

In my demo project, I created 2 files, workflow and the wrangler configuration.

Important part for the deployment in Github actions is this:

- name: Deploy
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: deploy --config website/cf-worker-assets-wrangler.toml

And the wrangler.toml configuration:

name = "demo-cloudflare-workers"
compatibility_date = "2026-04-22"

[assets]
directory = "./build"
not_found_handling = "404-page"
html_handling = "drop-trailing-slash"

Similar to pages toml, except static is handled by worker assets. not_found_handling can be a 404-page or a single-page-application, depending what's your build like. I don't like trailing slashes, so for html_handling I chose to drop a trailing slash.

Deploying a worker with R2

I had to migrate from the worker assets setup because it was timeouting for deployment of 30k+ documents. I still wanted to stick with Cloudflare to handle my static, so I migrated from assets to R2.

R2 is an S3 equivalent simple storage. Everything you know from S3 applies to R2. You can even use aws cli to deploy to cf r2.

There's more setup involved, as in creating an R2 bucket and creating access tokens for R2. Cloudflare api token doesn't apply to r2.

Create access token for R2

Creating a new R2 token will output an access key id and a secret access key. Save them to GitHub repository secrets.

Workflow file:

- name: Sync build/ to R2
  working-directory: website
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
    AWS_DEFAULT_REGION: auto
  run: |
    aws s3 sync build/ s3://demo-cloudflare-workers-r2/ \
      --endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \
      --delete

- name: Deploy Worker
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: deploy --config website/cf-worker-r2-wrangler.toml

Workflow file syncs build directory to R2 storage, and the worker is used to proxy R2 objects. Important part of the worker process is the wrangler.toml and linking worker with r2:

name = "demo-cloudflare-workers-r2"
main = "./worker-r2/index.js"
compatibility_date = "2026-04-22"

[[r2_buckets]]
binding = "ASSETS"
bucket_name = "demo-cloudflare-workers-r2"

Now, R2 is called from the worker.js module as "ASSETS":

export default {
  async fetch(request, env) {
    if (request.method !== "GET" && request.method !== "HEAD") {
      return new Response("Method not allowed", { status: 405 });
    }

    const url = new URL(request.url);
    const requestedKey = resolveKey(url.pathname);
    const { object, key: resolvedKey } = await lookup(env.ASSETS, requestedKey);

    if (object) {
      const headers = new Headers();
      headers.set("Content-Type", contentType(resolvedKey));
      headers.set("Cache-Control", cacheControl(resolvedKey));
      if (object.httpEtag) headers.set("ETag", object.httpEtag);
      return new Response(request.method === "HEAD" ? null : object.body, {
        status: 200,
        headers,
      });
    }

    const notFound = await env.ASSETS.get("404.html");
    if (notFound) {
      const headers = new Headers();
      headers.set("Content-Type", "text/html; charset=utf-8");
      headers.set("Cache-Control", "public, max-age=0, must-revalidate");
      if (notFound.httpEtag) headers.set("ETag", notFound.httpEtag);
      return new Response(request.method === "HEAD" ? null : notFound.body, {
        status: 404,
        headers,
      });
    }

    return new Response("Not Found", { status: 404 });
  },
};

Worker disallows anything other than GET and HEAD, fetches objects and sets an ETag header. In case an object is not found, it falls back to the docusaurus 404.html page.

Troubleshooting

If you get a fatal error: An HTTP Client raised an unhandled exception: Invalid header value, that's probably because you pasted R2 credentials with a new line character in secrets. Update, delete the newline character that GitHub creates, try again.

GitHub repo: https://github.com/amarjanica/demo-cloudflare-pages

Discussion in the ATmosphere

Loading comments...