Skip to content

DevOps · Static Deployment

Cloudflare Pages Deployment Guide 2026: Wrangler, CI/CD and the Direct Upload API

Published April 22, 2026 · 15 min read · By FastTool Editorial

The first time you watch wrangler pages deploy push a 4,000-file static site to the global edge in eleven seconds, you start asking questions. Why is it so fast? What is it actually sending over the wire? Why does the second deploy take two seconds instead of eleven? And when the CI job starts failing at 3 AM, what do you need to know to diagnose it without opening a ticket?

This guide is the short answer. We walk through the actual request flow the CLI makes, the blake3-based content addressing that skips unchanged files, how _headers and _redirects are parsed at deploy time, the CI/CD patterns that do and do not hold up in production, and the edge cases — custom domains, apex redirects, cache purge, rollback — that will bite you the first time if you did not plan for them.

Why Pages beats the alternatives in 2026

Vercel and Netlify are excellent products. They also meter bandwidth. For a static site that picks up a viral post or gets scraped by a bot, the invoice matters. Cloudflare Pages sits on the same 330-city anycast network that serves tens of millions of zones, and it does not charge egress for static assets. That is the structural reason it keeps winning new projects.

Three other things matter in 2026. First, Pages deploys are atomic and content-addressed, which means rollback is a one-click operation and cache correctness is a non-issue when you fingerprint assets. Second, Pages Functions — Workers bound to routes — let you add server logic without leaving the platform. Third, the free plan is genuinely usable: 500 builds per month, unlimited bandwidth under fair use, and preview deployments on every branch and PR without additional configuration.

The tradeoffs are real. If you need a traditional Node.js server with persistent state, Pages is not the fit; Cloudflare Workers with Durable Objects is. If your build output exceeds 25,000 files, you will hit the default upload limit and need to split the project. If your team is deeply committed to Vercel's ISR semantics, replicating them on Pages via Functions is possible but not a drop-in.

The Wrangler CLI, end to end

Wrangler is the officially blessed tool. Install it locally as a dev dependency so your team runs the same version:

npm install --save-dev wrangler
# or
pnpm add -D wrangler

Authenticate once per machine. The CLI opens a browser window and stores an OAuth token in ~/.wrangler:

npx wrangler login

For CI or headless environments, use an API token instead. Create one in the Cloudflare dashboard under My Profile → API Tokens with the Cloudflare Pages: Edit template, then export it:

export CLOUDFLARE_API_TOKEN="your-token-here"
export CLOUDFLARE_ACCOUNT_ID="your-account-id"

Deploy a built directory:

npx wrangler pages deploy ./dist \
  --project-name my-static-site \
  --branch main \
  --commit-dirty=true

The --commit-dirty flag suppresses the warning when deploying with uncommitted local changes; drop it in CI. --branch main pins the deploy to production; any other value creates a preview deployment with its own URL. The output prints the deployment URL, which Wrangler also exposes as CF_PAGES_URL for subsequent steps.

