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>
This commit is contained in:
165
vibe/guidebooks/cms/site.md
Normal file
165
vibe/guidebooks/cms/site.md
Normal file
@@ -0,0 +1,165 @@
|
||||
[vibe](../../README.md) > [Guidebooks](../README.md) > [CMS](README.md) > **Site (Nuxt)**
|
||||
|
||||
# Site (Nuxt)
|
||||
|
||||
> **Status:** ✅ Active
|
||||
> **Last Updated:** 2026-06-23
|
||||
> **Upstream:** [CMS](README.md) · [lab-ecosystem 03 · cms](../lab-ecosystem/03-cms.md)
|
||||
> **Downstream:** [Cloudflare](cloudflare.md)
|
||||
> **Related:** [Zoho email](zoho-email.md) · [tools CrowdSec](../tools/components.md) · [secrets-and-vault concept](../lab-ecosystem/secrets-and-vault.md)
|
||||
|
||||
The public site face of the [`cms` repo](https://gitea.arcodange.lab/arcodange-org/cms): a **Nuxt 4** application built to **static HTML** and shipped two ways from one image — to **Cloudflare Pages** (the live public `arcodange.fr`) and into **k3s** via a Helm chart behind the Cloudflared tunnel. This page maps the Nuxt app, its Docker build, the Helm chart, and the Gitea Actions that drive both deploys.
|
||||
|
||||
## The Nuxt 4 application
|
||||
|
||||
Configured in [`nuxt.config.ts`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/nuxt.config.ts). It runs `ssr: true` for dev but is shipped via **`nuxt generate`** — a full static prerender — so production is plain HTML served by a static file server, no Node runtime.
|
||||
|
||||
| Concern | Setting | Notes |
|
||||
|---|---|---|
|
||||
| Rendering | `ssr: true`, shipped via `nuxt generate` | Static prerender to `.output/public`; Nitro `prerender.autoSubfolderIndex: false` |
|
||||
| Site identity | `site.url: https://arcodange.fr`, `site.name: Arcodange`, `trailingSlash: true` | Drives canonical URLs, sitemap, robots via `@nuxtjs/seo` |
|
||||
| Content | `@nuxt/content` collections | Markdown under [`content/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/content); mermaid highlight enabled |
|
||||
| Editing | **Nuxt Studio** at route **`/admin`** | `nuxt-studio` module; repo `arcodange-org/cms`, commits to `main` |
|
||||
| Sitemap / robots | `@nuxtjs/sitemap` (`zeroRuntime: true`), `@nuxtjs/seo` | No runtime sitemap server — fully prerendered |
|
||||
| Analytics | `@nuxtjs/plausible` | `apiHost: https://analytics.arcodange.fr`, `hashMode: true`, outbound tracking on, `localhost` ignored |
|
||||
| i18n | `@nuxtjs/i18n` | Single locale **`fr`** (default `fr`); `htmlAttrs.lang: fr` |
|
||||
| Images | `@nuxt/image` | `webp`/`jpeg`, quality 80 |
|
||||
| Fonts | `@nuxt/fonts` | Local **Noto Emoji** preloaded |
|
||||
| UI | `@nuxt/ui` | Plus `@nuxt/scripts`, `@nuxtjs/device`, `nuxt-booster`, `@compodium/nuxt` |
|
||||
|
||||
### Content collections
|
||||
|
||||
Declared in [`content.config.ts`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/content.config.ts). Every collection is wrapped with `asSeoCollection()` (from `@nuxtjs/seo`) and sourced from a folder of Markdown under [`content/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/content).
|
||||
|
||||
| Collection | Source glob | Type | Schema extras |
|
||||
|---|---|---|---|
|
||||
| `parcours` | `parcours/*.md` | `page` | — |
|
||||
| `site` | `site/*.md` | `page` | — |
|
||||
| `tech` | `tech/*.md` | `page` | `date` (required), `image` (media), `featured` (default `false`) |
|
||||
| `experiences` | `experiences/*.md` | `page` | `date`, `enddate`, `icon` (default `i-lucide-rocket`), `image`, `secondaryImage`, `descriptionHTML` |
|
||||
|
||||
A content build transformer `~~/content/transformers/description-md` runs at build time, and Markdown highlighting registers the `mermaid` language.
|
||||
|
||||
## Docker build: one image, two static trees
|
||||
|
||||
[`Dockerfile`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/Dockerfile) is a multi-stage build that produces **two** static outputs from the same source and packs them into a static web server image.
|
||||
|
||||
```mermaid
|
||||
%%{init: {'theme': 'base'}}%%
|
||||
flowchart LR
|
||||
classDef base fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
classDef build fill:#059669,stroke:#047857,color:#fff
|
||||
classDef out fill:#d97706,stroke:#b45309,color:#fff
|
||||
|
||||
DEPS["cms-deps:TAG<br>(Dockerfile.deps base)"]:::base
|
||||
BUILD["build stage<br>npm ci"]:::build
|
||||
PROD["nuxt generate<br>→ /app/prod"]:::out
|
||||
STG["NUXT_SITE_ENV=staging<br>nuxt generate → /app/.output/public"]:::out
|
||||
SWS["static-web-server:2<br>serves /public"]:::build
|
||||
|
||||
DEPS --> BUILD
|
||||
BUILD --> PROD
|
||||
BUILD --> STG
|
||||
PROD --> SWS
|
||||
STG --> SWS
|
||||
```
|
||||
|
||||
1. The **build stage** starts `FROM gitea.arcodange.lab/arcodange-org/cms-deps:${BASE_IMAGE_TAG}` — a prebuilt base ([`Dockerfile.deps`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/Dockerfile.deps), `node:24-slim` + `python3`/`make`/`g++`/`sqlite3`/`libvips` for `better-sqlite3`/`libvips`) — copies the source and runs `npm ci`.
|
||||
2. The **prod** build: `npx nuxt generate`, then the output is moved to **`/app/prod`**.
|
||||
3. The **staging** build: `NUXT_SITE_ENV="staging" npx nuxt generate`, leaving its output at **`/app/.output/public`**.
|
||||
4. The **server stage** is `FROM joseluisq/static-web-server:2`; it copies the staging tree to **`/public`** and the prod tree to **`/prod`**, plus [`webserver.config.toml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/webserver.config.toml) as `/sws.toml`, and serves on port 80.
|
||||
|
||||
> [!NOTE]
|
||||
> **`/public` is staging, `/prod` is production.** The static-web-server serves `root = "./public"` (the **staging** build) by default — that is what the in-cluster k3s deploy exposes (e.g. `cms-rec.arcodange.fr`). The **prod** build at `/prod` is the tree extracted and pushed to Cloudflare Pages by the `arcodange_fr` workflow. One image therefore carries both faces.
|
||||
|
||||
The final image is pushed to **`gitea.arcodange.lab/arcodange-org/cms`** (tags `latest` and the branch ref).
|
||||
|
||||
## Helm chart
|
||||
|
||||
[`chart/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart) deploys the in-cluster face. The pod is just the static-web-server image above, fronted by Traefik with a CrowdSec middleware and reached either over the lab ingress (`www.arcodange.lab`) or through a sidecar Cloudflared tunnel (`cms-rec.arcodange.fr`).
|
||||
|
||||
| Key | Value | Source |
|
||||
|---|---|---|
|
||||
| Chart name / version | `arcodange-cms` / `0.1.0`, `appVersion: latest` | [`Chart.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/Chart.yaml) |
|
||||
| Image | `gitea.arcodange.lab/arcodange-org/cms:latest`, `pullPolicy: Always` | [`values.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/values.yaml) |
|
||||
| Replicas | `1` (autoscaling disabled) | `replicaCount: 1`, `autoscaling.enabled: false` |
|
||||
| Service | `ClusterIP`, port **80** (named `http`) | `service.port: 80` |
|
||||
| Probes | liveness + readiness `httpGet /` on `http` | — |
|
||||
| ServiceAccount | created, name **`cms`**, automount on | `serviceAccount.name: cms` |
|
||||
| Lab ingress | `www.arcodange.lab`, path `/` Prefix | Traefik `websecure`, TLS via `letsencrypt` resolver (`arcodange.lab` + SAN `www.arcodange.lab`) |
|
||||
| Edge middleware | `kube-system-crowdsec@kubernetescrd` | applied on both ingresses |
|
||||
| Tunnel ingress | `cms-rec.arcodange.fr`, Traefik `web` entrypoint | `ingress.cloudflared.host` |
|
||||
| Cloudflared sidecar | enabled, `Deployment`, `1` replica, image `cloudflare/cloudflared:latest` | `cloudflared.*` |
|
||||
| Tunnel token | Vault KV-v2 `kvv2` path `cms/cloudflared`, role `cms`, refresh `1h` | `cloudflared.vault.*` |
|
||||
|
||||
### Chart templates
|
||||
|
||||
The chart renders these objects (in [`chart/templates/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates)):
|
||||
|
||||
| Template | Renders |
|
||||
|---|---|
|
||||
| [`deployment.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/deployment.yaml) | the `cms` static-web-server pod, port `http`/80 |
|
||||
| [`service.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/service.yaml) | ClusterIP service on 80 |
|
||||
| [`ingress.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/ingress.yaml) | lab Traefik ingress for `www.arcodange.lab` + CrowdSec middleware |
|
||||
| [`ingress_cloudflared.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/ingress_cloudflared.yaml) | `<fullname>-cloudflared` ingress for `cms-rec.arcodange.fr` (web entrypoint) |
|
||||
| [`cloudflared_tunnel.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/cloudflared_tunnel.yaml) | `cloudflared` SA, `VaultAuth`, `VaultStaticSecret`, and the cloudflared `Deployment`/`DaemonSet` |
|
||||
| [`serviceaccount.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/serviceaccount.yaml) | the `cms` ServiceAccount |
|
||||
| [`ingress_gitea.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/ingress_gitea.yaml), [`hpa.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/chart/templates/hpa.yaml) | optional Gitea ingress; HPA (disabled) |
|
||||
|
||||
### Cloudflared tunnel template
|
||||
|
||||
The cloudflared sidecar pulls its tunnel token from Vault through the [VSO](../tools/components.md) operator, never from a static manifest:
|
||||
|
||||
1. A `ServiceAccount` **`cloudflared`** is created with a `VaultAuth` (Kubernetes auth, mount `kubernetes`, role from `cloudflared.vault.role` = `cms`, audience `vault`).
|
||||
2. A **`VaultStaticSecret`** named `cloudflared-tunnel-token` reads **KV-v2** mount **`kvv2`** at path **`cms/cloudflared`** (refresh `1h`) and materialises a `cloudflared-tunnel-token` Secret.
|
||||
3. The cloudflared `Deployment` (1 replica, pinned to a `control-plane` node via affinity) runs `cloudflared tunnel --no-autoupdate run --token $(TUNNEL_TOKEN) --no-tls-verify`, with `TUNNEL_TOKEN` injected from that Secret's `token` key.
|
||||
|
||||
This connects Cloudflare's edge to internal Traefik so `cms-rec.arcodange.fr` reaches the in-cluster `cms` service without opening any home-LAN port — the cluster side of the tunnel whose Cloudflare side lives in the [Cloudflare IaC](cloudflare.md).
|
||||
|
||||
## CI: building and deploying
|
||||
|
||||
Three Gitea Actions workflows under [`.gitea/workflows/`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/.gitea/workflows) cover the site. (A fourth, `cloudflare.yaml`, drives the OpenTofu — see [Cloudflare](cloudflare.md).)
|
||||
|
||||
| Workflow | Triggers | What it does |
|
||||
|---|---|---|
|
||||
| [`docker-dependencies.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/.gitea/workflows/docker-dependencies.yaml) | `workflow_dispatch`; push to `main` touching `package.json`, `package-lock.json`, `Dockerfile.deps` | Builds the **deps** base image, pushes `gitea.arcodange.lab/<repo>-deps:{latest,YYYYMMDD-SHA8}`, then creates+pushes a **git tag `deps-YYYYMMDD-SHA8`** (with retry, up to 30 attempts) |
|
||||
| [`docker-content.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/.gitea/workflows/docker-content.yaml) | `workflow_dispatch`; push to `main` touching `nuxt.config.ts`, `app/**`, `content.config.ts`, `content/**`, `public/**`, `package*.json`, `Dockerfile` | Finds the latest `deps-*` git tag, strips `deps-` to get `BASE_TAG`, builds the **full image** with `--build-arg BASE_IMAGE_TAG=$BASE_TAG`, pushes `gitea.arcodange.lab/<repo>:{latest,<ref>}` |
|
||||
| [`arcodange_fr.yaml`](https://gitea.arcodange.lab/arcodange-org/cms/src/branch/main/.gitea/workflows/arcodange_fr.yaml) | `workflow_dispatch` (input `image_tag`, default `main`) | Pulls `cms:<image_tag>`, `docker create` + `docker cp` to extract **`/prod`** to `./public`, writes a minimal `wrangler.toml`, then **`wrangler pages deploy`** to project `arcodange-cms`, branch `main` |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **The deps tag is the contract between the two Docker workflows.** `docker-dependencies` publishes both the `-deps` image and a matching **git tag** `deps-YYYYMMDD-SHA8`; `docker-content` discovers that tag (`git tag --list "deps-*" | sort -V | tail -n1`) to pin its `BASE_IMAGE_TAG`. Touch `package.json`/lockfile/`Dockerfile.deps` and the deps build must land first, or the content build pins a stale base.
|
||||
|
||||
### From image to Cloudflare Pages
|
||||
|
||||
```mermaid
|
||||
%%{init: {'theme': 'base'}}%%
|
||||
flowchart LR
|
||||
classDef ci fill:#059669,stroke:#047857,color:#fff
|
||||
classDef reg fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
classDef edge fill:#d97706,stroke:#b45309,color:#fff
|
||||
|
||||
DEP["docker-dependencies<br>→ -deps image + git tag deps-*"]:::ci
|
||||
CON["docker-content<br>pins BASE_IMAGE_TAG"]:::ci
|
||||
REG["registry<br>gitea.arcodange.lab/arcodange-org/cms"]:::reg
|
||||
FR["arcodange_fr<br>extract /prod"]:::ci
|
||||
PAGES["Cloudflare Pages<br>project arcodange-cms"]:::edge
|
||||
K3S["k3s Helm chart<br>serves /public (staging)"]:::edge
|
||||
|
||||
DEP --> CON --> REG
|
||||
REG --> FR --> PAGES
|
||||
REG --> K3S
|
||||
```
|
||||
|
||||
1. **`docker-dependencies`** publishes the `-deps` base image and a `deps-YYYYMMDD-SHA8` git tag whenever dependencies change.
|
||||
2. **`docker-content`** resolves that tag, builds the full dual-tree image, and pushes it to **`gitea.arcodange.lab/arcodange-org/cms`**.
|
||||
3. **`arcodange_fr`** (manual) pulls that image, extracts the **`/prod`** tree, and deploys it to **Cloudflare Pages** project `arcodange-cms` on branch `main` — this is the live public `arcodange.fr`.
|
||||
4. In parallel, the k3s **Helm chart** runs the same image and serves the **`/public`** (staging) tree behind Traefik + CrowdSec and the Cloudflared tunnel (`cms-rec.arcodange.fr`, `www.arcodange.lab`).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [CMS](README.md) — the guidebook hub: the two faces of the repo and the public request/email flow.
|
||||
- [Cloudflare](cloudflare.md) — the Cloudflare side of the tunnel, the Pages project, and the zone the deploys publish into.
|
||||
- [Zoho email](zoho-email.md) — mail for the same `arcodange.fr` domain.
|
||||
- [tools CrowdSec](../tools/components.md) — the Traefik bouncer middleware fronting both chart ingresses.
|
||||
- [secrets-and-vault concept](../lab-ecosystem/secrets-and-vault.md) — where the Cloudflared tunnel token (`kvv2` `cms/cloudflared`) and registry/CF credentials live.
|
||||
- Repo: [arcodange-org/cms](https://gitea.arcodange.lab/arcodange-org/cms).
|
||||
Reference in New Issue
Block a user