Cloudflare Migration: Frontend Infrastructure Approaches

Context: We are migrating our AWS frontend infrastructure (CloudFront + S3) to Cloudflare as part of moving to AWS Sovereign Cloud (Berlin region), which does not support CloudFront. We already have domains, Workers, Pages, and Zero Trust configured in Cloudflare.

Contents

  1. Background and Goals
  2. What We Need to Manage
  3. Approach 1 — Wrangler Only
  4. Approach 2 — Terraform + Wrangler
  5. Approach 3 — Cloudflare SDK in CI
  6. Side-by-Side Comparison
  7. Recommendation
  8. Proposed Repo and CI Structure
  9. Microstage / Per-PR Environments

1. Background and Goals

AWS Sovereign Cloud (eu-central-2, Berlin) does not support CloudFront or S3 static hosting in the same way. We need an alternative CDN and edge platform for all our frontend apps.

Why Cloudflare? We already use it for DNS, Zero Trust, and some Workers. Consolidating there reduces the number of platforms we operate.

Key goals from team feedback:


2. What We Need to Manage

Not all Cloudflare resources are equal. Some belong to individual apps; others belong to the whole organisation. This distinction drives every architecture decision below.

ResourceScopeChanges how often?
Pages project (static site hosting)Per appEvery deploy
Workers / Pages Functions (edge logic)Per appEvery deploy
KV namespaces, R2 buckets (bindings)Per appOccasionally
Custom domains on PagesPer appRarely
DNS zone records (MX, subdomains, CNAMEs)OrganisationRarely
WAF custom rulesOrganisation / zoneRarely
Rate limiting policiesOrganisation / zoneRarely
Zero Trust / Access policiesOrganisationOccasionally
SSL/TLS strictness settingsOrganisation / zoneRarely
Key insight: App-scoped resources are managed per-deploy and belong in app repos. Organisation-scoped resources are managed centrally and need a different tool or repo.

Resource scope diagram

App scope (lives in app repo, managed by Wrangler) Pages project Workers / Functions KV / R2 bindings Custom domains Preview URLs per PR (microstage) Env vars / secrets per environment Organisation scope (shared infra repo, managed by Terraform/Pulumi) WAF custom rules DNS zone records Rate limiting Zero Trust / Access SSL / TLS strictness settings MX records / corporate subdomains

3. Approach 1 — Wrangler Only

What is it?

Wrangler is Cloudflare's own CLI tool. You put a wrangler.toml file in each app repo, and Wrangler handles deploying the app to Cloudflare Pages or Workers. No separate IaC tool is needed for the app itself.

How it works

Every app repo gets a wrangler.toml that describes the Pages project name, environment variables, KV/R2 bindings, and routes. The GitLab CI pipeline runs wrangler pages deploy on every push. That's it.

# wrangler.toml — lives in the app repo
name = "my-frontend-app"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"

[env.production]
vars = { APP_ENV = "production" }

[env.staging]
vars = { APP_ENV = "staging" }

What it covers

What it cannot cover

Wrangler has no support for zone-level or account-level resources. The following cannot be managed with Wrangler at all: If you choose Wrangler-only, these must be managed manually in the Cloudflare dashboard — which means no audit trail, no code review, and no automated recovery if settings are changed by mistake.

Verdict

AspectAssessment
App deploymentsGood Works great for Pages + Workers
Microstage envsGood Native Pages preview URLs per branch
Repo simplicityGood One file in each app repo
WAF / DNS / ZT / SSLBad Not supported — dashboard only
AuditabilityBad Org-level config lives in no repo

Use this approach only for app deployments. It must be combined with something else for org-level config.


4. Approach 2 — Terraform + Wrangler (Two-layer)

What is it?

A clean split: one shared platform infra repo uses Terraform (or Pulumi) to manage everything at the organisation and zone level. Each app repo uses only Wrangler for its own deployment. App teams never see or touch Terraform.

Architecture diagram

APP REPOS Frontend App A src/ wrangler.toml .gitlab-ci.yml Frontend App B src/ wrangler.toml .gitlab-ci.yml Frontend App C src/ wrangler.toml .gitlab-ci.yml cloudflare-platform-infra dns/ · waf/ · ssl/ zero-trust/ · rate-limit/ Terraform (or Pulumi) Shared GitLab CI Library deploy-pages.yml · preview-comment.yml Cloudflare Pages Production deployments Staging deployments Preview URLs per PR (microstage — automatic) Workers / Functions SSR, edge logic API proxies Zone + Org config WAF rules · Rate limiting · SSL/TLS DNS records · MX · Subdomains Zero Trust / Access policies ← managed by platform infra repo

