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>
183 lines
14 KiB
Markdown
183 lines
14 KiB
Markdown
[vibe](../../README.md) > [Guidebooks](../README.md) > [CMS](README.md) > **Cloudflare**
|
|
|
|
# Cloudflare
|
|
|
|
> **Status:** ✅ Active
|
|
> **Last Updated:** 2026-06-23
|
|
> **Upstream:** [CMS](README.md) · [lab-ecosystem 03 · cms](../lab-ecosystem/03-cms.md)
|
|
> **Downstream:** [tools CrowdSec](../tools/components.md) (consumes the Turnstile widget)
|
|
> **Related:** [Zoho email](zoho-email.md) · [tofu CI flow](../factory-provisioning/opentofu/ci-apply-flow.md) · [secrets-and-vault concept](../lab-ecosystem/secrets-and-vault.md) · [naming conventions](../lab-ecosystem/naming-conventions.md) · [safe-env ADR](../../ADR/0001-safe-prod-like-environment.md)
|
|
|
|
This page maps [`cms/cloudflare/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/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](../tools/components.md) challenges with, and (via a sibling module) wires **Zoho** mail. The Nuxt site itself is not built here — see [Site (Nuxt)](site.md).
|
|
|
|
## Providers
|
|
|
|
Declared in [`providers.tf`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/providers.tf). Versions pinned in [`.terraform.lock.hcl`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/.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](../factory-provisioning/opentofu/ci-apply-flow.md) documents lab-wide.
|
|
|
|
## State backend — S3 on Cloudflare R2
|
|
|
|
[`backend.tf`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/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](#ci--cloudflareyaml) below). Without those creds nothing — not even a read-only plan — can run.
|
|
|
|
## Resource graph
|
|
|
|
```mermaid
|
|
%%{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`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/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/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/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](../lab-ecosystem/secrets-and-vault.md).
|
|
|
|
## Module: `cloudflared_captcha_for_crowdsec`
|
|
|
|
[`modules/cloudflared_captcha_for_crowdsec/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare/modules/cloudflared_captcha_for_crowdsec). Mints a **Cloudflare Turnstile widget** and stores its keys in Vault for the [tools CrowdSec bouncer](../tools/components.md) 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`](../tools/components.md) 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](zoho-email.md) — note that a `cms/cloudflare` apply touches mail DNS too, so plan output there is expected.
|
|
|
|
## CI — `cloudflare.yaml`
|
|
|
|
[`.gitea/workflows/cloudflare.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/.gitea/workflows/cloudflare.yaml). Manual-only (`workflow_dispatch`), same Gitea-OIDC→Vault→`tofu apply` shape as the [tofu CI flow concept](../factory-provisioning/opentofu/ci-apply-flow.md).
|
|
|
|
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 untested** — `terraform_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](README.md) — the guidebook hub; the public-request + email flow diagram.
|
|
- [Site (Nuxt)](site.md) — the Nuxt app served by the Pages project and the in-cluster pod this tunnel fronts.
|
|
- [Zoho email](zoho-email.md) — `module.zoho` lives in this same OpenTofu root.
|
|
- [tools CrowdSec](../tools/components.md) — consumer of the Turnstile widget minted here.
|
|
- [tofu CI flow concept](../factory-provisioning/opentofu/ci-apply-flow.md) — the shared Gitea-OIDC→Vault→apply pattern.
|
|
- [secrets-and-vault concept](../lab-ecosystem/secrets-and-vault.md) — where the tunnel token, Turnstile keys, and provider creds live.
|
|
- [safe-env ADR](../../ADR/0001-safe-prod-like-environment.md) — why this Internet-facing surface is isolated from the safe prod-like environment.
|
|
- Code: [`cms/cloudflare/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/cloudflare).
|