Deploying Cloudflare Pages From GitHub Actions
Cloudflare Pages offers two documented paths: let Cloudflare build from your repository, or build yourself and upload the result. There is a third that people reach for once they have been bitten by the first one — keep the repository, keep continuous integration, but run the build in a workflow file you control.
It is the setup I would default to for anything that is more than a hobby page.
What the trade actually is
Git integration and a GitHub Actions pipeline both end in the same place: a directory of files arriving at Pages. The difference is who owns the environment that produced them.
With the built-in integration, the build runs on Cloudflare’s build image. The toolchain is whatever that image happens to carry. When your local build succeeds and the deployed one does not, you are debugging a machine you cannot log into.
With a workflow file, the toolchain is pinned in YAML, in your repository, under version control. Node version, package manager, install flags, build steps — all declared, all reviewable, all diffable when something changes.
The workflow
Drop this at .github/workflows/deploy.yml:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
concurrency:
group: pages-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install and build
run: |
npm ci
npm run build
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=your-project --branch=main --commit-dirty=true
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
Four details in there are doing real work.
concurrency with cancel-in-progress. Push twice in a minute and the first pipeline is cancelled rather than racing the second to the edge. Without it you occasionally deploy an older build on top of a newer one.
permissions: deployments: write. Required only if you pass gitHubToken, which is what makes deployments show up in GitHub’s own Deployments list. Drop both and the workflow still deploys.
cache: npm on the setup step. Restores the npm cache between runs. On a site with a normal dependency tree this is the difference between a thirty-second build and a two-minute one.
--commit-dirty=true. Your build output is generated in CI and usually not committed. Without this flag Wrangler warns about a dirty working tree on every run. It is a warning, not a failure, but a permanent yellow warning in a log is how people learn to ignore logs.
The two secrets
Both go in Settings → Secrets and variables → Actions.
CLOUDFLARE_ACCOUNT_ID is on the right-hand side of your Cloudflare dashboard, and in the output of npx wrangler whoami.
CLOUDFLARE_API_TOKEN needs to be created as a custom token — Profile → API Tokens → Create Token → Custom — with one permission:
| Scope | Resource | Level |
|---|---|---|
| Account | Cloudflare Pages | Edit |
Resist adding more. A token that can deploy a static site has no business touching billing, DNS, or Workers. If it leaks — and tokens leak through logs and screenshots more often than through breaches — the blast radius should be “someone can publish to my website”, not “someone owns my account”.
Why --branch is spelled out
Wrangler infers the branch from the local git state when it can. In CI that state is a checkout, and checkout is not always on a named branch.
Passing --branch=main explicitly removes the inference. For a production deploy, main is what you want. For previews, --branch=${{ github.ref_name }} gives every branch its own preview URL.
The flag names the branch, not the environment. Main maps to production because that is how the project is configured — if you set a different production branch at project creation, that is the name to use.
How this compares to the built-in integration
| Built-in Git integration | GitHub Actions | |
|---|---|---|
| Build environment | Cloudflare’s image | Defined in your workflow file |
| Node version control | Limited | setup-node, exact |
| Run tests before deploy | Not without extra machinery | Add a step |
| Steps after deploy (cache purge, notifications) | Not available | Add a step |
| Build minutes | Count as Cloudflare build quota | Count against your GitHub allowance |
| Secrets available at build time | Dashboard variables | GitHub repository secrets |
| Preview URLs | Automatic | One added flag |
The last two rows are the ones that decide it for most people.
If your build needs a secret — an API key to pull data at build time, say — you are choosing between putting it in Cloudflare’s dashboard or keeping it in GitHub. Neither is a vault, but the one you already audit is the better home.
If you have more than one person deploying, the automatic preview URLs from the built-in integration are genuinely hard to give up. Reviewing a layout change on a real URL before merge is worth more than the cleaner build environment.
What breaks a first run
- Wrong output path.
pages deploy distwhen the build writes tobuild/orpublic/. Check whatnpm run buildactually produces locally before touching CI. npm installinstead ofnpm ci.ciinstalls exactly what the lockfile pins and fails on a mismatch. That is the point — a lockfile drift should stop a deploy, not silently change dependency versions.- Missing lockfile.
npm cirequirespackage-lock.json(or the pnpm/yarn equivalent) to be committed. It is a common thing to have been gitignored by accident. - Project not created yet. The workflow deploys to a project; it does not create one. Run
npx wrangler pages project createonce before the first pipeline. - Token scoped to the wrong account. A multi-account Cloudflare setup will take the token and fail with a permission error that sounds like the token is broken.
When to pick this
Take the built-in Git integration if preview URLs per pull request matter more than build reproducibility, or if nobody on the team wants to maintain a workflow file.
Take GitHub Actions when the build environment needs to be pinned and reviewable, when you want tests or post-deploy steps in the same pipeline, or when the build needs credentials that already live in GitHub.
The two are not exclusive. Plenty of projects keep the repository connected for previews and run a separate workflow for production — which is more moving parts than most one-person projects need, but it is a legitimate answer if the previews are doing real work.
Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.