The platform infra repo structure

cloudflare-platform-infra/
├── dns/
│   ├── main.tf          # MX records, subdomains, CNAMEs
│   └── variables.tf
├── waf/
│   ├── main.tf          # Custom WAF rules per zone
│   └── rules.tf
├── zero-trust/
│   ├── applications.tf  # Zero Trust app policies
│   └── policies.tf
├── rate-limiting/
│   └── main.tf
├── ssl/
│   └── main.tf          # TLS strictness, min version
└── README.md

Why keep Terraform here?

For standing infrastructure that changes rarely but must be correct, Terraform gives you three things the other approaches cannot:

Pulumi as an alternative to Terraform

If the team finds Terraform's HCL syntax unfamiliar, Pulumi does the same job with TypeScript. The state file can be stored in S3 (eu-central-1) which fits our AWS Sovereign Cloud setup cleanly. Coverage is identical — the Cloudflare provider is available for both.

Verdict

AspectAssessment
App deploymentsGood Wrangler in each app repo
Microstage envsGood Native Pages previews
WAF / DNS / ZT / SSLGood Fully managed, auditable
Drift detectionGood terraform plan in CI
App team overheadGood App teams never touch Terraform
Number of reposNeutral One extra platform infra repo
Platform team overheadNeutral Terraform state to manage

5. Approach 3 — Cloudflare SDK in CI

What is it?

Cloudflare publishes SDKs in Node.js, Python, and Go that wrap their REST API. They have full coverage of everything the API supports. The idea is to use the SDK in GitLab CI scripts instead of Wrangler or Terraform.

The honest problem with this approach

The SDK is an API client, not an infrastructure manager. Using it as a replacement for Terraform means you have to write code that does what Terraform does automatically:

// With Terraform: you write what you want, Terraform figures out the rest
resource "cloudflare_ruleset" "waf" {
  name = "WAF rules"
  rules = [...]
}

// With the SDK: you write every step manually
const existing = await cf.rulesets.list({ zoneId });
const rule = existing.find(r => r.name === "WAF rules");
if (rule) {
  await cf.rulesets.update(rule.id, { zoneId, rules: [...] });  // update
} else {
  await cf.rulesets.create({ zoneId, name: "WAF rules", rules: [...] });  // create
}
// And you still need to handle: errors, retries, ordering, cleanup, drift

For each resource type (WAF, DNS, Zero Trust, rate limiting) you write this logic from scratch. At scale this becomes a fragile, hard-to-maintain custom IaC system.

Where the SDK genuinely belongs

The SDK is the right tool for dynamic, event-driven operations — not standing infrastructure management. Specifically in our case:

Use caseWhy the SDK fits
Create a Zero Trust Access policy when a PR is openedTriggered by a GitLab event, short-lived, cleaned up on PR close
Delete preview resources when a PR is merged/closedEvent-driven cleanup, no state needed
Seed a KV namespace with data after deploymentPost-deploy step, not infrastructure provisioning
Rotate an API token as part of a security workflowOperational action, not configuration management

The SDK runs inside GitLab CI as part of the Wrangler-based pipeline — not as a standalone replacement for anything.

Example: protect a preview URL with Zero Trust on PR open

# .gitlab-ci.yml (in shared CI library)
protect-preview:
  stage: post-deploy
  script:
    - node scripts/protect-preview.js
  variables:
    PREVIEW_URL: "${CI_MERGE_REQUEST_IID}.${CF_PAGES_PROJECT}.pages.dev"
  only:
    - merge_requests
// scripts/protect-preview.js
import Cloudflare from "cloudflare";

const cf = new Cloudflare({ apiToken: process.env.CF_API_TOKEN });

await cf.zeroTrust.access.applications.create({
  accountId: process.env.CF_ACCOUNT_ID,
  name: `Preview - MR ${process.env.CI_MERGE_REQUEST_IID}`,
  domain: process.env.PREVIEW_URL,
  type: "self_hosted",
  policies: [{ id: process.env.CF_TEAM_POLICY_ID }],
});

Verdict

AspectAssessment
App deploymentsBad Wrong tool — use Wrangler
Standing infra (WAF, DNS, ZT)Bad You rewrite Terraform, poorly
Dynamic/event-driven tasksGood Exactly the right tool
Microstage: ZT policy per PRGood Ideal use case
Drift detectionBad Not supported
Auditability of infra changesBad No state, no plan output

6. Side-by-Side Comparison

