GitHub Actions with Cloudflare
Two paths deploy a static site from GitHub-hosted Actions runners to Cloudflare: Pages, which takes wrangler pages deploy and no cache purge, and Workers static assets, which takes wrangler deploy and a wrangler.jsonc. Prerequisites are a Cloudflare account with the tokens listed under Required secrets, a build that writes dist, and the Bun and D2 toolchain the steps set up.
Cloudflare Pages vs Workers static assets
Section titled “Cloudflare Pages vs Workers static assets”Cloudflare offers two approaches for deploying static sites with optional server-side functionality:
When to use each approach
Section titled “When to use each approach”| Use Case | Recommendation |
|---|---|
| Simple static site or blog | Pages - easier setup, free preview deployments |
| Documentation site | Pages - works with Astro/Starlight |
| Need Durable Objects | Workers Static Assets - required |
| Need Cron Triggers | Workers Static Assets - required |
| Need Logpush/Tail Workers | Workers Static Assets - required |
| Multi-platform deployment | Workers Static Assets - more flexible routing |
| Custom domain routing logic | Workers Static Assets - routes array in config |
Compatibility matrix (key differences)
Section titled “Compatibility matrix (key differences)”| Feature | Pages | Workers Static Assets |
|---|---|---|
| Static asset serving | ✅ | ✅ |
| Server-side functions | ✅ (Pages Functions) | ✅ (Worker script) |
| Preview deployments | ✅ | ✅ (version URLs) |
| Durable Objects | ❌ | ✅ |
| Cron Triggers | ❌ | ✅ |
| Logpush | ❌ | ✅ |
| Tail Workers | ❌ | ✅ |
| Queue consumers | ❌ | ✅ |
*.pages.dev subdomain | ✅ | ❌ |
*.workers.dev subdomain | ❌ | ✅ |
| Cache purge needed | ❌ | 🟡 (for proxied zones) |
For the complete matrix, see Cloudflare’s official documentation.
Cloudflare Pages workflow
Section titled “Cloudflare Pages workflow”Workflow overview
Section titled “Workflow overview”The workflow runs on:
- Push to
mainbranch - Pull requests to
mainbranch - Manual trigger via
workflow_dispatch
Only deployments to production occur on main branch pushes or manual triggers.
Workflow configuration
Section titled “Workflow configuration”Concurrency control
Section titled “Concurrency control”concurrency: group: "pages" cancel-in-progress: trueA new run cancels any in-progress deployment, so only one runs at a time.
Job environment
Section titled “Job environment”- Runner:
ubuntu-latest - Package Manager: Bun
- Diagram Rendering: D2 CLI
- Deployment Target: Cloudflare Pages
Step-by-step breakdown
Section titled “Step-by-step breakdown”1. Repository checkout
Section titled “1. Repository checkout”- name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 2fetch-depth: 2fetches the last 2 commits (needed for git information extraction)
2. Bun environment setup
Section titled “2. Bun environment setup”- name: Setup Bun environment uses: oven-sh/setup-bun@v13. Dependency caching
Section titled “3. Dependency caching”Bun dependencies cache
Section titled “Bun dependencies cache”- name: Cache Bun dependencies uses: actions/cache@v4 id: bun-cache with: path: | ~/.bun/install/cache node_modules key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/package.json') }} restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}- ${{ runner.os }}-bun-- Caches
node_modulesand Bun’s install cache; the key includes the OS, the lockfile hash and the package.json hash, so it invalidates when dependencies change
D2 CLI cache
Section titled “D2 CLI cache”- name: Cache D2 CLI uses: actions/cache@v4 id: d2-cache with: path: ~/.local key: ${{ runner.os }}-d2-v0.7.1 restore-keys: | ${{ runner.os }}-d2-- The key carries the D2 version (
v0.7.1), so bumping the version invalidates the cache
Astro build cache
Section titled “Astro build cache”- name: Cache Astro build uses: actions/cache@v4 with: path: | .astro dist key: ${{ runner.os }}-astro-${{ github.sha }} restore-keys: | ${{ runner.os }}-astro-- Keyed on the commit SHA
4. Dependency installation
Section titled “4. Dependency installation”- name: Install project dependencies run: bun install- Hits the Bun cache from step 3 when the lockfile is unchanged
5. D2 CLI installation
Section titled “5. D2 CLI installation”- name: Install D2 CLI for diagram rendering if: steps.d2-cache.outputs.cache-hit != 'true' run: curl -fsSL https://d2lang.com/install.sh | sh- Only runs if the D2 cache missed; D2 is used by the
remark-d2plugin during the Astro build - On a self-hosted runner behind one shared IP,
install.sh’s call toapi.github.comto resolve the version is a liability; download the pinned release tarball instead, as the Forgejo port of this workflow does
6. Build with retry logic
Section titled “6. Build with retry logic”- name: Build project with retry env: MAX_ATTEMPTS: 3 RETRY_INTERVAL: 30 PATH: ${{ github.workspace }}/.local/bin:~/.local/bin:/usr/local/bin:/usr/bin:/bin run: | export PATH="$HOME/.local/bin:$PATH" d2 --version || { echo "D2 CLI not found"; exit 1; }
attempt=1 until bun run build || [ $attempt -eq $MAX_ATTEMPTS ]; do echo "Build attempt $attempt failed. Retrying in $RETRY_INTERVAL seconds..." sleep $RETRY_INTERVAL attempt=$((attempt + 1)) done
if [ $attempt -eq $MAX_ATTEMPTS ] && ! bun run build; then echo "Build failed after $MAX_ATTEMPTS attempts." exit 1 fi- Fails fast if
d2is missing from PATH, then retriesbun run buildfor transient failures, up to 3 times with 30-second delays
7. Git information extraction
Section titled “7. Git information extraction”- name: Get git information id: git-info if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' run: | echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "COMMIT_MESSAGE=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT echo "BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT echo "COMMIT_DIRTY=$(if [[ -n $(git status -s) ]]; then echo true; else echo false; fi)" >> $GITHUB_OUTPUTOnly runs for deployments. Extracts:
- COMMIT_HASH: Full commit SHA
- COMMIT_MESSAGE: Last commit subject line (avoids multiline issues with wrangler)
- BRANCH_NAME: Current branch name
- COMMIT_DIRTY: Whether working directory has uncommitted changes
8. Cloudflare Pages deployment
Section titled “8. Cloudflare Pages deployment”- name: Deploy to Cloudflare Pages if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_WRANGLER_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy dist --project-name=my-site --commit-hash "${{ steps.git-info.outputs.COMMIT_HASH }}" --commit-message "${{ steps.git-info.outputs.COMMIT_MESSAGE }}" --branch "${{ steps.git-info.outputs.BRANCH_NAME }}" --commit-dirty ${{ steps.git-info.outputs.COMMIT_DIRTY }} wranglerVersion: '4.28.1' packageManager: bun- Passes the step 7 git values to
wrangler pages deployas deployment metadata;wranglerVersionpins Wrangler for reproducibility
Workers static assets workflow
Section titled “Workers static assets workflow”This site deploys this way, from Forgejo Actions rather than GitHub Actions; the port, including why the build and deploy jobs are merged there, is in Porting a GitHub Actions workflow to Forgejo Actions.
For projects requiring Durable Objects, Cron Triggers, or other Workers-only features, use the Workers Static Assets approach.
Project configuration
Section titled “Project configuration”Workers Static Assets requires a wrangler.jsonc (or wrangler.toml) configuration file:
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "my-site", "compatibility_date": "2025-12-06", "main": "src/index.ts", // Optional: Worker script "assets": { "directory": "./dist" }, "routes": [ { "pattern": "example.com", "custom_domain": true }, { "pattern": "www.example.com", "custom_domain": true } ]}Key differences from Pages:
main: Optional entry point for Worker script (handle API routes, etc.)assets.directory: Points to your build output (likedist)routes: Explicit custom domain configuration
Deploy command
Section titled “Deploy command”- name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_WRANGLER_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy # Note: not "pages deploy" packageManager: bun wranglerVersion: "4.53.0"Cache purge (required for proxied zones)
Section titled “Cache purge (required for proxied zones)”Unlike Pages, Workers deployments to proxied zones may require manual cache purging:
- name: Purge Cloudflare Cache if: success() run: | curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_CACHE_PURGE_TOKEN }}" \ -H "Content-Type: application/json" \ --data '{"hosts":["example.com", "www.example.com"]}'Required secrets
Section titled “Required secrets”Configure these secrets in your GitHub repository settings:
For Cloudflare Pages
Section titled “For Cloudflare Pages”| Secret Name | Description |
|---|---|
CLOUDFLARE_WRANGLER_TOKEN | Cloudflare API token with Pages:Edit permissions |
CLOUDFLARE_ACCOUNT_ID | Your Cloudflare account ID |
Additional for Workers static assets
Section titled “Additional for Workers static assets”| Secret Name | Description |
|---|---|
CLOUDFLARE_ZONE_ID | Zone ID for your domain (for cache purge) |
CLOUDFLARE_CACHE_PURGE_TOKEN | API token with Zone:Cache Purge permissions |
Full workflow examples
Section titled “Full workflow examples”name: Build and Deploy
on: push: branches: [main] pull_request: branches: [main] workflow_dispatch:
concurrency: group: "pages" cancel-in-progress: true
jobs: deploy: runs-on: ubuntu-latest
steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 2
- name: Setup Bun environment uses: oven-sh/setup-bun@v1
- name: Cache Bun dependencies uses: actions/cache@v4 id: bun-cache with: path: | ~/.bun/install/cache node_modules key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/package.json') }} restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}- ${{ runner.os }}-bun-
- name: Cache D2 CLI uses: actions/cache@v4 id: d2-cache with: path: ~/.local key: ${{ runner.os }}-d2-v0.7.1 restore-keys: | ${{ runner.os }}-d2-
- name: Cache Astro build uses: actions/cache@v4 with: path: | .astro dist key: ${{ runner.os }}-astro-${{ github.sha }} restore-keys: | ${{ runner.os }}-astro-
- name: Install project dependencies run: bun install
- name: Install D2 CLI for diagram rendering if: steps.d2-cache.outputs.cache-hit != 'true' run: curl -fsSL https://d2lang.com/install.sh | sh
- name: Build project with retry env: MAX_ATTEMPTS: 3 RETRY_INTERVAL: 30 run: | export PATH="$HOME/.local/bin:$PATH" d2 --version || { echo "D2 CLI not found"; exit 1; }
attempt=1 until bun run build || [ $attempt -eq $MAX_ATTEMPTS ]; do echo "Build attempt $attempt failed. Retrying in $RETRY_INTERVAL seconds..." sleep $RETRY_INTERVAL attempt=$((attempt + 1)) done
if [ $attempt -eq $MAX_ATTEMPTS ] && ! bun run build; then echo "Build failed after $MAX_ATTEMPTS attempts." exit 1 fi
- name: Get git information id: git-info if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' run: | echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "COMMIT_MESSAGE=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT echo "BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT echo "COMMIT_DIRTY=$(if [[ -n $(git status -s) ]]; then echo true; else echo false; fi)" >> $GITHUB_OUTPUT
- name: Deploy to Cloudflare Pages if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_WRANGLER_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy dist --project-name=my-site --commit-hash "${{ steps.git-info.outputs.COMMIT_HASH }}" --commit-message "${{ steps.git-info.outputs.COMMIT_MESSAGE }}" --branch "${{ steps.git-info.outputs.BRANCH_NAME }}" --commit-dirty ${{ steps.git-info.outputs.COMMIT_DIRTY }} wranglerVersion: '4.28.1' packageManager: bunname: Build and Deploy to Workers
on: push: branches: [main] pull_request: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4
- name: Setup Bun environment uses: oven-sh/setup-bun@v1
- name: Cache dependencies uses: actions/cache@v4 with: path: | ~/.bun/install/cache node_modules key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb', '**/package.json') }} restore-keys: | ${{ runner.os }}-bun-
- name: Install project dependencies run: bun install
- name: Build project run: bun run build
- name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist retention-days: 7
deploy: needs: build if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4
- name: Setup Bun environment uses: oven-sh/setup-bun@v1
- name: Install dependencies run: bun install
- name: Download built artifacts uses: actions/download-artifact@v4 with: name: dist path: dist
- name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_WRANGLER_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy packageManager: bun
purge-cache: needs: deploy if: success() runs-on: ubuntu-latest steps: - name: Purge Cloudflare Cache run: | curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_CACHE_PURGE_TOKEN }}" \ -H "Content-Type: application/json" \ --data '{"hosts":["example.com"]}'Migrating from Pages to Workers
Section titled “Migrating from Pages to Workers”If you need to migrate an existing Pages project to Workers Static Assets:
- Create
wrangler.jsoncwith your asset directory and routes - Update workflow to use
wrangler deployinstead ofwrangler pages deploy - Add cache purge step if using proxied zones
- Update DNS or configure custom domains via the
routesarray
For complete migration guidance, see Cloudflare’s migration guide.
Verification
Section titled “Verification”- The build step fails fast when D2 is missing -
d2 --version || { echo "D2 CLI not found"; exit 1; }- before it spends a retry on it. - A green Pages run means the upload landed:
wrangler pages deployexits non-zero on a failed deploy, and the deployment carries the commit hash and subject line extracted in step 7 as its metadata. - A green Workers run means the deploy step (
command: deploy) succeeded, because the purge job is gated onif: success()and runs only after it. - Pages needs no cache check: Cloudflare invalidates the cache as part of the deployment. On a proxied Workers zone, the purge call is the step that stops the previous deployment being served.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”install.shresolves the D2 version throughapi.github.com, which rate-limits a self-hosted runner behind one shared IP; download the pinned release tarball instead (Forgejo port).- The Astro build cache is keyed on
github.sha, so no run ever hits its exact cache; only therestore-keysprefix carries state forward. fetch-depth: 2in the checkout exists for step 7’sgit log -1, not for the build.- Pages has no Durable Objects, Cron Triggers, Logpush or Tail Workers; the compatibility matrix above is what decides the migration.
- The
upload-artifactanddownload-artifactactions fail on Forgejo Actions, so the Forgejo port merges the build and deploy jobs instead of passingdistbetween them (details).
File reference
Section titled “File reference”| Path | What it is |
|---|---|
The workflow YAML under .github/workflows/ | The Pages or Workers pipeline shown below, whichever variant is in use |
wrangler.jsonc (or wrangler.toml) | Workers static assets configuration: name, assets.directory, routes, optional main |
dist/ | The build output the deploy command uploads, and the directory the Astro cache step caches |
bun.lockb and package.json | The dependency cache keys in step 3 |
~/.local | The D2 CLI install prefix, cached by version |
Related docs
Section titled “Related docs”- Porting a GitHub Actions workflow to Forgejo Actions - the self-hosted runner replacement for the workflows on this page, including the merged build-and-deploy job and the pinned D2 tarball.
- The Forgejo Actions runner - the runner that executes the Forgejo variant, and the labels its
runs-on:needs.