3 Ways to Deploy Static to Cloudflare: Pages, Workers Assets or R2 from GitHub actions
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