Deploy your build output to Cloudflare Pages with Wrangler, while tracking every release through GitHub Environments and GitHub Deployment. On a pull request, it creates a preview deployment and comments the URL on the PR.
Features
- Deploy to Cloudflare Pages and wait for the deployment to finish — the step fails if the build fails or isn't live within 10 minutes.
- Track releases with GitHub Environments & GitHub Deployment.
- Comment the deployment URL on pull requests.
- Write a job summary of each deployment.
- Delete old deployments with the companion
/deleteaction. - Run Wrangler from a subfolder via the
working-directoryinput — handy for monorepos wherefunctionsisn't in the repo root.
- Create a Cloudflare Pages project and an API token that can edit it.
- Manually create your GitHub Environments (for example
productionandpreview) — the action can't create them for you. See Setup. - Add the Cloudflare values as repository secrets/variables, then add a workflow like the one below (this mirrors the official template in .github/workflow-templates/deploy.yml):
name: Cloudflare Pages Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
# Deny all permissions by default; grant only what each job needs.
permissions: {}
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15 # The action waits up to 10 minutes for Cloudflare.
permissions:
actions: read # Only required for a private repo.
contents: read
deployments: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Deploy to Cloudflare Pages
uses: andykenward/github-actions-cloudflare-pages@46d86e1caa6b86365a41d335db65a6936a1beb39 #v3.5.0
with:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-project-name: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: dist
github-token: ${{ secrets.GITHUB_TOKEN }}
github-environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'preview' }}The github-environment expression deploys the main branch to production and every other branch to preview. For a line-by-line breakdown of this expression — and how it relates to the Cloudflare branch input — see GitHub Environments.
Create a Cloudflare Pages project, then give the action three values (store the token as a repository secret and the rest as variables or secrets):
cloudflare-api-token— an API token with permission to edit Cloudflare Pages.cloudflare-account-id— your Cloudflare account ID.cloudflare-project-name— the Pages project to upload to.
With the GitHub CLI, run these from your repository — the token is read from a prompt, so it stays out of your shell history:
gh secret set CLOUDFLARE_API_TOKEN
gh variable set CLOUDFLARE_ACCOUNT_ID --body "<account-id>"
gh variable set CLOUDFLARE_PROJECT_NAME --body "<project-name>"No Pages project yet? Create one with npx wrangler pages project create <project-name> --production-branch main.
Important
This action does not create GitHub Environments. Creating them requires the GitHub API administration:write permission, which the action can't request — so you must create them manually. See Creating an environment.
To create them with the GitHub CLI — this needs admin access to the repository, which your own gh auth login session usually has and the workflow's GITHUB_TOKEN never does:
gh api --method PUT "repos/{owner}/{repo}/environments/production"
gh api --method PUT "repos/{owner}/{repo}/environments/preview"Create each environment you reference (for example production and preview), then select one per run with the github-environment input. A common pattern switches on the branch:
github-environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'preview' }}GitHub Actions has no condition ? a : b ternary, so this uses the &&/|| idiom to get the same result. Read it as "if on main, use production, otherwise use preview":
github.refis the full ref of the branch that triggered the run, e.g.refs/heads/mainorrefs/heads/my-feature.github.ref == 'refs/heads/main'istrueonly on themainbranch.A && BreturnsBwhenAis true, so onmainthe expression so far is'production'; on any other branch it isfalse.X || 'preview'returnsXunlessXis falsy, so afalseleft side falls through to'preview'.
To map more branches to environments, extend the same pattern — for example, send main to production, staging to staging, and everything else to preview:
github-environment: >-
${{ (github.ref == 'refs/heads/main' && 'production')
|| (github.ref == 'refs/heads/staging' && 'staging')
|| 'preview' }}Note
github-environment only sets the GitHub Environment the deployment is recorded against. Whether Cloudflare treats the upload as a production or preview deployment is decided separately, by the branch name — Cloudflare promotes the deployment to production only when the branch matches your Pages project's production branch. By default the branch is detected from the GitHub context; use the branch input to override it. The two inputs are independent, so make sure your branch logic and github-environment logic agree on what counts as "production".
When using the workflow's built-in GITHUB_TOKEN for the github-token input, grant these permissions:
permissions:
actions: read # Only required for a private GitHub repo.
contents: read
deployments: write
pull-requests: writeAdding this action with an AI coding agent? Install its skill, which walks the agent through the steps below:
npx skills add andykenward/github-actions-cloudflare-pagesOr point the agent at this section.
-
Do the steps outside the workflow file first — they can't be expressed in YAML. Run them, or ask the user to:
- create a Cloudflare Pages project and an API token with the Cloudflare Pages: Edit permission;
- add the
CLOUDFLARE_API_TOKENsecret and theCLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_PROJECT_NAMEvariables; - create every GitHub Environment the workflow names in
github-environment.
The commands are in Setup.
-
Pick the workflow for how the project receives changes:
Situation Start from Pushes to main, and pull requests from branches in the same repositoryQuick start Pull requests from forks Fork pull requests with workflow_runand Custom branch nameRemoving preview deployments when a pull request closes The delete action -
Fit it to the project:
- Add the project's build step before the deploy step, and set
directoryto its output (relative toworking-directory). - Keep
uses:pinned to the full commit SHA shown in these examples. - Use only environment names that exist.
- Never build
pr-numberorbranchfromgithub.event.workflow_run.pull_requests[0]— it's empty for pull requests from forks.
- Add the project's build step before the deploy step, and set
-
Check the first run: the step's
urloutput, the job summary, the pull request comment, and a new deployment under the repository's Environments.
- Checks while uploading. While Wrangler uploads
directory, the action checks thatgithub-environmentexists and finds the pull request to comment on. If either fails, the upload is stopped and the step fails straight away, so no orphaned Cloudflare deployment is left behind. - Waits for Cloudflare. It polls the deployment Wrangler just created every second, for up to 10 minutes, until it's live. A build still running after 10 minutes fails the step. So does a failed or canceled build, once the outputs and job summary are written: the error links to the Cloudflare build log, and no pull request comment or GitHub Deployment is created.
- Reports. It sets the outputs and writes a job summary with the environment, branch, commit, status, URLs and Wrangler output.
- Comments on the pull request, if there is one — see below.
- Records a GitHub Deployment in
github-environment, with a success status that links to the deployment URL and its Cloudflare build log.
push, pull_request, workflow_dispatch and workflow_run. Any other event fails the step.
| Trigger | Pull request commented on |
|---|---|
pr-number input set (any event) |
That pull request. The step fails if the value isn't a valid pull request number. |
pull_request |
The event's pull request. No comment when the pull request is closed. |
workflow_dispatch |
The first open pull request whose head is the run's branch. The step fails if there is none. |
workflow_run |
The one pull request in the event's pull_requests that matches its head branch and commit. The step fails if there are none or several. For pull requests from forks the list is always empty, so set pr-number. |
push |
None. |
| Input | Required | Description |
|---|---|---|
cloudflare-api-token |
yes | Cloudflare API token with permission to edit Cloudflare Pages. It's passed only to the Wrangler process and masked in logs, even when it doesn't come from secrets. |
cloudflare-account-id |
yes | Cloudflare account ID. |
cloudflare-project-name |
yes | Cloudflare Pages project to upload to. |
directory |
yes | Directory of built static files to upload, relative to working-directory. |
github-token |
yes | GitHub token with the required permissions. Masked in logs, even when it doesn't come from secrets. |
github-environment |
yes | GitHub Environment to record the deployment in. It must already exist. |
pr-number |
no | Pull request number to comment on. If not set, it's detected from the event — see Which pull request gets the comment. |
working-directory |
no | Directory to run Wrangler from — e.g. where your functions/ folder lives. Defaults to ., the job's working directory. |
wrangler-version |
no | Wrangler version to run. Defaults to the version this release of the action pins. Versions too old to report the new deployment's id fall back to the most recent deployment for the commit. |
branch |
no | Branch name for the Cloudflare Pages deployment. If not set, it's detected from the GitHub context. |
| Output | Description |
|---|---|
id |
Cloudflare Pages deployed id |
url |
Cloudflare Pages deployed url |
environment |
Cloudflare Pages deployed environment production or preview |
alias |
Cloudflare Pages deployed alias. Falls back to deployed url if deployed alias is null |
wrangler |
Wrangler cli output |
Ready-to-use GitHub Workflow Templates live in .github/workflow-templates/:
Pull requests from forks don't have access to secrets in the initial pull_request workflow. Use a second workflow triggered by workflow_run to deploy from the original repository context after the first workflow succeeds, and set the pr-number input. For pull requests from forks, workflow_run lists no pull requests, so without pr-number the action can't find the pull request and the step fails.
name: Deploy PR Preview (Fork Safe)
on:
workflow_run:
workflows: ['CI']
types: [completed]
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
deployments: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Deploy to Cloudflare Pages
uses: andykenward/github-actions-cloudflare-pages@46d86e1caa6b86365a41d335db65a6936a1beb39 #v3.5.0
with:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-project-name: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: dist
github-token: ${{ secrets.GITHUB_TOKEN }}
github-environment: preview
pr-number: # The PR numberThe action supports the workflow_run event and uses its head commit SHA and branch for the deployment metadata.
You can override the automatically detected branch name with the branch input. This is useful with workflow_run: a fork pull request opened from the fork's main branch would otherwise deploy to your project's production branch and overwrite the production deployment. Giving each pull request its own branch name (for example pr-123) keeps it on a separate Cloudflare Pages preview.
Do not build the branch name from github.event.workflow_run.pull_requests[0].number — that array is empty for pull requests from forks (community discussion #25220), which is the exact case this is meant to cover. Instead, save the PR number in the triggering pull_request workflow and read it back from an artifact in the workflow_run workflow.
In the pull_request workflow (the one named in workflows: of the workflow_run trigger), save the PR number alongside your build output:
- name: Save PR number
run: echo "${{ github.event.number }}" > pr-number.txt
- name: Upload PR number
uses: actions/upload-artifact@v4
with:
name: pr-number
path: pr-number.txtThen, in the workflow_run workflow, download it and pass it to both branch and pr-number:
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
contents: read
deployments: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Download PR number
uses: actions/download-artifact@v4
with:
name: pr-number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Read PR number
id: pr
run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
- name: Deploy to Cloudflare Pages
uses: andykenward/github-actions-cloudflare-pages@46d86e1caa6b86365a41d335db65a6936a1beb39 #v3.5.0
with:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-project-name: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: dist
github-token: ${{ secrets.GITHUB_TOKEN }}
github-environment: preview
branch: pr-${{ steps.pr.outputs.number }}
pr-number: ${{ steps.pr.outputs.number }}This creates a Cloudflare Pages preview deployment with a branch name like pr-123, so each pull request — including those from forks — gets its own preview environment instead of overwriting production.
Use the companion sub-action andykenward/github-actions-cloudflare-pages/delete to remove old deployments.
The GitHub Deployment payload this action creates includes the Cloudflare metadata the delete action needs:
{
"payload": {
"cloudflare": {
"id": "123",
"projectName": "cloudflare-pages-project-name",
"accountId": "123"
},
"url": "https://example.com",
"commentId": "1234"
}
}A failed step's annotation carries a one-line message; turn on step debug logs for the full error.
| Message | Cause and fix |
|---|---|
Input required and not supplied: <input> |
A required input is empty. Check the secret or variable exists — on a fork's pull_request run, secrets are empty, so deploy with workflow_run. |
Input '<input>' is invalid: … |
The input has the wrong format; the rest of the message says what was expected. |
GitHub Action event name '<name>' not supported. |
Trigger on one of the supported events. |
GitHub Environment: Not created for <name> |
The environment doesn't exist — create it, or fix the github-environment expression. |
GitHub Environment: No ref id <name> |
The branch or tag the run is for no longer exists, e.g. it was deleted before the run started. |
GitHub Environment: Errors - […] |
GitHub rejected the lookup; the JSON lists its errors. Usually github-token is missing a permission. |
GitHub API request failed: <status> … |
A 401 or 403 means github-token is invalid or missing a permission. |
A JSON list of errors mentioning Resource not accessible by integration |
github-token is missing a permission — usually deployments: write or pull-requests: write. |
Invalid pr-number input: <value> |
pr-number must be a positive whole number. |
No pull request node id found for pr-number input: <number> |
No pull request with that number exists in this repository. |
No pull request node id found for workflow_dispatch event |
No open pull request is headed by the dispatched branch. Dispatch from one, or set pr-number. |
No pull request found in workflow_run event matching head branch and sha |
The run has no matching pull request — always the case for forks. Set pr-number (example). |
Multiple pull requests found in workflow_run event matching head branch and sha |
Several pull requests share the commit. Set pr-number. |
Status Of Deployment: timed out after 10m waiting for the deploy stage to complete. |
Cloudflare didn't finish within 10 minutes. Check the build in the Cloudflare dashboard. |
A request to the Cloudflare API (<url>) failed. … |
Cloudflare rejected the request; the rest of the message gives its reason and code, e.g. Authentication error [code: 10000] for a token without permission. failed: 5xx … is a Cloudflare outage — re-run later. |
| An error printed by Wrangler | The upload failed and Wrangler's message says why. Check the token's permission, cloudflare-account-id, cloudflare-project-name and directory. |
| The job is cancelled at its time limit | Raise the job's timeout-minutes to at least 15 — the action waits up to 10 minutes for Cloudflare. |
Create Deployment: the Cloudflare Pages build failed. Build log: <url> (or was canceled) |
The build failed or was canceled on Cloudflare. Open the build log link; the outputs and job summary still describe the deployment. |
GitHub provides two debug log levels — see Action Debugging. Enable them by setting a repository secret:
- Step debug logs: set
ACTIONS_STEP_DEBUGtotrue. Debug events then appear in the downloaded logs and web logs. When a step fails, its annotation carries a one-line message; the full error (stack trace and nested causes) is only logged at this level. - Runner diagnostic logs: set
ACTIONS_RUNNER_DEBUGtotrue. Extra diagnostic files then appear in therunner-diagnostic-logsfolder of the log archive.
See CONTRIBUTING.md for setting up the repository, running the checks and opening a pull request.
Upgrading from an older version? Check CHANGELOG.md for breaking changes.
