Cloudflare Pages CI Setup
Set up Cloudflare Pages deployment with GitHub Actions workflows for static sites. Supports production deploys, PR preview deploys, and named preview branches.
Step 1: Gather Project Info
Identify the project's build setup:
cat package.json
ls .github/workflows/ 2>/dev/null
cat wrangler.toml 2>/dev/null
Determine: package manager (pnpm/npm/yarn), build command, output directory (dist/, build/, out/), base path (root / or subpath like /pj/project-name/).
Step 2: Ask User Preferences
- Cloudflare Pages project name (used in
--project-name) - Which workflows: Main only, Main + PR previews, Main + PR + named previews
- Base path: root
/or specific subpath - IFTTT notifications: yes/no
Step 3: Create Cloudflare Configuration
wrangler.toml
# Cloudflare Pages project configuration
compatibility_date = "2024-12-01"
Add wrangler devDependency
pnpm add -D wrangler # or npm
For pnpm: add esbuild and workerd to pnpm.onlyBuiltDependencies in package.json.
public/_redirects (if using a base path)
If the site has a base path (e.g., /pj/project-name/), create public/_redirects:
/ /pj/project-name/ 302
Most static site generators (Astro, Next.js, etc.) copy public/ to output, eliminating CI-time redirect generation.
Step 4: Create Workflows
Security Best Practices (apply to all workflows)
- Explicit
permissionsblocks (least privilege) - Pass
${{ }}values viaenv:blocks, never inline ingithub-scriptJavaScript (prevents script injection) - Quote all shell variable expansions:
"${GITHUB_SHA}" - Pin wrangler version:
npm install -g wrangler@4(orpnpm exec wranglerwhen node_modules available) - Add
timeout-minutesto all jobs (build: 15, deploy: 20, notify: 5) - Use
curl -sSf --max-time 10for external HTTP calls
Deploy Retry (apply to all deploy steps)
Cloudflare Pages API occasionally returns transient errors (504 Gateway Timeout on /upload-token). Wrap all wrangler pages deploy commands in a bash retry loop:
- name: Deploy to Cloudflare Pages
run: |
for attempt in 1 2 3; do
echo "Deploy attempt $attempt/3..."
if wrangler pages deploy deploy \
--project-name=PROJECT_NAME \
--branch=main \
--commit-hash="${GITHUB_SHA}" \
--commit-message="Production deploy: ${GITHUB_SHA}"; then
echo "Deploy succeeded on attempt $attempt"
exit 0
fi
if [ "$attempt" -lt 3 ]; then
echo "Deploy failed, retrying in 150 seconds..."
sleep 150
fi
done
echo "Deploy failed after 3 attempts"
exit 1
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- 3 attempts, 150s (2.5 min) delay between retries
- Increase
timeout-minuteson deploy jobs to 20 (from 10) to accommodate retries - For steps that set
GITHUB_OUTPUT(preview URLs), move the output logic inside the success branch of theifblock - Works with both
npx wrangler@4andpnpm exec wranglervariants
Production Deploy (main-deploy.yml)
Trigger: push to main. Concurrency: production-deploy, cancel-in-progress: false.
The notify job below follows the canonical IFTTT payload contract owned by /dev-ci-ifttt-notify:
| Field | Content | Example |
|---|---|---|
value1 |
<project>: <emoji> <status> |
my-app: ✅ Deploy succeeded |
value2 |
Run URL for tapping through | https://github.com/.../runs/123 |
value3 |
(unused / empty) | "" |
permissions:
contents: read
jobs:
build:
# Heavy job — candidate for a larger cloud runner; see /dev-blacksmith-migration
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# fetch-depth: 0 if project needs git history (e.g., doc history, changelogs)
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with: { name: dist-out, path: dist/, retention-days: 1 }
deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/download-artifact@v4
with: { name: dist-out, path: deploy/ }
- run: npm install -g wrangler@4
- name: Deploy to Cloudflare Pages (production)
run: |
for attempt in 1 2 3; do
echo "Deploy attempt $attempt/3..."
if wrangler pages deploy deploy \
--project-name=PROJECT_NAME \
--branch=main \
--commit-hash="${GITHUB_SHA}" \
--commit-message="Production deploy: ${GITHUB_SHA}"; then
echo "Deploy succeeded on attempt $attempt"
exit 0
fi
if [ "$attempt" -lt 3 ]; then
echo "Deploy failed, retrying in 150 seconds..."
sleep 150
fi
done
echo "Deploy failed after 3 attempts"
exit 1
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
notify: # Optional IFTTT notification
needs: [build, deploy]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Notify via IFTTT
if: env.IFTTT_PROD_NOTIFY != ''
env:
IFTTT_PROD_NOTIFY: ${{ secrets.IFTTT_PROD_NOTIFY }}
BUILD_RESULT: ${{ needs.build.result }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
if [ "$DEPLOY_RESULT" = "success" ]; then STATUS="✅ succeeded"
elif [ "$BUILD_RESULT" = "failure" ]; then STATUS="❌ failed (build)"
elif [ "$DEPLOY_RESULT" = "failure" ]; then STATUS="❌ failed (deploy)"
else STATUS="⚠️ cancelled"; fi
RUN_URL="${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}"
curl -sSf --max-time 10 -X POST "$IFTTT_PROD_NOTIFY" \
-H 'Content-Type: application/json' \
-d "{
\"value1\": \"PROJECT_NAME: ${STATUS}\",
\"value2\": \"${RUN_URL}\",
\"value3\": \"\"
}" || echo "::warning::IFTTT notification failed"
PR Preview Deploy (pr-checks.yml)
Trigger: pull_request to main. Concurrency: per-PR, cancel-in-progress: true.
permissions:
contents: read
pull-requests: write
Build job identical to production. Preview job:
- Download artifact to
deploy/ - Deploy with
--branch="pr-${PR_NUMBER}" - Preview URL:
https://pr-${PR_NUMBER}.PROJECT_NAME.pages.dev - Post/update PR comment using
actions/github-script@v8with marker<!-- cf-preview-pr --> - Pass deploy URL via
env::const deployUrl = process.env.DEPLOY_URL;
Named Preview Deploy (preview-deploy.yml)
Trigger: push to preview and expreview/**. Concurrency: per-branch, cancel-in-progress: true.
permissions:
contents: read
pull-requests: write
statuses: write
Single-job workflow (build + deploy in one job):
- Convert branch slashes to hyphens for deploy branch name
- Deploy directly from build output (no copy step needed)
- Use
pnpm exec wrangler(node_modules available in same job) - Set commit status via
createCommitStatusAPI - Comment on associated PR if one exists, using marker
<!-- cf-preview-branch --> - Use distinct markers from pr-checks.yml to prevent collision
Step 5: Required Secrets
| Secret | Required | Purpose |
|---|---|---|
CLOUDFLARE_API_TOKEN |
Yes | Wrangler authentication |
CLOUDFLARE_ACCOUNT_ID |
Yes | Cloudflare account identifier |
IFTTT_PROD_NOTIFY |
No | IFTTT webhook URL (skipped if not set) |
Creating Cloudflare API Token
- Cloudflare dashboard > My Profile > API Tokens > Create Token > Custom token
- Permissions: Account > Cloudflare Pages > Edit
- Account Resources: Include the target account
The Cloudflare Pages project is auto-created on first deploy via wrangler pages deploy.
Step 6: Verify
pnpm build # Verify build works locally
Companion Skills
/dev-blacksmith-migration— Move heavy build jobs offubuntu-latestonto Blacksmith/cloud runners/dev-ci-ifttt-notify— Add IFTTT webhook notifications