Skip to content

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 offers two approaches for deploying static sites with optional server-side functionality:

Cloudflare PagesWorkers Static Assets- `wrangler pages deploy`- Simpler setup- Auto 404/SPA handling- Preview deployments- pages.dev subdomain- `wrangler deploy`- wrangler.jsonc config- Durable Objects- Cron Triggers- Logpush- Custom routes- Preview (version) URLsmigrate whenyou need more features
Use CaseRecommendation
Simple static site or blogPages - easier setup, free preview deployments
Documentation sitePages - works with Astro/Starlight
Need Durable ObjectsWorkers Static Assets - required
Need Cron TriggersWorkers Static Assets - required
Need Logpush/Tail WorkersWorkers Static Assets - required
Multi-platform deploymentWorkers Static Assets - more flexible routing
Custom domain routing logicWorkers Static Assets - routes array in config
FeaturePagesWorkers 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.

Push/PRBun + D2 + Cachesbun run buildwrangler pages deploy

The workflow runs on:

  • Push to main branch
  • Pull requests to main branch
  • Manual trigger via workflow_dispatch

Only deployments to production occur on main branch pushes or manual triggers.

concurrency:
group: "pages"
cancel-in-progress: true

A new run cancels any in-progress deployment, so only one runs at a time.

  • Runner: ubuntu-latest
  • Package Manager: Bun
  • Diagram Rendering: D2 CLI
  • Deployment Target: Cloudflare Pages
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
  • fetch-depth: 2 fetches the last 2 commits (needed for git information extraction)
- 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-
  • Caches node_modules and Bun’s install cache; the key includes the OS, the lockfile hash and the package.json hash, so it invalidates when dependencies change
- 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
- 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
- name: Install project dependencies
run: bun install
  • Hits the Bun cache from step 3 when the lockfile is unchanged
- 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-d2 plugin during the Astro build
  • On a self-hosted runner behind one shared IP, install.sh’s call to api.github.com to resolve the version is a liability; download the pinned release tarball instead, as the Forgejo port of this workflow does
- 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 d2 is missing from PATH, then retries bun run build for transient failures, up to 3 times with 30-second delays
- 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

Only 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
- 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 deploy as deployment metadata; wranglerVersion pins Wrangler for reproducibility

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.

PushBuild + Upload Artifactwrangler deployCache Purgeproxied zone

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 (like dist)
  • routes: Explicit custom domain configuration
- 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"

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"]}'

Configure these secrets in your GitHub repository settings:

Secret NameDescription
CLOUDFLARE_WRANGLER_TOKENCloudflare API token with Pages:Edit permissions
CLOUDFLARE_ACCOUNT_IDYour Cloudflare account ID
Secret NameDescription
CLOUDFLARE_ZONE_IDZone ID for your domain (for cache purge)
CLOUDFLARE_CACHE_PURGE_TOKENAPI token with Zone:Cache Purge permissions
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: bun

If you need to migrate an existing Pages project to Workers Static Assets:

  1. Create wrangler.jsonc with your asset directory and routes
  2. Update workflow to use wrangler deploy instead of wrangler pages deploy
  3. Add cache purge step if using proxied zones
  4. Update DNS or configure custom domains via the routes array

For complete migration guidance, see Cloudflare’s migration guide.

  • 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 deploy exits 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 on if: 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.
  • install.sh resolves the D2 version through api.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 the restore-keys prefix carries state forward.
  • fetch-depth: 2 in the checkout exists for step 7’s git 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-artifact and download-artifact actions fail on Forgejo Actions, so the Forgejo port merges the build and deploy jobs instead of passing dist between them (details).
PathWhat 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.jsonThe dependency cache keys in step 3
~/.localThe D2 CLI install prefix, cached by version