Deployment

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:

ScopeResourceLevel
AccountCloudflare PagesEdit

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 integrationGitHub Actions
Build environmentCloudflare’s imageDefined in your workflow file
Node version controlLimitedsetup-node, exact
Run tests before deployNot without extra machineryAdd a step
Steps after deploy (cache purge, notifications)Not availableAdd a step
Build minutesCount as Cloudflare build quotaCount against your GitHub allowance
Secrets available at build timeDashboard variablesGitHub repository secrets
Preview URLsAutomaticOne 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 dist when the build writes to build/ or public/. Check what npm run build actually produces locally before touching CI.
  • npm install instead of npm ci. ci installs 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 ci requires package-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 create once 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.