Two code-grounded tree-docs guidebooks under vibe/guidebooks/, drilling into the lab-ecosystem 02-tools and 03-cms pages (bidirectional): - tools/ : hub + components.md (Vault+VSO, Prometheus, Grafana, CrowdSec, pgbouncer, Redis/KeyDB, Plausible, ClickHouse; pgcat/tool as Tier-2) + secrets-and-vso.md (Vault engines/auth, the app_roles/app_policy modules = the <app> join-key machinery, VSO CRDs, secret-paths inventory). - cms/ : hub + site.md (Nuxt + dual Pages/k3s deploy) + cloudflare.md (zone via OVH->CF, Pages, cloudflared tunnel, Turnstile, R2 state) + zoho-email.md (OAuth, MX/SPF/DKIM/DMARC/BIMI, the 7 aliases). Sibling-repo code linked via full gitea URLs; vibe-internal links bidirectional. Reconciled the cloudflared tunnel token path to kvv2 cms/cloudflared (the chart VaultStaticSecret is kv-v2; the kvv1 tofu reference is a commented-out stub). 6 mermaid diagrams MCP-validated; zero dead links. Lab Cartographer cohort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
vibe > Guidebooks > CMS > Cloudflare
Cloudflare
Status: ✅ Active Last Updated: 2026-06-23 Upstream: CMS · lab-ecosystem 03 · cms Downstream: tools CrowdSec (consumes the Turnstile widget) Related: Zoho email · tofu CI flow · secrets-and-vault concept · naming conventions · safe-env ADR
This page maps cms/cloudflare/ — the OpenTofu root that owns the arcodange.fr edge. One tofu apply registers the zone at OVH, delegates its DNS to Cloudflare, publishes the public site on Cloudflare Pages, opens a Cloudflared Zero-Trust tunnel into the in-cluster Traefik, mints the Turnstile CAPTCHA the tools CrowdSec bouncer challenges with, and (via a sibling module) wires Zoho mail. The Nuxt site itself is not built here — see Site (Nuxt).
Providers
Declared in providers.tf. Versions pinned in .terraform.lock.hcl.
| Provider | Source | Version | Auth | Purpose |
|---|---|---|---|---|
cloudflare |
cloudflare/cloudflare |
~> 5 |
CLOUDFLARE_API_TOKEN env |
Zone, Pages, DNS records, Zero-Trust tunnel, Turnstile, zone settings |
ovh |
ovh/ovh |
~> 2.8 |
OVH_* env (ovh-eu endpoint) |
Domain registration + nameserver delegation |
vault |
vault |
5.5.0 |
auth_login_jwt (mount gitea_jwt, role gitea_cicd_cms) at https://vault.arcodange.lab |
Persists the Turnstile secret/sitekey; reads tunnel token |
Note
The Vault provider authenticates with a Gitea-issued OIDC JWT (
TERRAFORM_VAULT_AUTH_JWT), the same OIDC→Vault pattern the tofu CI flow documents lab-wide.
State backend — S3 on Cloudflare R2
backend.tf keeps state in an S3-compatible bucket on Cloudflare R2, not AWS. The skip_* flags and use_path_style are what let the AWS S3 backend talk to R2.
| Setting | Value |
|---|---|
bucket |
arcodange-tf |
key |
cms/terraform.tfstate |
region |
auto |
endpoints.s3 |
var.CLOUDFLARE_S3_ENDPOINT (R2 S3 API URL) |
access_key / secret_key |
var.CLOUDFLARE_S3_ACCESS_KEY / var.CLOUDFLARE_S3_SECRET_ACCESS_KEY |
| Flags | skip_credentials_validation, skip_metadata_api_check, skip_region_validation, skip_requesting_account_id, skip_s3_checksum, use_path_style |
Warning
The R2 backend credentials are Terraform variables, so they must be present in the environment before
tofu initcan read state. CI injects them from Vault pathkvv1/cloudflare/r2/arcodange-tf(mapped toTF_VAR_CLOUDFLARE_*— see CI below). Without those creds nothing — not even a read-only plan — can run.
Resource graph
%%{init: {'theme': 'base'}}%%
flowchart TD
classDef ovh fill:#1e3a8a,stroke:#1e40af,color:#fff
classDef cf fill:#d97706,stroke:#b45309,color:#fff
classDef mod fill:#059669,stroke:#047857,color:#fff
classDef vault fill:#7c3aed,stroke:#6d28d9,color:#fff
OVHDOM["ovh_domain_name<br>arcodange.fr"]:::ovh
OVHNS["ovh_domain_name_servers<br>delegate NS"]:::ovh
ZONE["cloudflare_zone<br>arcodange.fr"]:::cf
PAGES["cloudflare_pages_project<br>arcodange-cms (branch main)"]:::cf
PDOM["cloudflare_pages_domain<br>arcodange.fr + www"]:::cf
DNS["cloudflare_dns_record<br>@ + www CNAME (proxied)"]:::cf
TUN["module.cf_tunnel<br>Zero-Trust tunnel 'lab'"]:::mod
CAP["module.cf_captcha_for_crowdsec<br>Turnstile widget"]:::mod
ZOHO["module.zoho<br>mail records"]:::mod
VBACK["module.vault_backend<br>cms app role (cloudflared)"]:::vault
OVHDOM --> ZONE
ZONE -- "name_servers" --> OVHNS
ZONE --> PAGES --> PDOM
PAGES -- "subdomain target" --> DNS
ZONE --> DNS
ZONE --> TUN
ZONE --> ZOHO
OVHDOM --> CAP
ovh_domain_name "arcodange.fr"anchors the registration (imported into state, not created by OpenTofu).cloudflare_zonecreates the Cloudflare zone for that domain under thearcodange@gmail.comaccount.ovh_domain_name_serverswrites Cloudflare's assigned nameservers back at OVH, delegating DNS to Cloudflare.cloudflare_pages_project "arcodange-cms"(production branchmain) plus twocloudflare_pages_domainresources attacharcodange.frandwww.arcodange.frto Pages.cloudflare_dns_recordpublishes apex (@) andwwwas proxied CNAMEs pointing at the Pages project's.pages.devsubdomain.- The three modules (
cf_tunnel,cf_captcha_for_crowdsec,zoho) andvault_backendhang off the same zone/domain/account.
DNS & zone resources
| Resource | Name | Detail |
|---|---|---|
ovh_domain_name.arcodange_fr |
arcodange.fr |
Registration; # was terraform imported into state |
cloudflare_zone.arcodange_fr |
arcodange.fr |
Zone under account resolved from arcodange@gmail.com |
ovh_domain_name_servers.arcodange_fr |
— | Delegates NS to cloudflare_zone…name_servers (or original_name_servers when rolling back) |
terraform_data.arcodange_fr_initial_conf |
— | Snapshot of OVH's pre-Cloudflare config, kept for rollback inspection (ignore_changes) |
cloudflare_pages_project.arcodange_fr |
arcodange-cms |
production_branch = "main" |
cloudflare_pages_domain.arcodange_fr |
arcodange.fr |
Custom domain on Pages |
cloudflare_pages_domain.www_arcodange_fr |
www.arcodange.fr |
Custom domain on Pages |
cloudflare_dns_record.root_cname |
@ |
CNAME → Pages subdomain, proxied = true, ttl = 1 |
cloudflare_dns_record.www_cname |
www |
CNAME → Pages subdomain, proxied = true, ttl = 1 |
All wiring lives in iac.tf. The account id is resolved at plan time via data.cloudflare_account filtered on the arcodange@gmail.com account name.
Module: cloudflared_tunnel
modules/cloudflared_tunnel/. A Zero-Trust Cloudflared tunnel that lets public hostnames reach in-cluster services without opening any home-LAN port — Cloudflare originates the connection from inside the cluster outward. Instantiated as module.cf_tunnel with tunnel_name = "lab".
| Resource | Role |
|---|---|
cloudflare_zero_trust_tunnel_cloudflared.tunnel |
The tunnel named lab under the account |
cloudflare_zero_trust_tunnel_cloudflared_config.tunnel_config |
Ingress rules from hostname_mappings, terminating in a catch-all http_status:404 |
data.cloudflare_zone.arcodange |
Looks up the zone (created by the root module) |
cloudflare_zone_setting.setting |
Sets always_use_https = on |
cloudflare_dns_record.dns |
One proxied CNAME per mapping → <tunnel_id>.cfargotunnel.com |
The single ingress mapping passed from the root is:
| Hostname | Service |
|---|---|
*.arcodange.fr |
http://traefik.kube-system.svc.cluster.local:80 |
So every wildcard subdomain under arcodange.fr lands on the cluster's internal Traefik (origin_request.no_tls_verify = true), which then routes to the right in-cluster app (e.g. the cms Nuxt pod, Grafana, etc.). Pairs with the apex/www Pages records above, which are not tunneled.
Caution
The tunnel token is created by hand and rotation is not automated. Cloudflare only issues a connector token from the web console, so it is manually stored in Vault under the KV-v2 mount
kvv2at pathcms/cloudflared(the in-repovault_kv_secretresource is commented out for exactly this reason). The cluster-sidecloudflaredDeployment reads it via aVaultStaticSecret(Vault Secrets Operator), rolecms, refreshed hourly. If the token is rotated in the console, the Vault entry must be updated manually — nothing in this IaC will do it.module.vault_backendprovisions thecmsVault app role (service accountcloudflared) that grants that read; see secrets-and-vault.
Module: cloudflared_captcha_for_crowdsec
modules/cloudflared_captcha_for_crowdsec/. Mints a Cloudflare Turnstile widget and stores its keys in Vault for the tools CrowdSec bouncer to serve as a CAPTCHA challenge on remediated requests.
| Resource | Detail |
|---|---|
cloudflare_turnstile_widget.turnstile |
name = "crowdsec captcha", mode = "invisible", clearance_level = "interactive", region = "world"; bot_fight_mode/ephemeral_id/offlabel all false |
vault_kv_secret_v2.turnstile |
Writes { sitekey, secret } to KV-v2 (cas = 1) |
Instantiated as module.cf_captcha_for_crowdsec with domain_names = [arcodange.fr, arcodange.lab, arcodange.duckdns.org] and vault_path = "cms/factory/turnstile".
| What | Where |
|---|---|
| Turnstile mode | Invisible widget, interactive clearance — challenges only when CrowdSec flags a request |
| Vault destination | kvv2/cms/factory/turnstile → keys sitekey + secret |
| Consumer | The CrowdSec Traefik bouncer in tools reads sitekey + secret to render and verify the challenge |
This is the one knot that ties the cms edge to the tools security stack: cms produces the Turnstile keys; tools consumes them.
Sibling module: Zoho mail
module.zoho (source ./zoho) lives in this same OpenTofu root and writes mail records into the same cloudflare_zone. It is documented separately on Zoho email — note that a cms/cloudflare apply touches mail DNS too, so plan output there is expected.
CI — cloudflare.yaml
.gitea/workflows/cloudflare.yaml. Manual-only (workflow_dispatch), same Gitea-OIDC→Vault→tofu apply shape as the tofu CI flow concept.
gitea_vault_auth— mints a Gitea OIDC id-token (decodesvault_oauth__sh_b64and runs it), exported asgitea_vault_jwt.tofu— depends on the auth job; a shared*vault_stepreads all secrets from Vault (rolegitea_cicd_cms, mountgitea_jwt), prepares the homelab CA cert, then runsdflook/terraform-apply@v1onpath: cloudflare/withauto_approve: trueat OpenTofu1.8.2.
Vault secrets read by the workflow
| Vault path | Mapped to | Used for |
|---|---|---|
kvv1/cloudflare/cms/cf_arcodange_cms_token (token) |
CLOUDFLARE_API_TOKEN |
Cloudflare provider auth |
kvv1/cloudflare/r2/arcodange-tf (*) |
TF_VAR_CLOUDFLARE_* |
R2/S3 state backend creds + endpoint |
kvv1/gitea/tofu_module_reader (ssh_private_key) |
TERRAFORM_SSH_KEY |
SSH key to clone the tools git module (vault_backend) |
kvv1/ovh/cms/app (*) |
OVH_* |
OVH provider auth |
kvv1/zoho/self_client (*) |
ZOHO_* and TF_VAR_ZOHO_* |
Zoho API auth for module.zoho |
Caution
auto_approve: trueapplies without a human gate. Any dispatch of this workflow on any ref runstofu applystraight against the livearcodange.fredge and Vault. There is no plan-review step; review happens in the PR before merge, not in the apply. Treat a dispatch as a production change.
Gotchas
Caution
Cloudflared tunnel token — manual, unrotated. Created in the Cloudflare console and hand-placed in Vault under
kvv2at pathcms/cloudflared. No IaC rotates it. (Repeated here because it is the most common surprise.)
Warning
OVH → Cloudflare nameserver delegation is the live cutover.
ovh_domain_name_serverspoints OVH at Cloudflare's nameservers. Theuse_ovh_initial_name_serversvariable (defaultfalse) is meant to flip delegation back to OVH'soriginal_name_servers, but that rollback path is untested —terraform_data.arcodange_fr_initial_confonly snapshots the pre-Cloudflare config for inspection. Do not assume a clean revert.
Warning
R2-backed state creds gate everything. State lives on Cloudflare R2 and the access/secret keys are
TF_VAR_inputs (fromkvv1/cloudflare/r2/arcodange-tf). If those creds are missing or rotated out from under the workflow, eventofu initfails — there is no fallback backend.
Cross-references
- CMS — the guidebook hub; the public-request + email flow diagram.
- Site (Nuxt) — the Nuxt app served by the Pages project and the in-cluster pod this tunnel fronts.
- Zoho email —
module.zoholives in this same OpenTofu root. - tools CrowdSec — consumer of the Turnstile widget minted here.
- tofu CI flow concept — the shared Gitea-OIDC→Vault→apply pattern.
- secrets-and-vault concept — where the tunnel token, Turnstile keys, and provider creds live.
- safe-env ADR — why this Internet-facing surface is isolated from the safe prod-like environment.
- Code:
cms/cloudflare/.