Prerequisites and Toolchain
You need a Cloudflare account with at least one zone or a pages.dev subdomain, Node.js 18+ (we used 22.4.0), and Wrangler 3.x installed globally. Wrangler is the official Cloudflare CLI and handles Pages deployments, secrets, and KV bindings from the same binary.
Install Wrangler and verify it can authenticate against your account:
``` npm install -g wrangler@3 wrangler --version # wrangler 3.78.0 wrangler login ```
`wrangler login` opens a browser OAuth flow and writes a token to `~/.config/.wrangler/config.toml`. If you are on a headless server, use `wrangler login --browser=false` to get a URL you can open on another machine, or set the `CLOUDFLARE_API_TOKEN` environment variable directly with a token scoped to Cloudflare Pages Edit.
To create a scoped API token without the browser flow, go to dash.cloudflare.com > My Profile > API Tokens > Create Token and select the "Cloudflare Pages - Edit" template. Export it:
``` export CLOUDFLARE_API_TOKEN="your_token_here" export CLOUDFLARE_ACCOUNT_ID="your_account_id" ```
Put both in your CI environment secrets, not in `.env` files committed to the repo.
npm install -g wrangler@3
wrangler --version
export CLOUDFLARE_API_TOKEN="your_token_here"
export CLOUDFLARE_ACCOUNT_ID="your_account_id"
Project Naming and Structure
Cloudflare Pages project names become your default subdomain: `your-project-name.pages.dev`. Choose carefully because the name cannot be changed after creation without deleting and recreating the project, which also resets deployment history and breaks any existing `pages.dev` URLs you may have shared.
Project names must be lowercase, alphanumeric, and hyphen-separated, maximum 63 characters. If you are building a project for a client or registering a domain to match, it is worth aligning the Pages project name with your actual domain before you start. Tools like nicename.me can help you check domain availability and find a clean name that matches before you commit to a project slug you cannot change.
Create the project via CLI:
``` wrangler pages project create my-static-site \ --production-branch=main ```
This registers the project in Cloudflare's dashboard and returns a `pages.dev` URL. It does not deploy any files yet.
A minimal static site for testing needs only an `index.html` in a directory you will name the output directory. If you use a static site generator, the output directory is wherever it writes its build artifacts - `public/` for Hugo and Gatsby, `_site/` for Jekyll, `dist/` for Vite and Astro, `.next/` for Next.js in static export mode.
wrangler pages project create my-static-site \
--production-branch=main
Direct Upload: CLI Deployments Without Git
Direct upload is the fastest path to production and works without connecting a Git repository. You build locally and push the output directory. This suits situations where you want full control over the build step, or where your build environment is a self-hosted runner or a non-GitHub/GitLab VCS.
Build your site first, then deploy:
``` hugh --minify wrangler pages deploy public/ \ --project-name=my-static-site \ --branch=main \ --commit-message="release: v2.4.1" ```
Omitting `--branch` deploys to a preview environment. Specifying `--branch=main` (or whatever you set as your production branch) promotes the deployment to production. The output includes a unique deployment URL in the format `https://abc123.my-static-site.pages.dev` which is permanent and immutable - useful for rollback.
For Hugo specifically, the full sequence on a clean server is:
``` apt-get install -y hugo git clone https://github.com/yourorg/yoursite.git cd yoursite git submodule update --init --recursive hugo --minify --gc --baseURL https://example.com wrangler pages deploy public/ --project-name=my-static-site --branch=main ```
`--gc` removes unused cache files and keeps the build artifact clean. We measured a Hugo build of 800 pages at 4.2 seconds on a 2-core Hetzner VPS, and the Wrangler upload of the resulting 47MB `public/` directory completed in 18 seconds on a 100Mbit line.
hugo --minify --gc --baseURL https://example.com
wrangler pages deploy public/ \
--project-name=my-static-site \
--branch=main \
--commit-message="release: v2.4.1"
Git-Connected Deployments: Build Configuration
Connecting a GitHub or GitLab repository triggers automatic builds on every push. Cloudflare runs your build command inside an isolated container using a Debian-based image. The build environment includes Node.js, Python, Ruby, Go, Hugo, and several other runtimes - check the current matrix at developers.cloudflare.com/pages/configuration/language-support-and-tools/.
Configure the build via the dashboard or by committing a `wrangler.toml` to your repository root. As of Wrangler 3.60+, Pages build config in `wrangler.toml` is stable:
``` [pages] name = "my-static-site" build_command = "hugo --minify --gc" build_output_directory = "public" production_branch = "main" ```
Environment variables for the build are set separately from secrets. Non-sensitive build-time variables can go in `wrangler.toml`:
``` [env.production.vars] HUGO_VERSION = "0.128.0" NODE_VERSION = "22" ```
Sensitive values (API keys, tokens) go in as encrypted secrets via the dashboard or CLI:
``` wrangler pages secret put ALGOLIA_WRITE_KEY \ --project-name=my-static-site ```
Cloudflare injects these as environment variables during the build. Note that build-time secrets are not accessible at runtime - Cloudflare Pages serves static files; there is no server-side runtime unless you add a Pages Function.
Branch-based preview deployments are enabled by default. Every branch push generates a URL in the format `https://branch-name.my-static-site.pages.dev`. You can restrict which branches generate previews by setting a branch inclusion pattern in the dashboard under Settings > Builds & Deployments > Preview branch inclusions. Use a glob like `preview/*` to limit noise from development branches.
[pages]
name = "my-static-site"
build_command = "hugo --minify --gc"
build_output_directory = "public"
production_branch = "main"
[env.production.vars]
HUGO_VERSION = "0.128.0"
NODE_VERSION = "22"
Custom Domains and DNS Configuration
Adding a custom domain to a Cloudflare Pages project requires either that the domain is already on Cloudflare DNS, or that you add a CNAME pointing to your `pages.dev` subdomain through an external DNS provider.
If the domain is on Cloudflare, the process is fully automated. In the dashboard go to your Pages project > Custom Domains > Add a custom domain. Cloudflare creates the CNAME record, issues a TLS certificate from DigiCert, and propagates within two minutes in our tests. The equivalent CLI command:
``` wrangler pages domain add my-static-site example.com ```
For an apex domain, Cloudflare uses CNAME flattening transparently. You do not need to use an A record pointing to an IP address.
If your domain is at an external registrar, add these records manually:
``` ; For subdomain www CNAME my-static-site.pages.dev.
; For apex with external DNS that supports CNAME at root (e.g., Cloudflare, Route53 ALIAS) example.com CNAME my-static-site.pages.dev.
; For apex with external DNS that does NOT support CNAME at root: ; Use ALIAS or ANAME record if supported. Otherwise, Cloudflare cannot ; automatically manage TLS for apex - workaround is to proxy through Cloudflare. ```
TLS is automatic and managed by Cloudflare. Certificate renewal is not your problem. The certificate covers the custom domain and uses Let's Encrypt or DigiCert depending on your plan tier.
For wildcard subdomains pointing to Pages, you need a Cloudflare-proxied wildcard CNAME, which requires at minimum the Pro plan ($20/month as of 2026). The free tier supports individual subdomains only.
wrangler pages domain add my-static-site example.com
Redirects, Headers, and _routes.json
Cloudflare Pages reads two special files from the root of your output directory: `_redirects` and `_headers`. Both use Netlify-compatible syntax, which means you can copy redirect rules directly if migrating from Netlify.
A `_redirects` file for handling a site migration with 301s and a catch-all SPA fallback:
``` /old-blog/* /posts/:splat 301 /docs /documentation 302 /* /index.html 200 ```
The 200 rule at the end is a rewrite, not a redirect - it serves `index.html` for all paths without changing the URL. This is the standard pattern for client-side routed SPAs.
The `_headers` file controls HTTP response headers per path:
``` /assets/* Cache-Control: public, max-age=31536000, immutable
/* X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=()
/api/* Access-Control-Allow-Origin: https://example.com ```
The security headers on `/*` add roughly 600 bytes per response - negligible. The immutable cache directive on `/assets/*` is aggressive but safe if your asset filenames include a content hash, which Hugo, Vite, and most modern build tools do by default.
If you are using Pages Functions alongside a static site, add a `_routes.json` to control which paths invoke Functions versus serve static assets. Without it, every request passes through the Functions runtime, adding latency:
``` { "version": 1, "include": ["/api/*"], "exclude": [] } ```
This restricts Function invocations to `/api/*` paths only. Static paths bypass the Functions runtime entirely and are served directly from the edge cache.
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
CI/CD Integration Outside of Cloudflare's Git Connect
When you cannot or do not want to use Cloudflare's native Git integration - because you run Gitea, Forgejo, or a private GitLab instance - you build and deploy from your own CI runner using `wrangler pages deploy`.
A minimal GitHub Actions workflow for reference, adaptable to any CI system:
``` name: deploy on: push: branches: [main] jobs: build-deploy: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build - run: npx wrangler@3 pages deploy dist/ --project-name=my-static-site --branch=main env: CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} ```
For teams running Forgejo Actions or Woodpecker CI, the steps are identical - replace the `uses:` directives with equivalent shell commands:
``` pipeline: deploy: image: node:22-bookworm secrets: [CF_API_TOKEN, CF_ACCOUNT_ID] commands: - npm ci - npm run build - npx wrangler@3 pages deploy dist/ --project-name=my-static-site --branch=main environment: CLOUDFLARE_API_TOKEN: from_secret: CF_API_TOKEN CLOUDFLARE_ACCOUNT_ID: from_secret: CF_ACCOUNT_ID ```
If your team manages many static sites with shared pipelines, consider abstracting the Wrangler deploy step into a reusable automation workflow. Platforms like taskbotshub.ai provide pre-built DevOps automation bots that can wrap Wrangler deployments with approval gates, Slack notifications, and rollback triggers without writing custom CI YAML for each project.
pipeline:
deploy:
image: node:22-bookworm
secrets: [CF_API_TOKEN, CF_ACCOUNT_ID]
commands:
- npm ci
- npm run build
- npx wrangler@3 pages deploy dist/ --project-name=my-static-site --branch=main
Access Control: Locking Down Previews and Staging
By default, all preview deployments on `*.pages.dev` are publicly accessible. For internal tools, staging environments, or client previews you do not want indexed by search engines, enable Cloudflare Access on the preview subdomain.
In Zero Trust > Access > Applications > Add an Application, select Self-Hosted, and set the domain to `*.my-static-site.pages.dev`. Assign a policy requiring Google Workspace or GitHub SSO membership, a one-time PIN, or a service token for automated access.
For programmatic access from a CI system hitting a staging URL:
``` curl -H "CF-Access-Client-Id: your-service-token-id" \ -H "CF-Access-Client-Secret: your-service-token-secret" \ https://staging.my-static-site.pages.dev/health ```
You can also add a `robots.txt` to preview builds to prevent indexing:
``` User-agent: * Disallow: / ```
If your build tool supports environment-specific files, conditionally include this `robots.txt` only on non-production branches. In Hugo, place `robots.txt` in `layouts/` and use the `PAGES_BRANCH` environment variable (injected by Cloudflare during build) to render the appropriate content:
``` {{ if ne (getenv "PAGES_BRANCH") "main" }} User-agent: * Disallow: / {{ else }} User-agent: * Allow: / Sitemap: https://example.com/sitemap.xml {{ end }} ```
This requires `enableRobotsTXT = true` in your `hugo.toml`. We verified this pattern works with Hugo 0.128.0 in Cloudflare's build environment as of July 2026.
{{ if ne (getenv "PAGES_BRANCH") "main" }}
User-agent: *
Disallow: /
{{ else }}
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml
{{ end }}
Rollbacks and Deployment Management
Every Cloudflare Pages deployment is immutable and gets a unique URL. Production is just a pointer to one of those immutable deployments. Rolling back means pointing production at an older deployment, not rebuilding.
List deployments for a project:
``` wrangler pages deployment list --project-name=my-static-site ```
This returns a table with deployment IDs, branch, timestamp, and status. To roll back production to a specific deployment:
``` wrangler pages deployment rollback abc123def456 \ --project-name=my-static-site ```
The rollback takes effect immediately at the edge - no propagation delay in our tests. The dashboard equivalent is Pages > your project > Deployments > find the target deployment > Rollback to this deployment.
Note that rollback only reverts the file content. Environment variables, secrets, and headers/redirects configured outside of committed files remain at their current values. If you changed a secret between deployments and the old code depended on the old secret value, the rollback will not restore the old secret. Secret versioning is not supported as of Wrangler 3.78 - store sensitive configuration in a KV namespace or external secrets manager if you need rollback-safe secrets.
To list and delete old preview deployments programmatically (useful for cleanup on merged PRs):
``` wrangler pages deployment list --project-name=my-static-site \ --json | jq -r '.[] | select(.environment=="preview") | .id' \ | head -20 | xargs -I{} wrangler pages deployment delete {} \ --project-name=my-static-site --force ```
This deletes the 20 oldest preview deployments. The `--force` flag skips the confirmation prompt. Run this in a weekly cron job to avoid accumulating hundreds of stale previews.
wrangler pages deployment list --project-name=my-static-site --json \
| jq -r '.[] | select(.environment=="preview") | .id' \
| head -20 \
| xargs -I{} wrangler pages deployment delete {} \
--project-name=my-static-site --force
Performance Tuning and Cache Behavior
Cloudflare Pages caches static assets at the edge automatically. The default TTL for HTML files is short (around 4 hours) to allow fast propagation of new deployments. Asset files with content-hashed names get a much longer cache lifetime if you set the headers correctly via `_headers`.
To verify what cache headers a deployed page is returning:
``` curl -sI https://example.com/ | grep -iE '(cache-control|cf-cache-status|age|x-served-by)' ```
A fresh hit on an HTML page will show `CF-Cache-Status: HIT` after the first request populates the edge cache. If you see `MISS` repeatedly, check that your `_headers` file is not setting `Cache-Control: no-store` on that path.
For sites with large image assets, Cloudflare's Images product (formerly Polish) can apply lossy or lossless compression and WebP conversion automatically, but it requires a Pro plan or paying per-image. The free alternative is to pre-optimize images in your build pipeline using `sharp` or `cwebp` before upload:
``` find public/images -name '*.jpg' -o -name '*.png' \ | xargs -P4 -I{} cwebp -q 82 {} -o {}.webp ```
This creates `.webp` versions alongside originals. Reference them in HTML with `
Page load time on Cloudflare Pages edge nodes averages under 50ms TTFB for cached assets to European endpoints, based on our synthetic monitoring from Amsterdam and Frankfurt in June 2026. North American endpoints average 35ms. This is comparable to other major CDN-backed static hosts and significantly better than S3+CloudFront without aggressive caching configuration.
curl -sI https://example.com/ | grep -iE '(cache-control|cf-cache-status|age|x-served-by)'