Capability Wrangler only Terraform + Wrangler Cloudflare SDK
Pages deployments❌ wrong tool
Workers deployments❌ wrong tool
Per-PR preview URLs✅ native✅ native❌ wrong tool
WAF custom rules⚠️ manual code
DNS zone records⚠️ manual code
Rate limiting⚠️ manual code
Zero Trust / Access⚠️ manual code
SSL/TLS settings⚠️ manual code
Drift detection
Protect preview URLs (event-driven)✅ ideal
Cleanup on PR close✅ ideal
App team overheadLowLowLow
Platform team overheadHigh (manual dashboard)Medium (one Terraform repo)High (custom scripts)

7. Recommendation

Use all three tools — but each for the right job

This is not a choice between approaches. Each tool has a distinct scope where it is clearly the best option. Using all three together removes the tradeoffs entirely.

Wrangler In each app repo Deploy Pages projects Deploy Workers KV / R2 bindings Env vars per stage Preview URLs per PR App teams own this Terraform / Pulumi One platform infra repo WAF custom rules DNS zone records / MX Rate limiting policies Zero Trust / Access SSL / TLS strictness Platform team owns this Cloudflare SDK In shared CI library Create ZT policy on PR open Delete ZT policy on PR close Seed KV after deploy Event-driven operations Rotate secrets / tokens Shared CI library owns this

This gives us:

This directly addresses the original feedback: "No Terraform in app repos" ✅ — "App code + infra colocated" ✅ — "Fewer shared repos" ✅ — "Microstage support" ✅

8. Proposed Repo and CI Structure

Repos

RepoOwnerContents
cloudflare-platform-infraPlatform teamTerraform modules: dns, waf, zero-trust, rate-limit, ssl
gitlab-ci-cloudflarePlatform teamShared CI templates: deploy-pages.yml, preview-comment.yml, protect-preview.yml, cleanup-preview.yml
frontend-app-*App teamsApp code + wrangler.toml + .gitlab-ci.yml (references shared templates)

App repo layout

my-frontend-app/
├── src/                     ← React / Vue / etc.
├── dist/                    ← build output
├── wrangler.toml            ← Cloudflare Pages config (infra-as-code for this app)
├── package.json
└── .gitlab-ci.yml           ← references shared CI templates only

wrangler.toml example

name = "my-frontend-app"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"

[[kv_namespaces]]
binding = "CONFIG"
id = "abc123"

[env.production]
vars = { APP_ENV = "production", API_URL = "https://api.example.com" }

[env.staging]
vars = { APP_ENV = "staging", API_URL = "https://api.staging.example.com" }

.gitlab-ci.yml example (in app repo)

include:
  - project: "platform/gitlab-ci-cloudflare"
    ref: main
    file:
      - "/templates/deploy-pages.yml"
      - "/templates/protect-preview.yml"
      - "/templates/cleanup-preview.yml"

variables:
  CF_PAGES_PROJECT: "my-frontend-app"

9. Microstage / Per-PR Environments

Microstage environments deploy a complete, independent version of the app for every open MR. Reviewers can test the exact code in the PR without running anything locally.

How Cloudflare Pages handles this natively

Every push to any branch automatically gets a unique preview URL:

https://<branch-name>.<pages-project-name>.pages.dev

No extra configuration needed. The URL is stable for the lifetime of that branch.

Full per-PR lifecycle in GitLab CI

1. MR opened Push to feature branch GitLab CI triggers 2. Build & deploy npm run build wrangler pages deploy 3. Protect preview SDK: create ZT policy team can access preview 4. Post URL SDK / API: comment preview URL on MR ... review, iterate, more pushes redeploy automatically ... 5. MR merged GitLab CI triggers cleanup job 6. Cleanup SDK: delete ZT policy Wrangler: remove preview 7. Deploy to prod wrangler pages deploy --branch main Preview URL format: https://mr-42--my-frontend-app.pages.dev — stable per MR, accessible via Zero Trust

What the shared CI templates do

TemplateTriggerWhat it does
deploy-pages.ymlEvery pushRuns build, runs wrangler pages deploy
protect-preview.ymlMR opened / updatedSDK: creates a Zero Trust Access policy scoped to the preview URL
preview-comment.ymlAfter deploy on MRPosts the preview URL as a comment on the MR
cleanup-preview.ymlMR closed / mergedSDK: deletes the Zero Trust policy; optionally deletes the Pages deployment
Note on existing Cloudflare setup: Because you already have Zero Trust configured, the protect-preview.yml template only needs to create an application entry scoped to the preview URL and attach your existing team policy. No new Access groups or identity providers are needed.

Last updated: June 2026 · Owner: Platform / Infrastructure team · Feedback: open an MR against this page