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.
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:
Not all Cloudflare resources are equal. Some belong to individual apps; others belong to the whole organisation. This distinction drives every architecture decision below.
| Resource | Scope | Changes how often? |
|---|---|---|
| Pages project (static site hosting) | Per app | Every deploy |
| Workers / Pages Functions (edge logic) | Per app | Every deploy |
| KV namespaces, R2 buckets (bindings) | Per app | Occasionally |
| Custom domains on Pages | Per app | Rarely |
| DNS zone records (MX, subdomains, CNAMEs) | Organisation | Rarely |
| WAF custom rules | Organisation / zone | Rarely |
| Rate limiting policies | Organisation / zone | Rarely |
| Zero Trust / Access policies | Organisation | Occasionally |
| SSL/TLS strictness settings | Organisation / zone | Rarely |
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.
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" }
| Aspect | Assessment |
|---|---|
| App deployments | Good Works great for Pages + Workers |
| Microstage envs | Good Native Pages preview URLs per branch |
| Repo simplicity | Good One file in each app repo |
| WAF / DNS / ZT / SSL | Bad Not supported — dashboard only |
| Auditability | Bad 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.
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.
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
For standing infrastructure that changes rarely but must be correct, Terraform gives you three things the other approaches cannot:
terraform plan tells you if someone changed something in the dashboard without going through code review.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.
| Aspect | Assessment |
|---|---|
| App deployments | Good Wrangler in each app repo |
| Microstage envs | Good Native Pages previews |
| WAF / DNS / ZT / SSL | Good Fully managed, auditable |
| Drift detection | Good terraform plan in CI |
| App team overhead | Good App teams never touch Terraform |
| Number of repos | Neutral One extra platform infra repo |
| Platform team overhead | Neutral Terraform state to manage |
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 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.
The SDK is the right tool for dynamic, event-driven operations — not standing infrastructure management. Specifically in our case:
| Use case | Why the SDK fits |
|---|---|
| Create a Zero Trust Access policy when a PR is opened | Triggered by a GitLab event, short-lived, cleaned up on PR close |
| Delete preview resources when a PR is merged/closed | Event-driven cleanup, no state needed |
| Seed a KV namespace with data after deployment | Post-deploy step, not infrastructure provisioning |
| Rotate an API token as part of a security workflow | Operational action, not configuration management |
The SDK runs inside GitLab CI as part of the Wrangler-based pipeline — not as a standalone replacement for anything.
# .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 }],
});
| Aspect | Assessment |
|---|---|
| App deployments | Bad Wrong tool — use Wrangler |
| Standing infra (WAF, DNS, ZT) | Bad You rewrite Terraform, poorly |
| Dynamic/event-driven tasks | Good Exactly the right tool |
| Microstage: ZT policy per PR | Good Ideal use case |
| Drift detection | Bad Not supported |
| Auditability of infra changes | Bad No state, no plan output |
| 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 overhead | Low | Low | Low |
| Platform team overhead | High (manual dashboard) | Medium (one Terraform repo) | High (custom scripts) |
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.
This gives us:
wrangler.toml and reference the shared CI template.| Repo | Owner | Contents |
|---|---|---|
cloudflare-platform-infra | Platform team | Terraform modules: dns, waf, zero-trust, rate-limit, ssl |
gitlab-ci-cloudflare | Platform team | Shared CI templates: deploy-pages.yml, preview-comment.yml, protect-preview.yml, cleanup-preview.yml |
frontend-app-* | App teams | App code + wrangler.toml + .gitlab-ci.yml (references shared templates) |
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
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" }
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"
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.
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.
| Template | Trigger | What it does |
|---|---|---|
deploy-pages.yml | Every push | Runs build, runs wrangler pages deploy |
protect-preview.yml | MR opened / updated | SDK: creates a Zero Trust Access policy scoped to the preview URL |
preview-comment.yml | After deploy on MR | Posts the preview URL as a comment on the MR |
cleanup-preview.yml | MR closed / merged | SDK: deletes the Zero Trust policy; optionally deletes the Pages deployment |
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