Auto-Deploy a Static Site to Cloudflare Pages with GitHub Actions

by goyostudio

Every static site eventually needs a deploy story. The bad version is a human running a build on a laptop and dragging a folder into a dashboard. It works until that human is on a train, or skips the build step, or ships from a dirty working tree that never got committed. The good version is git push.

This is the pipeline I run for a Next.js static export on Cloudflare Pages. It costs nothing, there is no server to patch, and the whole deploy config is four files: next.config.ts, wrangler.toml, .github/workflows/deploy.yml, and public/_headers. The generator barely matters — swap Next.js for Astro, Hugo, or Eleventy and only the build command and the output directory change.

## Why static export plus Pages

  • The build output is just files. No runtime to keep patched, no process to restart, nothing to scale under traffic.
  • Cloudflare serves them from its edge with TLS and a custom domain included, on a free tier with no bandwidth cap.
  • Every deploy is retained, so a rollback is picking an older deployment and promoting it — not a revert-and-rebuild race.
  • Non-production branches get their own preview URL automatically, so a pull request has a real address you can open.

What Pages does not give you is a trigger. Its own git integration can build for you, but then your build environment is a black box you configure through a web form. Running the build in GitHub Actions instead keeps it in the repo, next to the code, reviewable in a diff — and Actions hands the finished directory to wrangler. Push becomes deploy.

## What you need first

  • A generator that emits a directory of static files: out/, dist/, _site/, whatever your tool calls it.
  • A committed lockfile, so CI installs exactly what you built against locally.
  • A free Cloudflare account, and the zone for your domain on it if you want a custom domain.
  • wrangler — no global install needed, npx wrangler is enough.

For Next.js, static export is one line of config. next build then writes prerendered HTML plus hashed assets into out/ instead of standing up a server.

# next.config.ts — emit a static site into out/
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;

// note: output: "export" switches off everything that needs a server at request time: route handlers, middleware, ISR, on-demand revalidation, and the default image optimization loader. Find that out before you build the site, not the day you try to deploy it. If a page needs per-request logic, this whole stack is the wrong one.

## Point wrangler at the build output

One file at the repo root tells wrangler two things: the Pages project to deploy into, and which directory holds the output.

# wrangler.toml
name = "my-site"
pages_build_output_dir = "out"

The pages_build_output_dir key is what marks this as a Pages project rather than a Worker — wrangler branches on its presence. The path is relative to the repo root. If you also pass a directory positionally to wrangler pages deploy, the command-line argument wins, so keep the two in sync or you will one day deploy an empty folder and stare at a blank site.

name has to match the Pages project name on the Cloudflare side, character for character. Project names are per-account and cannot be renamed after creation, so pick the one you want to live with.

### Create the project with one manual deploy

CI should never be the thing that creates infrastructure. Do the first deploy by hand from your machine — it creates the project, proves the build output is right, and gives you a live my-site.pages.dev URL to check before any automation touches it.

# bootstrap the Pages project once, locally
npx wrangler login
npx wrangler whoami

pnpm build
npx wrangler pages project create my-site --production-branch=main
npx wrangler pages deploy out --project-name=my-site

// note: Run npx wrangler whoami before every manual deploy and actually read the output. Wrangler stores one active login per machine, and if you work across multiple Cloudflare accounts you will eventually push a site into the wrong one — where it creates a real project, under a real name, that you then have to hunt down and delete. CI does not have this problem because its token is bound to a single account, which is exactly why manual deploys are the risky ones.

## The workflow

Thirty lines, no custom scripts, no shell glue. Checkout, set up the toolchain, install, build, upload.

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      - run: pnpm build

      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=my-site
  • on: push: branches: [main] — only the production branch deploys. Pull requests do not, so an untrusted fork cannot reach your token.
  • actions/checkout@v4 — a shallow clone of the pushed commit. That is all a build needs.
  • pnpm/action-setup@v4 — installs pnpm itself, and it must come before setup-node.
  • actions/setup-node@v4node-version-file: ".nvmrc" means the Node version lives in one file that both CI and your shell read. cache: "pnpm" caches the store between runs.
  • pnpm install --frozen-lockfile — fails if the lockfile disagrees with package.json instead of silently resolving something new. Non-negotiable in CI.
  • pnpm build — produces out/. If it fails, the job stops and nothing is deployed.
  • cloudflare/wrangler-action@v3 — installs wrangler and runs the command with the credentials injected as env vars.

