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 > 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
- 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/libvipsforbetter-sqlite3/libvips) — copies the source and runsnpm ci. - The prod build:
npx nuxt generate, then the output is moved to/app/prod. - The staging build:
NUXT_SITE_ENV="staging" npx nuxt generate, leaving its output at/app/.output/public. - The server stage is
FROM joseluisq/static-web-server:2; it copies the staging tree to/publicand the prod tree to/prod, pluswebserver.config.tomlas/sws.toml, and serves on port 80.
Note
/publicis staging,/prodis production. The static-web-server servesroot = "./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/prodis the tree extracted and pushed to Cloudflare Pages by thearcodange_frworkflow. 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:
- A
ServiceAccountcloudflaredis created with aVaultAuth(Kubernetes auth, mountkubernetes, role fromcloudflared.vault.role=cms, audiencevault). - A
VaultStaticSecretnamedcloudflared-tunnel-tokenreads KV-v2 mountkvv2at pathcms/cloudflared(refresh1h) and materialises acloudflared-tunnel-tokenSecret. - The cloudflared
Deployment(1 replica, pinned to acontrol-planenode via affinity) runscloudflared tunnel --no-autoupdate run --token $(TUNNEL_TOKEN) --no-tls-verify, withTUNNEL_TOKENinjected from that Secret'stokenkey.
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-dependenciespublishes both the-depsimage and a matching git tagdeps-YYYYMMDD-SHA8;docker-contentdiscovers that tag (git tag --list "deps-*" | sort -V | tail -n1) to pin itsBASE_IMAGE_TAG. Touchpackage.json/lockfile/Dockerfile.depsand 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
docker-dependenciespublishes the-depsbase image and adeps-YYYYMMDD-SHA8git tag whenever dependencies change.docker-contentresolves that tag, builds the full dual-tree image, and pushes it togitea.arcodange.lab/arcodange-org/cms.arcodange_fr(manual) pulls that image, extracts the/prodtree, and deploys it to Cloudflare Pages projectarcodange-cmson branchmain— this is the live publicarcodange.fr.- 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.frdomain. - tools CrowdSec — the Traefik bouncer middleware fronting both chart ingresses.
- secrets-and-vault concept — where the Cloudflared tunnel token (
kvv2cms/cloudflared) and registry/CF credentials live. - Repo: arcodange-org/cms.