Files
factory/vibe/guidebooks/cms/site.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 > Site (Nuxt)

Site (Nuxt)

Status: Active Last Updated: 2026-06-23 Upstream: CMS · lab-ecosystem 03 · cms Downstream: Cloudflare Related: Zoho email · tools CrowdSec · secrets-and-vault concept

The public site face of the cms repo: 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. 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/; 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. Every collection is wrapped with asSeoCollection() (from @nuxtjs/seo) and sourced from a folder of Markdown under 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 is a multi-stage build that produces two static outputs from the same source and packs them into a static web server image.

%%{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, 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 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/ 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
Image gitea.arcodange.lab/arcodange-org/cms:latest, pullPolicy: Always 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/):

Template Renders
deployment.yaml the cms static-web-server pod, port http/80
service.yaml ClusterIP service on 80
ingress.yaml lab Traefik ingress for www.arcodange.lab + CrowdSec middleware
ingress_cloudflared.yaml <fullname>-cloudflared ingress for cms-rec.arcodange.fr (web entrypoint)
cloudflared_tunnel.yaml cloudflared SA, VaultAuth, VaultStaticSecret, and the cloudflared Deployment/DaemonSet
serviceaccount.yaml the cms ServiceAccount
ingress_gitea.yaml, hpa.yaml optional Gitea ingress; HPA (disabled)

Cloudflared tunnel template

The cloudflared sidecar pulls its tunnel token from Vault through the VSO 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.

CI: building and deploying

Three Gitea Actions workflows under .gitea/workflows/ cover the site. (A fourth, cloudflare.yaml, drives the OpenTofu — see Cloudflare.)

Workflow Triggers What it does
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 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 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

%%{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 — the guidebook hub: the two faces of the repo and the public request/email flow.
  • Cloudflare — the Cloudflare side of the tunnel, the Pages project, and the zone the deploys publish into.
  • Zoho email — mail for the same arcodange.fr domain.
  • tools CrowdSec — the Traefik bouncer middleware fronting both chart ingresses.
  • secrets-and-vault concept — where the Cloudflared tunnel token (kvv2 cms/cloudflared) and registry/CF credentials live.
  • Repo: arcodange-org/cms.