{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiam7quoxnbjdb3awnq5nzddgovbv7gbbgm64xymudbwae37wdxudm",
"uri": "at://did:plc:nueu5rkumgo3omtdzftnx2ff/app.bsky.feed.post/3mk425tanvl42"
},
"description": "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?\n\nUsing 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.\n\nDeploying from Cloudflare CI\n\nYou can choo",
"path": "/3-ways-to-deploy-static-to-cloudflare-pages-workers-assets-or-r2-from-github-actions/",
"publishedAt": "2026-04-22T17:40:26.000Z",
"site": "https://www.amarjanica.com",
"tags": [
"hosting static on Cloudflare",
"since my latest deployment tutorial",
"configuration is slightly different",
"https://dash.cloudflare.com/profile/api-tokens",
"https://github.com/amarjanica/demo-cloudflare-pages"
],
"textContent": "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?\n\nWorkers over Cloudflare pages\n\nUsing 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.\n\n**Deploying from Cloudflare CI**\n\nYou 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.\n\nYou can define in settings for Cloudflare CI to listen only to specific paths...\n\nDefine Cloudflare CI watch paths\n\nThat's not what I like. I want to control it from GitHub actions.\n\n**That's why I need a Cloudflare API Token**\n\nGo to https://dash.cloudflare.com/profile/api-tokens and create a new custom token.\n\nFor pages you need this:\n\nPermissions for CF Pages\n\nFor workers, some extra permissions...\n\nPermissions for CF Workers\n\nWhen deploying a CF Page that isn't created yet, you can do so with wrangler cli.\n\nExample from GH actions:\n\n\n - name: Ensure Pages project exists\n env:\n CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n run: npx wrangler pages project create ${{env.PROJECT_NAME}} --production-branch=main || true\n\n - name: Deploy\n uses: cloudflare/wrangler-action@v3\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n command: pages deploy website/build --project-name=${{env.PROJECT_NAME}} --branch main\n\n## Deploying a worker with static assets\n\nYou'll need to create a new project, and everything can be done from GitHub actions.\n\nIn my demo project, I created 2 files, workflow and the wrangler configuration.\n\nImportant part for the deployment in Github actions is this:\n\n\n - name: Deploy\n uses: cloudflare/wrangler-action@v3\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n command: deploy --config website/cf-worker-assets-wrangler.toml\n\nAnd the wrangler.toml configuration:\n\n\n name = \"demo-cloudflare-workers\"\n compatibility_date = \"2026-04-22\"\n\n [assets]\n directory = \"./build\"\n not_found_handling = \"404-page\"\n html_handling = \"drop-trailing-slash\"\n\nSimilar 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.\n\n## Deploying a worker with R2\n\nI 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.\n\nR2 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.\n\nThere's more setup involved, as in creating an R2 bucket and creating access tokens for R2. Cloudflare api token doesn't apply to r2.\n\nCreate access token for R2\n\nCreating a new R2 token will output an access key id and a secret access key. Save them to GitHub repository secrets.\n\nWorkflow file:\n\n\n - name: Sync build/ to R2\n working-directory: website\n env:\n AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}\n AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}\n AWS_DEFAULT_REGION: auto\n run: |\n aws s3 sync build/ s3://demo-cloudflare-workers-r2/ \\\n --endpoint-url \"https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com\" \\\n --delete\n\n - name: Deploy Worker\n uses: cloudflare/wrangler-action@v3\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n command: deploy --config website/cf-worker-r2-wrangler.toml\n\nWorkflow 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:\n\n\n name = \"demo-cloudflare-workers-r2\"\n main = \"./worker-r2/index.js\"\n compatibility_date = \"2026-04-22\"\n\n [[r2_buckets]]\n binding = \"ASSETS\"\n bucket_name = \"demo-cloudflare-workers-r2\"\n\nNow, R2 is called from the worker.js module as \"ASSETS\":\n\n\n export default {\n async fetch(request, env) {\n if (request.method !== \"GET\" && request.method !== \"HEAD\") {\n return new Response(\"Method not allowed\", { status: 405 });\n }\n\n const url = new URL(request.url);\n const requestedKey = resolveKey(url.pathname);\n const { object, key: resolvedKey } = await lookup(env.ASSETS, requestedKey);\n\n if (object) {\n const headers = new Headers();\n headers.set(\"Content-Type\", contentType(resolvedKey));\n headers.set(\"Cache-Control\", cacheControl(resolvedKey));\n if (object.httpEtag) headers.set(\"ETag\", object.httpEtag);\n return new Response(request.method === \"HEAD\" ? null : object.body, {\n status: 200,\n headers,\n });\n }\n\n const notFound = await env.ASSETS.get(\"404.html\");\n if (notFound) {\n const headers = new Headers();\n headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n headers.set(\"Cache-Control\", \"public, max-age=0, must-revalidate\");\n if (notFound.httpEtag) headers.set(\"ETag\", notFound.httpEtag);\n return new Response(request.method === \"HEAD\" ? null : notFound.body, {\n status: 404,\n headers,\n });\n }\n\n return new Response(\"Not Found\", { status: 404 });\n },\n };\n\nWorker 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.\n\n### Troubleshooting\n\nIf 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.\n\nGitHub repo: https://github.com/amarjanica/demo-cloudflare-pages",
"title": "3 Ways to Deploy Static to Cloudflare: Pages, Workers Assets or R2 from GitHub actions",
"updatedAt": "2026-04-22T17:40:26.981Z"
}