Files
factory/vibe/guidebooks/cms/cloudflare.md
Gabriel Radureau 548dacfc44 docs(vibe): add tools/ and cms/ guidebooks
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>
2026-06-23 21:41:15 +02:00

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 init can read state. CI injects them from Vault path kvv1/cloudflare/r2/arcodange-tf (mapped to TF_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
  1. ovh_domain_name "arcodange.fr" anchors the registration (imported into state, not created by OpenTofu).
  2. cloudflare_zone creates the Cloudflare zone for that domain under the arcodange@gmail.com account.
  3. ovh_domain_name_servers writes Cloudflare's assigned nameservers back at OVH, delegating DNS to Cloudflare.
  4. cloudflare_pages_project "arcodange-cms" (production branch main) plus two cloudflare_pages_domain resources attach arcodange.fr and www.arcodange.fr to Pages.
  5. cloudflare_dns_record publishes apex (@) and www as proxied CNAMEs pointing at the Pages project's .pages.dev subdomain.
  6. The three modules (cf_tunnel, cf_captcha_for_crowdsec, zoho) and vault_backend hang 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 kvv2 at path cms/cloudflared (the in-repo vault_kv_secret resource is commented out for exactly this reason). The cluster-side cloudflared Deployment reads it via a VaultStaticSecret (Vault Secrets Operator), role cms, 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_backend provisions the cms Vault app role (service account cloudflared) 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.

  1. gitea_vault_auth — mints a Gitea OIDC id-token (decodes vault_oauth__sh_b64 and runs it), exported as gitea_vault_jwt.
  2. tofu — depends on the auth job; a shared *vault_step reads all secrets from Vault (role gitea_cicd_cms, mount gitea_jwt), prepares the homelab CA cert, then runs dflook/terraform-apply@v1 on path: cloudflare/ with auto_approve: true at OpenTofu 1.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: true applies without a human gate. Any dispatch of this workflow on any ref runs tofu apply straight against the live arcodange.fr edge 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 kvv2 at path cms/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_servers points OVH at Cloudflare's nameservers. The use_ovh_initial_name_servers variable (default false) is meant to flip delegation back to OVH's original_name_servers, but that rollback path is untestedterraform_data.arcodange_fr_initial_conf only 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 (from kvv1/cloudflare/r2/arcodange-tf). If those creds are missing or rotated out from under the workflow, even tofu init fails — 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 emailmodule.zoho lives 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/.