Skip to content

GitHub Actions with Cloudflare


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.