Two flags that are worth knowing about: --skip-caching disables the blake3 dedup for the current deploy (useful when you suspect a corrupted cache on Cloudflare's side, which is rare), and --compatibility-date pins the Workers runtime version when Pages Functions are in play.

What Wrangler actually does: the Direct Upload API

Under the hood, wrangler pages deploy makes three API calls. Understanding them helps when debugging CI failures and opens the door to custom deploy pipelines that do not ship Node.

Step 1: Request an upload JWT. Wrangler POSTs to /accounts/{account_id}/pages/projects/{project_name}/upload-token. The response is a short-lived JWT scoped to the project, valid for roughly five minutes. This is the auth token used for the actual file upload step.

curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/pages/projects/$PROJECT/upload-token" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json"
# Returns: { "result": { "jwt": "eyJ..." } }

You can inspect this JWT with our JWT Decoder if you want to see the claims — it helps when debugging "unauthorized" errors in CI.

Step 2: Hash every file with blake3 and probe for missing hashes. Wrangler walks the output directory, computes the blake3 hash of each file's contents (plus a MIME-type suffix for content-type correctness), and POSTs the list to /pages/assets/check-missing. The response tells Wrangler which hashes are new and need uploading — everything else is already in Cloudflare's content-addressed store from a previous deploy.

Step 3: Upload missing files and commit the manifest. For each missing hash, Wrangler chunks the file (up to 25 MB per request, max 5,000 files per request) and POSTs the base64-encoded bytes to /pages/assets/upload. Once uploads succeed, Wrangler POSTs a manifest that maps logical paths (e.g. /index.html) to hashes to /pages/projects/{name}/deployments. Cloudflare creates the deployment record, flips the edge to serve the new manifest, and returns the deploy URL.

If you want to skip Wrangler entirely — say, to deploy from a Rust binary or a bash script — the REST API is documented at developers.cloudflare.com and largely stable. The one gotcha: the asset hash format is blake3(file_bytes) truncated to 32 hex characters with a file-extension suffix. Replicating the exact hashing is more work than it sounds, which is why most teams just ship Node for this one step.

Why blake3 makes deploys feel instant

Blake3 is a cryptographic hash published in 2020 that is roughly five to ten times faster than SHA-256 on modern CPUs thanks to SIMD-friendly tree hashing. Cloudflare picked it for Pages specifically because hashing 5,000 files in under a second is a feature, not a luxury.

The deploy-time experience this enables is content-addressed caching. The first time you deploy a 4,000-file site, Wrangler uploads all 4,000 files. The second deploy, only the files that changed get pushed — typically a handful. The unchanged files are referenced by their blake3 hash from the previous deploy's entry in Cloudflare's asset store.

You can confirm this by watching the CLI output on a repeat deploy: the "uploading" count will be a fraction of the file count. On the FastTool project specifically (~630 tool pages plus JS bundles and images, roughly 4,500 files total), the first deploy takes ~45 seconds; subsequent deploys after a content-only change clock in at 4-6 seconds.

Two implications. First, do not randomize filenames on every build — that defeats the dedup. Second, fingerprint your assets (hashed filename chunks) so the browser cache can be long-lived and the edge cache can be immutable. When main.a1b2c3.js changes, it becomes main.d4e5f6.js, and both files live at the edge simultaneously during a deploy cutover with zero chance of serving a mismatched bundle.

_headers and _redirects: the edge config files

Two magic files at the root of your output directory configure the edge at deploy time. They are plain text, version-controlled alongside the code, and parsed on every deploy.

_headers maps URL patterns to HTTP headers. Use it for cache control, security headers, and CORS:

# _headers
/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: interest-cohort=()

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

/*.html
  Cache-Control: public, max-age=0, must-revalidate

/api/*
  Access-Control-Allow-Origin: *
  Access-Control-Allow-Methods: GET, POST, OPTIONS

The precedence rule is last-match-wins per header. Define broad defaults at the top and override for specific paths below. Our HTTP Security Headers tool generates a hardened baseline you can paste into _headers without thinking about the details.

_redirects maps source patterns to destinations with status codes:

# _redirects
/old-post   /new-post   301
/blog/:slug /posts/:slug 301
/home       /          301
/docs/*     https://docs.example.com/:splat  301
/api/*      https://api.example.com/:splat  200   # 200 = proxy rewrite
/*          /index.html  200                      # SPA fallback

Status 200 with a full URL target creates a rewrite (proxy) rather than a redirect, which is how SPA fallback is implemented on Pages. The precedence is first-match-wins, so order matters — put specific rules before catch-alls.

CI/CD patterns that hold up

The canonical GitHub Actions workflow for Pages looks like this:

name: Deploy to Cloudflare Pages
on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Deploy
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-static-site
          directory: dist
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}

The gitHubToken field is what enables PR comment annotations with the preview URL — worth the three extra lines. Production builds happen on pushes to main; every other branch creates a preview deployment with its own URL.

Three details that catch teams:

  • Concurrency guard. If two pushes to main land within seconds, you can get out-of-order deploys. Add a concurrency block: concurrency: { group: pages-production, cancel-in-progress: false }. The deploys will queue.
  • Environment variables at build time vs runtime. Variables set in the Cloudflare dashboard are available at build time (for frameworks) and at runtime (for Functions). CI-set variables are build-time only. Mixing them is a common source of "works locally, not on Pages" surprises.
  • Artifact caching. Cache node_modules and your framework's build cache (.next/cache, .vite, etc.) between runs. A cold build of a midsize Next.js site takes 3-4 minutes; cached, it is under 90 seconds.

If you are building YAML-heavy CI workflows, our YAML Validator catches the indentation errors that take forever to debug in GitHub's web UI, and GitHub Actions Generator scaffolds common workflow patterns.

Custom domains, apex records, and SSL

Adding a custom domain to a Pages project requires two things: the domain in your Cloudflare account, and a CNAME record pointing to the Pages project. For the apex (example.com without a subdomain), Cloudflare uses CNAME flattening — you create a CNAME record on the apex and Cloudflare serves the resolved IPs to DNS clients that expect an A/AAAA record.

Workflow:

  1. In the Pages project → Custom domains, click Set up a custom domain.
  2. Enter www.example.com. Cloudflare verifies the zone is in your account and creates the CNAME automatically.
  3. Repeat for the apex example.com.
  4. Pick a canonical — typically the apex — and add a redirect. The cleanest way is a Cloudflare Redirect Rule: Host equals www.example.com → Permanent redirect to https://example.com/${uri}.

SSL is automatic. Cloudflare provisions a Universal SSL certificate via the edge CA within seconds of adding the domain. If you need EV or extended validation, upload your own cert under SSL/TLS → Edge Certificates; the Pages project picks it up automatically.

For international sites, you can verify DNS propagation with our DNS Lookup tool and generate new records with the DNS Record Generator. Both run entirely in the browser and hit a public resolver.

Caching, purge, and rollback

Cloudflare Pages is built around atomic deploys: each deploy is a self-contained content-addressed artifact, and switching production to a new deploy flips a pointer in the edge config with no intermediate state. This means two things for your cache strategy.

First, you almost never need to purge cache manually. When a deploy promotes to production, edge caches serving the old manifest are invalidated automatically because the asset paths and content hashes changed. If you fingerprint your assets — which you should — the old files remain servable to in-flight requests while new requests get the new version, avoiding any mismatched-bundle window.

Second, rollback is a two-click operation. In the Pages dashboard → Deployments, pick any previous deploy and click Rollback to this deployment. Production traffic flips to the selected artifact within seconds. The current bad deploy remains in the list so you can promote it later once fixed.

The edge cases that do require a manual purge:

  • You are serving Pages behind a Worker that caches responses (cache.put). Purge the Worker cache via the Workers dashboard or the Cache API.
  • You have Cache Rules on your custom domain that override Pages defaults. Purge the zone.
  • You are consuming Pages output from a downstream CDN (why would you, but it happens). Purge there.

Pages Functions: when to reach for them

Pages Functions are Cloudflare Workers bound to routes inside your Pages project. Drop a file at functions/api/hello.ts and it is served at /api/hello on your domain. Use them for:

  • API endpoints that need to read environment variables or secrets
  • Server-rendered pages with per-request logic (Astro, SvelteKit, Next.js adapters)
  • Webhook receivers that need to hide an internal service from public access
  • Auth callbacks that exchange OAuth codes for tokens

The runtime is the Workers runtime, which means V8 isolates, ~50ms CPU budget per request on the free plan (30 seconds on Pro), and access to KV, D1, R2, and Queues via bindings configured in wrangler.toml. Pages Functions cost nothing below 100,000 requests per day; above that, pricing matches Workers pricing.

For generating CORS-safe API response headers and testing HTTP status codes in Functions, HTTP Status Codes and HTTP Security Headers are the two references we keep open.

Seven pitfalls we have actually hit

1. Forgetting --commit-dirty=true in local tests

Wrangler refuses to deploy with uncommitted changes unless you opt in. If you are iterating on a feature branch and want to preview, pass the flag.

2. 25,000 file limit

Pages caps a single deployment at 25,000 files. If you have a documentation site with a history of every version, you will hit this. The fix is usually to move old versions to R2 and route to them.

3. _headers precedence surprise

Last match wins. If you set Cache-Control: public, max-age=0 for /* at the top and Cache-Control: public, max-age=31536000, immutable for /assets/* below, the assets override the default, which is what you want. Swap the order and suddenly everything is uncached.

4. Preview deployment environment variables

Variables defined in the Cloudflare dashboard have separate scopes for production and preview. If your OAuth callback URL is hardcoded to the production domain, previews will fail auth. Use CF_PAGES_URL in the Function to construct URLs dynamically.

5. Build cache incompatibility across Node versions

If CI ran on Node 18 and local dev is on Node 20, the same node_modules cache can surface native-module mismatches (looking at you, sharp). Pin the Node version explicitly.

6. Missing SPA fallback rule

A single-page app needs /* /index.html 200 as the last rule in _redirects, or direct navigation to /about returns a 404.

7. Deploying from a fork with a secret in the workflow

GitHub Actions hides repository secrets from pull requests coming from forks as a security measure. If you deploy previews for external contributors, you need pull_request_target (with all its footguns) or a separate build-on-fork-then-deploy-on-merge pattern.

Dev tools that make Pages work smoother

A handful of utility tools pay dividends during a Pages deploy workflow:

  • JWT Decoder — inspect the upload token when debugging CI auth
  • JSON Formatter — read the deployment API responses
  • JSON Validator — verify wrangler.toml-derived manifests
  • YAML Validator — catch GitHub Actions YAML errors before pushing
  • GitHub Actions Generator — scaffold the workflow
  • Hash Generator — reproduce blake3/SHA hashes for asset fingerprinting
  • HTTP Security Headers — generate a hardened _headers baseline
  • HTTP Status Codes — reference while building _redirects rules
  • DNS Lookup — verify apex/www propagation
  • DNS Record Generator — scaffold CNAME and MX records
  • Env File Parser — validate .env files before pasting into Pages
  • cURL to Code — convert Direct Upload API curl examples into Node/Python/Go
  • UUID Generator — deployment IDs and correlation trace headers
  • Base64 Encoder — the upload API expects base64-encoded file contents

All of these run in the browser — they never send your tokens, manifests, or file contents to an external server. Open DevTools → Network on any of them and the only requests are for static assets from fasttool.app.

Related reading

For continuous indexing after deploys, see Indexing API + IndexNow for Static Sites in 2026. For turning a Pages site into a PDF-exportable asset pipeline, see HTML to PDF: Print CSS and Paged Media.

FAQ

What is the difference between Wrangler and the Direct Upload API?

Wrangler is the CLI wrapper. It calls the Direct Upload API — three HTTP requests — under the hood. Use Wrangler for 95 percent of cases; drop to the raw API when you need a deploy pipeline without Node, or when you are integrating Pages deploys into a non-Node toolchain.

Why does Pages use blake3 specifically?

Blake3 is five to ten times faster than SHA-256, keyed for domain separation, and tree-structured, which means a multi-thousand-file site hashes in under a second. The hash becomes the content-addressed key in Cloudflare's asset store so unchanged files never re-upload.

How do _headers and _redirects files work?

Both live at the root of your output directory. _headers maps URL patterns to HTTP headers (last match wins). _redirects maps sources to destinations with status codes (first match wins). Both are parsed at deploy time and enforced at the edge.

Do I need to purge cache after a deploy?

Not for the Pages deployment itself — promotion is atomic and edge caches are invalidated automatically. You only purge if you have a Worker or Cache Rule overlaying the Pages response that caches independently.

How do custom domains and SSL work?

Add the domain under Custom domains in the project. Cloudflare creates a CNAME (or CNAME-flattened apex), provisions a Universal SSL cert in seconds, and starts serving. Add both apex and www; pick one as canonical via a Redirect Rule.

What is the build minutes and bandwidth limit?

As of April 2026: 500 builds/month and unlimited (fair-use) bandwidth on free; 5,000 builds/month on Pro. Single build time caps at 20 minutes (free) or 1 hour (Pro). Bandwidth is effectively unlimited for typical static sites.

Can I run Pages Functions without Workers knowledge?

Yes, for simple endpoints. Drop a TypeScript file in functions/ and export a onRequest handler. The Workers runtime is V8, not Node — so no fs, no process, no native modules. For anything beyond request/response logic, read the Workers docs first.

Closing thought

The best compliment you can give a deployment pipeline is that nobody thinks about it. Cloudflare Pages gets close by making the interesting choices — content-addressed uploads, atomic deploys, automatic SSL, free bandwidth — default behavior rather than opt-in optimizations. Once you have set up the first project with sensible _headers, a CI workflow, and a custom domain, the tenth project takes ten minutes. That is the shape of infrastructure that disappears.