The step order matters more than it looks. cache: "pnpm" makes setup-node shell out to pnpm to resolve the store path, so if setup-node runs first the step fails outright with an unable-to-locate-executable error. pnpm first, Node second.

Two flags are worth knowing on the deploy command. --project-name must match name in wrangler.toml; a typo does not error usefully, it just creates a second project. And if your deploys land as previews rather than production, add --branch=main — wrangler infers the branch from the CI environment, and it only counts as a production deploy when that branch matches the project's configured production branch.

One addition earns its keep on a repo where you push several commits in a row: cancel superseded runs so two deploys cannot land out of order.

# add above jobs: — newest push wins
concurrency:
  group: deploy-production
  cancel-in-progress: true

## Secrets, scoped down

Two values go in the repo under Settings, then Secrets and variables, then Actions. Nothing about the credentials appears in the yaml — only the ${{ secrets.NAME }} references, which resolve at run time and are masked in the logs.

  • CLOUDFLARE_API_TOKEN — created in the Cloudflare dashboard under My Profile, API Tokens, Create Token, Custom token. One permission: Account, Cloudflare Pages, Edit. Scope the Account Resources to the single account that owns the project.
  • CLOUDFLARE_ACCOUNT_ID — the hex id in your dashboard URL, also printed by wrangler whoami. Not really a secret, but keep it out of the yaml so a forked repo does not inherit it.

// note: Never use a Global API Key here. It authenticates as your whole account — every zone, every DNS record, billing — and it cannot be scoped or rotated without breaking everything else that uses it. A Pages-Edit token can do exactly one thing, and revoking it costs you a thirty-second reissue. If a token ever leaks, revoke first and regenerate second.

## Custom domains

In the Pages project, open Custom domains and add both the apex and the www host. If the zone is already on Cloudflare, the DNS records are created for you — a CNAME for www, a flattened record at the apex — and the certificate is issued within a couple of minutes. There is no origin server to point at and no cert to renew.

Pick one canonical host and redirect the other with a Redirect Rule at the zone level. Serving identical HTML on both splits your search signals, and your canonical tags, sitemap URLs, and OG tags all have to agree with whichever one you chose. Decide once, at launch, before anything gets indexed.

## Headers, caching, and 404s

Pages reads a plain-text _headers file from the root of the deployed directory. For Next.js that means writing it to public/, which gets copied verbatim into out/. It is parsed as configuration, not served as an asset.

# public/_headers
/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable

The first block applies to every response: stop MIME sniffing, refuse to be framed, and trim the referrer sent to other origins. The second gives build assets a one-year immutable cache. That is safe only because those filenames contain a content hash — a changed file gets a new name, so nothing stale can be pinned. Never point an immutable rule at your HTML, or visitors will hold a cached page for a year.

Indentation is significant: two spaces before each header, path patterns flush left. A misindented line is silently ignored, so check a real response with curl -I https://my-site.com after the first deploy rather than assuming it took.

// note: A static export writes a real 404.html, and Pages serves it with an actual 404 status for any unmatched path — nothing to configure. Do not add a catch-all rewrite to index.html. That is the single-page-app pattern, and on a prerendered site it turns every typo, every dead link, and every stale URL into a 200 response with your homepage in it. Crawlers will index the noise and you will not notice for months.

## The whole pipeline, start to finish

  • Configure the generator to emit static files into a known directory, and commit the lockfile.
  • Add wrangler.toml with name and pages_build_output_dir.
  • Run wrangler whoami, then wrangler pages deploy once by hand to create the project and confirm the output is right.
  • Create a Pages-Edit API token and store it with the account id as repo secrets.
  • Commit .github/workflows/deploy.yml — checkout, toolchain, install, build, wrangler-action.
  • Attach the custom domains, drop in _headers, and verify with curl -I that headers and the 404 status are what you expect.

After that the deploy button is git push, and the only thing that can break a release is a build that actually fails — which is the failure mode you want, because it fails in CI and never reaches production.

// related cheat sheets