# Source of "Deploy indicat"

The files of this tutorial, as they are in the repository. The authoring brief that defines the format follows them.

````yaml title="content/kubernetes/series.yaml"
title:
  en: Kubernetes
  fr: Kubernetes
summary:
  en: >-
    From a few Linux machines to a small production-grade k3s cluster, then a real web
    application on it — indicat, thudal's SaaS prototype — with a database, TLS and
    continuous deployment.
  fr: >-
    De quelques machines Linux à un petit cluster k3s digne de la prod, puis une vraie
    application web dessus — indicat, le prototype SaaS de thudal — avec base de données,
    TLS et déploiement continu.
order:
  - bootstrap-a-k3s-cluster
  - deploy-indicat
  - deploy-on-push-with-ci

# Shared by every page: the reader fills these once for the whole series.
groups:
  - id: cluster
    label: { en: Cluster, fr: Cluster }
    desc: { en: The k3s nodes and how you reach them., fr: Les nœuds k3s et comment tu les atteins. }
  - id: app
    label: { en: Application, fr: Application }
    desc: { en: Where indicat is published and how it is named inside the cluster., fr: Où indicat est publié et comment il est nommé dans le cluster. }

vars:
  - key: NODE_IP
    kind: ip
    group: cluster
    default: 203.0.113.20
    label: { en: First server node IP, fr: IP du premier nœud serveur }
    hint:
      en: The address of the first k3s server, reachable from your laptop. The API and the kubeconfig point at it.
      fr: L'adresse du premier serveur k3s, joignable depuis ton portable. L'API et le kubeconfig pointent dessus.
    impact:
      en: Baked into the API certificate (--tls-san) and into the kubeconfig you copy to your laptop and to CI. Change it later and both must be redone.
      fr: Inscrite dans le certificat de l'API (--tls-san) et dans le kubeconfig que tu copies sur ton portable et dans la CI. La changer plus tard oblige à refaire les deux.
  - key: K3S_VERSION
    kind: text
    group: cluster
    default: v1.31.4+k3s1
    label: { en: k3s version, fr: Version de k3s }
    hint:
      en: The release the install script pins. Every node of the cluster must run the same one.
      fr: La version que fige le script d'installation. Tous les nœuds du cluster doivent avoir la même.
    impact:
      en: Pinning keeps a re-run of the install script from silently upgrading a node. Upgrades are a deliberate step, one node at a time.
      fr: Figer la version évite qu'une relance du script d'installation mette un nœud à jour en silence. Les mises à jour sont un acte volontaire, nœud par nœud.
  - key: APP_DOMAIN
    kind: domain
    group: app
    default: indicat.example.com
    label: { en: Application domain, fr: Domaine de l'application }
    hint:
      en: The name users type. Its A record must point at the node(s) before the certificate can be issued.
      fr: Le nom que tapent les utilisateurs. Son enregistrement A doit pointer vers le(s) nœud(s) avant que le certificat puisse être émis.
    impact:
      en: Used as the Ingress host and as the certificate's common name. With Let's Encrypt the name must resolve publicly, or the HTTP-01 challenge never completes.
      fr: Utilisé comme hôte de l'Ingress et comme nom du certificat. Avec Let's Encrypt, le nom doit résoudre publiquement, sinon le défi HTTP-01 n'aboutit jamais.
  - key: NAMESPACE
    kind: text
    group: app
    default: indicat
    label: { en: Namespace, fr: Namespace }
    hint:
      en: The Kubernetes namespace everything of the application lives in. Lowercase, no spaces.
      fr: Le namespace Kubernetes où vit tout ce qui touche à l'application. Minuscules, sans espace.
    impact:
      en: Every kubectl command of the series carries -n with this value, and the CI ServiceAccount is only allowed inside it.
      fr: Chaque commande kubectl de la série porte -n avec cette valeur, et le ServiceAccount de la CI n'a de droits que dedans.
  - key: ACME_EMAIL
    kind: email
    group: cluster
    default: ops@example.com
    label: { en: Let's Encrypt email, fr: Email Let's Encrypt }
    when: { is: TLS, equals: letsencrypt }
    hint:
      en: The address Let's Encrypt writes to when a certificate is about to expire without renewal.
      fr: L'adresse à laquelle Let's Encrypt écrit quand un certificat va expirer sans avoir été renouvelé.
    impact:
      en: Stored in the ClusterIssuer. Not published; it only matters if cert-manager stops renewing.
      fr: Stockée dans le ClusterIssuer. Non publiée ; elle ne sert que si cert-manager cesse de renouveler.

choices:
  - key: TOPOLOGY
    type: select
    label: { en: Topology, fr: Topologie }
    default: single
    hint:
      en: One node is enough to learn and for a lab. Three servers survive the loss of one.
      fr: Un nœud suffit pour apprendre et pour un lab. Trois serveurs survivent à la perte de l'un d'eux.
    options:
      - { value: single, label: { en: One node (lab), fr: Un nœud (lab) } }
      - { value: ha, label: { en: Three server nodes (HA, embedded etcd), fr: Trois nœuds serveur (HA, etcd embarqué) } }
  - key: TLS
    type: select
    label: { en: Certificates, fr: Certificats }
    default: letsencrypt
    options:
      - { value: letsencrypt, label: { en: Let's Encrypt, fr: Let's Encrypt } }
      - { value: selfsigned, label: { en: Self-signed, fr: Auto-signés } }
  - key: DB
    type: select
    label: { en: PostgreSQL, fr: PostgreSQL }
    default: cnpg
    options:
      - { value: cnpg, label: { en: PostgreSQL by CloudNativePG operator, fr: PostgreSQL par l'opérateur CloudNativePG } }
      - { value: statefulset, label: { en: PostgreSQL as a plain StatefulSet, fr: PostgreSQL en simple StatefulSet } }
````

````yaml title="content/kubernetes/deploy-indicat/tuto.yaml"
# Contract for this page. Inherits the cluster and application groups, NODE_IP, K3S_VERSION,
# APP_DOMAIN, NAMESPACE, ACME_EMAIL and the TOPOLOGY / TLS / DB choices from ../series.yaml.

title:
  en: Deploy indicat
  fr: Déployer indicat
summary:
  en: >-
    A real web application on the cluster: a namespace, its secrets, PostgreSQL (operator or
    StatefulSet), the Deployment with probes and limits, migrations, a Service, an Ingress with
    TLS at your domain, and an autoscaler.
  fr: >-
    Une vraie application web sur le cluster : un namespace, ses secrets, PostgreSQL (opérateur
    ou StatefulSet), le Deployment avec sondes et limites, les migrations, un Service, un Ingress
    avec TLS sur ton domaine, et un autoscaler.
difficulty: intermediate
tags: [kubernetes, k3s, postgresql, cloudnativepg, ingress, hpa]
authors: [thudal]
created: 2026-09-25
minutes: 50
validated: k3s v1.31 · CloudNativePG 1.24 · PostgreSQL 16
status: draft             # not yet run end to end by its author

groups:
  - id: db
    label: { en: Database, fr: Base de données }
    desc: { en: The PostgreSQL indicat writes to., fr: Le PostgreSQL dans lequel indicat écrit. }
  - id: registry
    label: { en: Registry, fr: Registre }
    desc: { en: Credentials to pull the image from a private registry., fr: Identifiants pour tirer l'image d'un registre privé. }
    when: { flag: PRIVATE_REGISTRY }

vars:
  - key: APP_IMAGE
    kind: text
    group: app
    default: ghcr.io/7hud41/indicat:latest
    label: { en: Image, fr: Image }
    hint:
      en: The full image reference, registry included, with a tag. The next page replaces the tag with the commit SHA.
      fr: La référence complète de l'image, registre compris, avec un tag. La page suivante remplace le tag par le SHA du commit.
    impact:
      en: Written into the Deployment and the migrations Job. The registry host in front of the first slash is what the pull secret is created for.
      fr: Écrite dans le Deployment et dans le Job de migrations. L'hôte du registre avant la première barre oblique est celui pour lequel le pull secret est créé.
  - key: APP_PORT
    kind: port
    group: app
    default: "3000"
    label: { en: Port the app listens on, fr: Port d'écoute de l'app }
    hint:
      en: What the process inside the container binds. The Service maps 80 to it.
      fr: Ce que le processus dans le conteneur écoute. Le Service y renvoie le 80.
    impact:
      en: Used by the container port, the probes and the Service target port. Wrong value means probes fail and the pod never becomes Ready.
      fr: Utilisé par le port du conteneur, les sondes et le port cible du Service. Une mauvaise valeur fait échouer les sondes et le pod ne passe jamais Ready.
  - key: APP_REPLICAS
    kind: text
    group: app
    default: "2"
    label: { en: Replicas, fr: Réplicas }
    hint:
      en: How many pods run the application. Two survive a rolling update without downtime; the HPA can add more.
      fr: Combien de pods font tourner l'application. Deux survivent à une mise à jour progressive sans coupure ; le HPA peut en ajouter.
    impact:
      en: Also the HPA's minimum. With more than one, sessions and migrations need the care described at the end of the page.
      fr: Aussi le minimum du HPA. Au-delà d'un, les sessions et les migrations demandent les précautions décrites en fin de page.
  - key: APP_SECRET
    kind: secret
    group: app
    default: ""
    label: { en: Application secret key, fr: Clé secrète de l'application }
    hint:
      en: The SECRET_KEY indicat signs sessions with. Long and random; generate it, never type it.
      fr: La SECRET_KEY avec laquelle indicat signe les sessions. Longue et aléatoire ; génère-la, ne la tape jamais.
    impact:
      en: Stored in the indicat-secrets Secret. Changing it logs every user out.
      fr: Rangée dans le Secret indicat-secrets. La changer déconnecte tous les utilisateurs.
  - key: DB_PASSWORD
    kind: secret
    group: db
    default: ""
    label: { en: PostgreSQL password, fr: Mot de passe PostgreSQL }
    hint:
      en: The password of the "indicat" database role. Generated, never typed by hand.
      fr: Le mot de passe du rôle de base « indicat ». Généré, jamais tapé à la main.
    impact:
      en: Set once in the database Secret and copied into DATABASE_URL. If the two differ, the migrations Job fails with "password authentication failed".
      fr: Défini une fois dans le Secret de la base et recopié dans DATABASE_URL. Si les deux diffèrent, le Job de migrations échoue avec « password authentication failed ».
  - key: REGISTRY_USER
    kind: user
    group: registry
    default: my-github-user
    label: { en: Registry user, fr: Utilisateur du registre }
    when: { flag: PRIVATE_REGISTRY }
    hint:
      en: The account the cluster pulls with. On GHCR, your GitHub username.
      fr: Le compte avec lequel le cluster tire l'image. Sur GHCR, ton identifiant GitHub.
    impact:
      en: Goes into the regcred pull secret of the namespace, on the default ServiceAccount.
      fr: Va dans le pull secret regcred du namespace, sur le ServiceAccount par défaut.
  - key: REGISTRY_TOKEN
    kind: secret
    group: registry
    default: ""
    label: { en: Registry token, fr: Jeton du registre }
    when: { flag: PRIVATE_REGISTRY }
    hint:
      en: A token with read access to packages only. On GitHub, a classic PAT with read:packages.
      fr: Un jeton avec accès en lecture aux paquets seulement. Sur GitHub, un PAT classique avec read:packages.
    impact:
      en: If it expires, new pods fail with ImagePullBackOff while running ones keep going. Set an expiry reminder.
      fr: S'il expire, les nouveaux pods échouent en ImagePullBackOff pendant que ceux qui tournent continuent. Mets-toi un rappel d'expiration.

choices:
  - key: PRIVATE_REGISTRY
    type: boolean
    label: { en: The image is on a private registry, fr: L'image est sur un registre privé }
    hint: { en: Off if the image is public., fr: Décoche si l'image est publique. }
    default: true
````

````mdx title="content/kubernetes/deploy-indicat/page-en.mdx"
{/* First pass — to be validated against kubernetes.io/docs (workloads, probes, HPA), cloudnative-pg.io/documentation (Cluster, bootstrap, backups) and the indicat repository (migration command, /healthz) before publishing. */}

indicat is a containerised web application: one image, <V name="APP_IMAGE" />, that listens on <V name="APP_PORT" />, needs a `DATABASE_URL` and a `SECRET_KEY` in its environment, and answers `/healthz` when it is fine. This page gives it a namespace, its secrets, a PostgreSQL, <V name="APP_REPLICAS" /> replicas with probes and limits, migrations that run before each rollout, an Ingress with TLS at <V name="APP_DOMAIN" />, and an autoscaler.

<Run>

The script does every step of this page from your laptop, against the cluster of the previous page (`~/.kube/config`). Re-running it is safe: every object is applied, not created.

```bash
#!/usr/bin/env bash
set -euo pipefail
# indicat — ${APP_IMAGE} in namespace ${NAMESPACE}, at https://${APP_DOMAIN}
NS=${NAMESPACE}
kubectl create namespace $NS --dry-run=client -o yaml | kubectl apply -f -
kubectl -n $NS create secret generic indicat-db-app --type=kubernetes.io/basic-auth \
  --from-literal=username=indicat --from-literal=password="${DB_PASSWORD}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When is="DB" equals="cnpg">

<When is="TOPOLOGY" equals="single">

```bash
INSTANCES=1
```

</When>

<When is="TOPOLOGY" equals="ha">

```bash
INSTANCES=3
```

</When>

```bash
DBHOST=indicat-db-rw
helm repo add cnpg https://cloudnative-pg.github.io/charts --force-update
helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait
kubectl -n $NS apply -f - <<EOF
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: indicat-db }
spec:
  instances: $INSTANCES
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage: { size: 10Gi, storageClass: local-path }
  bootstrap: { initdb: { database: indicat, owner: indicat, secret: { name: indicat-db-app } } }
EOF
kubectl -n $NS wait --for=condition=Ready cluster/indicat-db --timeout=300s
```

</When>

<When is="DB" equals="statefulset">

```bash
DBHOST=indicat-db
kubectl -n $NS apply -f - <<EOF
apiVersion: v1
kind: Service
metadata: { name: indicat-db }
spec: { clusterIP: None, selector: { app: indicat-db }, ports: [{ port: 5432 }] }
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: indicat-db }
spec:
  serviceName: indicat-db
  replicas: 1
  selector: { matchLabels: { app: indicat-db } }
  template:
    metadata: { labels: { app: indicat-db } }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports: [{ containerPort: 5432 }]
          env:
            - { name: POSTGRES_DB, value: indicat }
            - { name: POSTGRES_USER, value: indicat }
            - { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: indicat-db-app, key: password } } }
            - { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
          volumeMounts: [{ name: data, mountPath: /var/lib/postgresql/data }]
          readinessProbe: { exec: { command: [pg_isready, -U, indicat] }, periodSeconds: 5 }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 10Gi } } }
EOF
kubectl -n $NS rollout status statefulset/indicat-db --timeout=300s
```

</When>

```bash
kubectl -n $NS create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@$DBHOST:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When flag="PRIVATE_REGISTRY">

```bash
kubectl -n $NS create secret docker-registry regcred \
  --docker-server=$(echo ${APP_IMAGE} | cut -d/ -f1) \
  --docker-username=${REGISTRY_USER} --docker-password="${REGISTRY_TOKEN}" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n $NS patch serviceaccount default -p '{"imagePullSecrets":[{"name":"regcred"}]}'
```

</When>

<When is="TLS" equals="letsencrypt">

```bash
ISSUER=letsencrypt
```

</When>

<When is="TLS" equals="selfsigned">

```bash
ISSUER=selfsigned
```

</When>

```bash
kubectl -n $NS delete job indicat-migrate --ignore-not-found
kubectl -n $NS apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: indicat-migrate, labels: { app: indicat-migrate } }
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ${APP_IMAGE}
          command: ["npm", "run", "migrate"]
          envFrom: [{ secretRef: { name: indicat-secrets } }]
EOF
kubectl -n $NS wait --for=condition=complete job/indicat-migrate --timeout=300s
kubectl -n $NS apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: { name: indicat }
spec:
  replicas: ${APP_REPLICAS}
  selector: { matchLabels: { app: indicat } }
  strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
  template:
    metadata: { labels: { app: indicat } }
    spec:
      containers:
        - name: indicat
          image: ${APP_IMAGE}
          ports: [{ containerPort: ${APP_PORT} }]
          envFrom: [{ secretRef: { name: indicat-secrets } }]
          readinessProbe: { httpGet: { path: /healthz, port: ${APP_PORT} }, periodSeconds: 5 }
          livenessProbe: { httpGet: { path: /healthz, port: ${APP_PORT} }, initialDelaySeconds: 15, periodSeconds: 10 }
          resources: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 500m, memory: 256Mi } }
---
apiVersion: v1
kind: Service
metadata: { name: indicat }
spec: { selector: { app: indicat }, ports: [{ port: 80, targetPort: ${APP_PORT} }] }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations: { cert-manager.io/cluster-issuer: $ISSUER }
spec:
  ingressClassName: traefik
  tls: [{ hosts: ["${APP_DOMAIN}"], secretName: indicat-tls }]
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - { path: /, pathType: Prefix, backend: { service: { name: indicat, port: { number: 80 } } } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: indicat }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: indicat }
  minReplicas: ${APP_REPLICAS}
  maxReplicas: 6
  metrics: [{ type: Resource, resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } } }]
EOF
kubectl -n $NS rollout status deploy/indicat --timeout=300s
echo "Done. https://${APP_DOMAIN}/healthz"
```

<Warn>`DATABASE_URL` and `SECRET_KEY` end up in a Kubernetes Secret: base64, not encryption. Anyone who can read Secrets in <V name="NAMESPACE" /> can read them. Keep this script out of git, or move to sealed-secrets or SOPS (see the secrets step).</Warn>

</Run>

## Before you start

<Guided>You need the cluster of the previous page reachable from `kubectl`, the DNS record for <V name="APP_DOMAIN" /> pointing at the node(s), and the image <V name="APP_IMAGE" /> published<When flag="PRIVATE_REGISTRY"> with a token that can pull it</When>. Generate the two secrets now rather than inventing them:</Guided>

```bash
openssl rand -hex 32    # APP_SECRET
openssl rand -hex 16    # DB_PASSWORD
```

<Deep>Everything on this page is a YAML file applied with `kubectl apply`, which creates or updates and never complains that a thing exists. Keep the files in a `deploy/` folder of the indicat repository: the next page's pipeline applies the same folder. The manifests are written for the namespace given with `-n`; they do not carry `metadata.namespace` on purpose, so the same files serve a staging namespace.</Deep>

## Namespace and secrets

<Guided>A namespace is a folder for objects: names only need to be unique inside it, quotas and RBAC attach to it, and deleting it deletes everything in it. Secrets are created from the command line so they never sit in a file.</Guided>

```bash
kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ${NAMESPACE} create secret generic indicat-db-app --type=kubernetes.io/basic-auth \
  --from-literal=username=indicat --from-literal=password="${DB_PASSWORD}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When is="DB" equals="cnpg">

```bash
kubectl -n ${NAMESPACE} create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@indicat-db-rw:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

</When>

<When is="DB" equals="statefulset">

```bash
kubectl -n ${NAMESPACE} create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@indicat-db:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

</When>

<Guided>The database host in `DATABASE_URL` is the name of a Service created in the next step; inside the namespace, a Service name resolves on its own, without a domain suffix.</Guided>

<Deep>`--dry-run=client -o yaml | kubectl apply` is the idiom for an idempotent secret: `create` alone fails the second time. The `basic-auth` type on `indicat-db-app` is not decoration: CloudNativePG requires it for the bootstrap secret. To keep secrets in git safely, two tools: **sealed-secrets** (a controller in the cluster holds a private key; you commit `SealedSecret` objects encrypted with its public key, only that cluster can open them) or **SOPS** with age or a KMS (files encrypted on your side, decrypted by the pipeline or by a Flux/Argo integration). Either replaces the `create secret` commands above; nothing else on this page changes.</Deep>

## PostgreSQL

<When is="DB" equals="cnpg">

<Guided>CloudNativePG is an operator: install it once, then describe a database as a `Cluster` object, and it creates the pods, the volumes, the Services and, with several instances, replication and automatic failover. It is the way to run PostgreSQL on Kubernetes without babysitting it.</Guided>

```bash
helm repo add cnpg https://cloudnative-pg.github.io/charts --force-update
helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait
```

<When is="TOPOLOGY" equals="single">

```yaml title="deploy/postgres.yaml" {5}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: indicat-db
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage:
    size: 10Gi
    storageClass: local-path
  bootstrap:
    initdb:
      database: indicat
      owner: indicat
      secret:
        name: indicat-db-app
```

</When>

<When is="TOPOLOGY" equals="ha">

```yaml title="deploy/postgres.yaml" {5}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: indicat-db
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage:
    size: 10Gi
    storageClass: local-path
  bootstrap:
    initdb:
      database: indicat
      owner: indicat
      secret:
        name: indicat-db-app
```

<Note>Three instances, one per node: the operator spreads them with anti-affinity. One primary takes the writes, two replicas stream from it; if the primary's node dies, a replica is promoted within seconds and the `-rw` Service follows.</Note>

</When>

```bash
kubectl -n ${NAMESPACE} apply -f deploy/postgres.yaml
kubectl -n ${NAMESPACE} get cluster indicat-db -w
```

<Deep>The operator creates three Services: `indicat-db-rw` (the primary, for writes), `indicat-db-ro` (replicas only) and `indicat-db-r` (any instance). The application uses `-rw`. `initdb` runs once, on the empty volume, creating the database and the owner role with the password from the Secret; afterwards the Secret is only read on rotation. Backups are a few more lines in the same object: `backup.barmanObjectStore` pointing at an S3 bucket (or any S3-compatible store) with WAL archiving, plus a `ScheduledBackup` object for the nightly base backup, and point-in-time recovery comes for free. Do that before real data lands here: on `local-path`, the volume dies with the node. `kubectl cnpg status indicat-db` (the kubectl plugin from the CNPG releases) gives the full picture, replication lag included.</Deep>

<Check cmd={"kubectl -n ${NAMESPACE} get cluster indicat-db -o jsonpath='{.status.phase}'"} expect="Cluster in healthy state" />

</When>

<When is="DB" equals="statefulset">

<Guided>Without an operator, PostgreSQL is a StatefulSet: a pod with a stable name (`indicat-db-0`) and a volume that follows it. A headless Service gives the pod a DNS name. It is the simplest thing that works, and it does nothing for you when the pod or the disk fails.</Guided>

```yaml title="deploy/postgres.yaml" {7,21-24,34-38}
apiVersion: v1
kind: Service
metadata:
  name: indicat-db
spec:
  clusterIP: None
  selector: { app: indicat-db }
  ports:
    - port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: indicat-db
spec:
  serviceName: indicat-db
  replicas: 1
  selector:
    matchLabels: { app: indicat-db }
  template:
    metadata:
      labels: { app: indicat-db }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432
          env:
            - { name: POSTGRES_DB, value: indicat }
            - { name: POSTGRES_USER, value: indicat }
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef: { name: indicat-db-app, key: password }
            - { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
          readinessProbe:
            exec: { command: [pg_isready, -U, indicat] }
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 10Gi
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/postgres.yaml
kubectl -n ${NAMESPACE} rollout status statefulset/indicat-db
```

<Deep>`clusterIP: None` makes the Service headless: `indicat-db` resolves straight to the pod's address, and `indicat-db-0.indicat-db` always names that exact pod. `PGDATA` in a subdirectory is the classic workaround for the `lost+found` directory some volumes carry at their root, which makes `initdb` refuse to run. The `volumeClaimTemplates` PVC is not deleted with the StatefulSet: `kubectl delete pvc data-indicat-db-0` is a separate, deliberate act. Backups are on you: a CronJob running `pg_dump` into an object store is the minimum. No replication, no failover; that is what the operator choice buys.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} exec indicat-db-0 -- pg_isready -U indicat" expect="/var/run/postgresql:5432 - accepting connections" />

</When>

<When flag="PRIVATE_REGISTRY">

## Pull secret

<Guided>The kubelet on each node pulls the image itself, so it needs credentials for the registry. A `docker-registry` Secret holds them; attaching it to the namespace's default ServiceAccount means every pod created there uses it, without a line in the manifests.</Guided>

```bash
kubectl -n ${NAMESPACE} create secret docker-registry regcred \
  --docker-server=$(echo ${APP_IMAGE} | cut -d/ -f1) \
  --docker-username=${REGISTRY_USER} --docker-password="${REGISTRY_TOKEN}" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ${NAMESPACE} patch serviceaccount default -p '{"imagePullSecrets":[{"name":"regcred"}]}'
```

<Deep>The server is the first segment of <V name="APP_IMAGE" />: `ghcr.io` for GitHub, `registry.gitlab.com` for GitLab. On GHCR the token is a classic personal access token with `read:packages`, or, better, a fine-grained one scoped to that package. An `ImagePullBackOff` on a pod with `401 Unauthorized` in `kubectl describe pod` means the token is wrong or expired; running pods are unaffected until they restart on another node.</Deep>

<Check cmd={"kubectl -n ${NAMESPACE} get serviceaccount default -o jsonpath='{.imagePullSecrets[0].name}'"} expect="regcred" />

</When>

## Migrations

<Guided>Schema changes must be applied before the new code runs. A Job runs the same image once with the migration command, and the rollout waits for it. Jobs are immutable, so it is deleted and recreated at each deployment.</Guided>

```yaml title="deploy/migrate.yaml" {14}
apiVersion: batch/v1
kind: Job
metadata:
  name: indicat-migrate
  labels: { app: indicat-migrate }
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ${APP_IMAGE}
          command: ["npm", "run", "migrate"]
          envFrom:
            - secretRef: { name: indicat-secrets }
```

<Note>`npm run migrate` is indicat's migration entry point at the time of writing; check the repository's `package.json` if it fails.</Note>

```bash
kubectl -n ${NAMESPACE} delete job indicat-migrate --ignore-not-found
kubectl -n ${NAMESPACE} apply -f deploy/migrate.yaml
kubectl -n ${NAMESPACE} wait --for=condition=complete job/indicat-migrate --timeout=300s
```

<Deep>The alternative is an `initContainer` in the Deployment running the same command: simpler, but with several replicas every new pod runs the migrations concurrently, and a slow one delays every pod. The Job runs once, before anything else, and its logs stay readable for ten minutes (`ttlSecondsAfterFinished`). `backoffLimit: 2` retries twice on failure; a migration that fails three times is something to look at, not to retry.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} wait --for=condition=complete job/indicat-migrate --timeout=10s" expect="job.batch/indicat-migrate condition met" />

## The application

<Guided>Three files for one thing: the Deployment runs the pods, the Service gives them one address, the Ingress publishes that address at <V name="APP_DOMAIN" /> with a certificate. Together they are the application.</Guided>

<Tabs group="indicat-manifests">

<Tab label="deployment.yaml">

<Annotated>

```yaml title="deploy/deployment.yaml" {6,8,17-22}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: indicat
spec:
  replicas: ${APP_REPLICAS}
  selector:
    matchLabels: { app: indicat }
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }           # (1)
  template:
    metadata:
      labels: { app: indicat }
    spec:
      containers:
        - name: indicat
          image: ${APP_IMAGE}
          ports:
            - containerPort: ${APP_PORT}
          envFrom:
            - secretRef: { name: indicat-secrets }              # (2)
          readinessProbe:
            httpGet: { path: /healthz, port: ${APP_PORT} }      # (3)
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: ${APP_PORT} }      # (4)
            initialDelaySeconds: 15
            periodSeconds: 10
          resources:
            requests: { cpu: 100m, memory: 128Mi }              # (5)
            limits: { cpu: 500m, memory: 256Mi }
```

1. Never take a pod down before its replacement is Ready: a rollout adds one pod, waits for it, then removes an old one. With <V name="APP_REPLICAS" /> replicas, users never notice.
2. Every key of the Secret becomes an environment variable: `DATABASE_URL`, `SECRET_KEY`.
3. Readiness: while it fails, the pod is taken out of the Service but left alone. Slow start, lost database: no traffic, no restart.
4. Liveness: when it fails, the kubelet restarts the container. Hence the delay, or a slow start becomes a restart loop.
5. Requests are what the scheduler reserves and what the HPA measures against; limits are where the container is throttled (CPU) or killed (memory). Measure with `kubectl top` after a week and adjust.

</Annotated>

</Tab>

<Tab label="service.yaml">

```yaml title="deploy/service.yaml"
apiVersion: v1
kind: Service
metadata:
  name: indicat
spec:
  selector: { app: indicat }
  ports:
    - port: 80
      targetPort: ${APP_PORT}
```

</Tab>

<Tab label="ingress.yaml">

<When is="TLS" equals="letsencrypt">

```yaml title="deploy/ingress.yaml" {6,10-11}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: traefik
  tls:
    - hosts: ["${APP_DOMAIN}"]
      secretName: indicat-tls
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: indicat
                port: { number: 80 }
```

</When>

<When is="TLS" equals="selfsigned">

```yaml title="deploy/ingress.yaml" {6,10-11}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations:
    cert-manager.io/cluster-issuer: selfsigned
spec:
  ingressClassName: traefik
  tls:
    - hosts: ["${APP_DOMAIN}"]
      secretName: indicat-tls
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: indicat
                port: { number: 80 }
```

</When>

</Tab>

</Tabs>

```bash
kubectl -n ${NAMESPACE} apply -f deploy/deployment.yaml -f deploy/service.yaml -f deploy/ingress.yaml
kubectl -n ${NAMESPACE} rollout status deploy/indicat
```

<Check cmd="kubectl -n ${NAMESPACE} get pods -l app=indicat --field-selector=status.phase=Running --no-headers | wc -l" expect="${APP_REPLICAS}" />

<When is="TLS" equals="letsencrypt">

<Check cmd={"curl -s -o /dev/null -w '%{http_code}' https://${APP_DOMAIN}/healthz"} expect="200" />

</When>

<When is="TLS" equals="selfsigned">

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' https://${APP_DOMAIN}/healthz"} expect="200" />

</When>

<Details summary="If the pods never become Ready">
Start with the events and the logs:

```bash
kubectl -n ${NAMESPACE} describe pod -l app=indicat | tail -20
kubectl -n ${NAMESPACE} logs -l app=indicat --tail=50
```

In order of likelihood:

- `ImagePullBackOff`: wrong image name or tag, or the pull secret is missing or expired.
- `Readiness probe failed: connection refused`: the process does not listen on <V name="APP_PORT" />, or not on all interfaces (`0.0.0.0`).
- The logs show a database error: `DATABASE_URL` has the wrong host or password. Decode the Secret to see what the pod sees: `kubectl get secret indicat-secrets -o jsonpath='{.data.DATABASE_URL}' | base64 -d`.
- `OOMKilled` in the pod status: raise `limits.memory`.
</Details>

## Autoscaling

<Guided>The HorizontalPodAutoscaler adds pods when the average CPU usage crosses 70 % of the requests, and removes them when it drops, between <V name="APP_REPLICAS" /> and 6. It needs metrics-server, which k3s ships.</Guided>

```yaml title="deploy/hpa.yaml"
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: indicat
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: indicat
  minReplicas: ${APP_REPLICAS}
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/hpa.yaml
```

<Deep>Utilization is relative to `requests.cpu`, so the 100m request above matters: at 70m average per pod the HPA scales up. Scaling down waits five minutes by default (`behavior.scaleDown.stabilizationWindowSeconds`), so a spike does not make the pod count flap. Once the HPA owns `replicas`, do not set it in the Deployment anymore: each `kubectl apply` would reset it. Remove the `replicas:` line from `deployment.yaml` after the first rollout, or accept that it is overridden a minute later.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} top pods -l app=indicat --no-headers | wc -l" expect="${APP_REPLICAS}" />

## Day to day: rollout, rollback, logs, exec

<Guided>Everything you did with `systemctl` and `journalctl` has a kubectl equivalent. The five you will use daily:</Guided>

```bash
kubectl -n ${NAMESPACE} rollout status deploy/indicat
kubectl -n ${NAMESPACE} rollout history deploy/indicat
kubectl -n ${NAMESPACE} rollout undo deploy/indicat
kubectl -n ${NAMESPACE} logs -l app=indicat --tail=100 -f
kubectl -n ${NAMESPACE} exec -it deploy/indicat -- sh
```

<Deep>A Deployment keeps its previous ReplicaSets (ten by default, `revisionHistoryLimit`): `rollout undo` scales the previous one back up, with the same zero-downtime strategy. It rolls back the image and the pod template, not the database: a migration that dropped a column is not undone by it, which is why migrations should be backward compatible with the previous release (add, deploy, remove later). `logs -l app=indicat` interleaves every pod; `--prefix` tags each line with the pod name. `exec` on a `deploy/` picks one pod; for a precise one, name it.</Deep>

## More than one replica

With <V name="APP_REPLICAS" /> pods, three things that were free on one server need a decision: sessions, migrations, and what happens during node maintenance.

<Deep>**Sessions.** Two pods do not share memory. If indicat keeps sessions in process, a user bounces between logged-in and logged-out as the Service round-robins. Either store sessions in the database or in a Redis, or make the session a signed cookie (`SECRET_KEY` is exactly for that). Sticky sessions on the Ingress (Traefik's `sticky` annotation on the Service) are the workaround, not the fix. **Migrations.** The Job runs once, but the old pods keep running against the new schema until the rollout replaces them: every migration must be compatible with the previous release. Expand, deploy, contract. **Disruption.** `kubectl drain` on a node (upgrades, maintenance) evicts pods; without a limit it can evict both at once. A `PodDisruptionBudget` with `minAvailable: 1` on `app: indicat` makes drain wait for one replacement to be Ready before evicting the second pod. Five lines, worth having as soon as replicas are more than one:</Deep>

<Deep>

```yaml title="deploy/pdb.yaml"
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: indicat
spec:
  minAvailable: 1
  selector:
    matchLabels: { app: indicat }
```

</Deep>

## Done

indicat answers at <V name="APP_DOMAIN" /> from <V name="APP_REPLICAS" /> pods, with its database, its secrets, migrations that run before each rollout, and an autoscaler. Every object is a file in `deploy/`, which is exactly what the next page needs: a pipeline that builds the image on each push, tags it with the commit, and applies these files with a ServiceAccount that can only touch <V name="NAMESPACE" />.
````

````mdx title="content/kubernetes/deploy-indicat/page-fr.mdx"
{/* Première passe — à valider contre kubernetes.io/docs (workloads, sondes, HPA), cloudnative-pg.io/documentation (Cluster, bootstrap, sauvegardes) et le dépôt indicat (commande de migration, /healthz) avant publication. */}

indicat est une application web conteneurisée : une image, <V name="APP_IMAGE" />, qui écoute sur <V name="APP_PORT" />, a besoin d'un `DATABASE_URL` et d'une `SECRET_KEY` dans son environnement, et répond sur `/healthz` quand elle va bien. Cette page lui donne un namespace, ses secrets, un PostgreSQL, <V name="APP_REPLICAS" /> réplicas avec sondes et limites, des migrations qui tournent avant chaque rollout, un Ingress avec TLS sur <V name="APP_DOMAIN" />, et un autoscaler.

<Run>

Le script fait toutes les étapes de cette page depuis ton portable, contre le cluster de la page précédente (`~/.kube/config`). Le relancer est sans risque : chaque objet est appliqué, pas créé.

```bash
#!/usr/bin/env bash
set -euo pipefail
# indicat — ${APP_IMAGE} dans le namespace ${NAMESPACE}, sur https://${APP_DOMAIN}
NS=${NAMESPACE}
kubectl create namespace $NS --dry-run=client -o yaml | kubectl apply -f -
kubectl -n $NS create secret generic indicat-db-app --type=kubernetes.io/basic-auth \
  --from-literal=username=indicat --from-literal=password="${DB_PASSWORD}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When is="DB" equals="cnpg">

<When is="TOPOLOGY" equals="single">

```bash
INSTANCES=1
```

</When>

<When is="TOPOLOGY" equals="ha">

```bash
INSTANCES=3
```

</When>

```bash
DBHOST=indicat-db-rw
helm repo add cnpg https://cloudnative-pg.github.io/charts --force-update
helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait
kubectl -n $NS apply -f - <<EOF
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: indicat-db }
spec:
  instances: $INSTANCES
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage: { size: 10Gi, storageClass: local-path }
  bootstrap: { initdb: { database: indicat, owner: indicat, secret: { name: indicat-db-app } } }
EOF
kubectl -n $NS wait --for=condition=Ready cluster/indicat-db --timeout=300s
```

</When>

<When is="DB" equals="statefulset">

```bash
DBHOST=indicat-db
kubectl -n $NS apply -f - <<EOF
apiVersion: v1
kind: Service
metadata: { name: indicat-db }
spec: { clusterIP: None, selector: { app: indicat-db }, ports: [{ port: 5432 }] }
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: indicat-db }
spec:
  serviceName: indicat-db
  replicas: 1
  selector: { matchLabels: { app: indicat-db } }
  template:
    metadata: { labels: { app: indicat-db } }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports: [{ containerPort: 5432 }]
          env:
            - { name: POSTGRES_DB, value: indicat }
            - { name: POSTGRES_USER, value: indicat }
            - { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: indicat-db-app, key: password } } }
            - { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
          volumeMounts: [{ name: data, mountPath: /var/lib/postgresql/data }]
          readinessProbe: { exec: { command: [pg_isready, -U, indicat] }, periodSeconds: 5 }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 10Gi } } }
EOF
kubectl -n $NS rollout status statefulset/indicat-db --timeout=300s
```

</When>

```bash
kubectl -n $NS create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@$DBHOST:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When flag="PRIVATE_REGISTRY">

```bash
kubectl -n $NS create secret docker-registry regcred \
  --docker-server=$(echo ${APP_IMAGE} | cut -d/ -f1) \
  --docker-username=${REGISTRY_USER} --docker-password="${REGISTRY_TOKEN}" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n $NS patch serviceaccount default -p '{"imagePullSecrets":[{"name":"regcred"}]}'
```

</When>

<When is="TLS" equals="letsencrypt">

```bash
ISSUER=letsencrypt
```

</When>

<When is="TLS" equals="selfsigned">

```bash
ISSUER=selfsigned
```

</When>

```bash
kubectl -n $NS delete job indicat-migrate --ignore-not-found
kubectl -n $NS apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: indicat-migrate, labels: { app: indicat-migrate } }
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ${APP_IMAGE}
          command: ["npm", "run", "migrate"]
          envFrom: [{ secretRef: { name: indicat-secrets } }]
EOF
kubectl -n $NS wait --for=condition=complete job/indicat-migrate --timeout=300s
kubectl -n $NS apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: { name: indicat }
spec:
  replicas: ${APP_REPLICAS}
  selector: { matchLabels: { app: indicat } }
  strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
  template:
    metadata: { labels: { app: indicat } }
    spec:
      containers:
        - name: indicat
          image: ${APP_IMAGE}
          ports: [{ containerPort: ${APP_PORT} }]
          envFrom: [{ secretRef: { name: indicat-secrets } }]
          readinessProbe: { httpGet: { path: /healthz, port: ${APP_PORT} }, periodSeconds: 5 }
          livenessProbe: { httpGet: { path: /healthz, port: ${APP_PORT} }, initialDelaySeconds: 15, periodSeconds: 10 }
          resources: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 500m, memory: 256Mi } }
---
apiVersion: v1
kind: Service
metadata: { name: indicat }
spec: { selector: { app: indicat }, ports: [{ port: 80, targetPort: ${APP_PORT} }] }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations: { cert-manager.io/cluster-issuer: $ISSUER }
spec:
  ingressClassName: traefik
  tls: [{ hosts: ["${APP_DOMAIN}"], secretName: indicat-tls }]
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - { path: /, pathType: Prefix, backend: { service: { name: indicat, port: { number: 80 } } } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: indicat }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: indicat }
  minReplicas: ${APP_REPLICAS}
  maxReplicas: 6
  metrics: [{ type: Resource, resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } } }]
EOF
kubectl -n $NS rollout status deploy/indicat --timeout=300s
echo "Terminé. https://${APP_DOMAIN}/healthz"
```

<Warn>`DATABASE_URL` et `SECRET_KEY` finissent dans un Secret Kubernetes : du base64, pas du chiffrement. Quiconque peut lire les Secrets de <V name="NAMESPACE" /> peut les lire. Garde ce script hors de git, ou passe à sealed-secrets ou SOPS (voir l'étape des secrets).</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut le cluster de la page précédente joignable par `kubectl`, l'enregistrement DNS de <V name="APP_DOMAIN" /> qui pointe vers le(s) nœud(s), et l'image <V name="APP_IMAGE" /> publiée<When flag="PRIVATE_REGISTRY"> avec un jeton capable de la tirer</When>. Génère les deux secrets maintenant plutôt que de les inventer :</Guided>

```bash
openssl rand -hex 32    # APP_SECRET
openssl rand -hex 16    # DB_PASSWORD
```

<Deep>Tout sur cette page est un fichier YAML appliqué avec `kubectl apply`, qui crée ou met à jour et ne se plaint jamais qu'une chose existe. Garde les fichiers dans un dossier `deploy/` du dépôt indicat : le pipeline de la page suivante applique le même dossier. Les manifestes sont écrits pour le namespace passé avec `-n` ; ils ne portent pas de `metadata.namespace`, exprès, pour que les mêmes fichiers servent à un namespace de staging.</Deep>

## Namespace et secrets

<Guided>Un namespace, c'est un dossier pour les objets : les noms n'ont besoin d'être uniques que dedans, les quotas et le RBAC s'y accrochent, et le supprimer supprime tout ce qu'il contient. Les secrets sont créés en ligne de commande pour ne jamais traîner dans un fichier.</Guided>

```bash
kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ${NAMESPACE} create secret generic indicat-db-app --type=kubernetes.io/basic-auth \
  --from-literal=username=indicat --from-literal=password="${DB_PASSWORD}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

<When is="DB" equals="cnpg">

```bash
kubectl -n ${NAMESPACE} create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@indicat-db-rw:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

</When>

<When is="DB" equals="statefulset">

```bash
kubectl -n ${NAMESPACE} create secret generic indicat-secrets \
  --from-literal=DATABASE_URL="postgresql://indicat:${DB_PASSWORD}@indicat-db:5432/indicat" \
  --from-literal=SECRET_KEY="${APP_SECRET}" \
  --dry-run=client -o yaml | kubectl apply -f -
```

</When>

<Guided>L'hôte de base dans `DATABASE_URL` est le nom d'un Service créé à l'étape suivante ; dans le namespace, un nom de Service résout tout seul, sans suffixe de domaine.</Guided>

<Deep>`--dry-run=client -o yaml | kubectl apply`, c'est l'idiome du secret idempotent : `create` seul échoue la deuxième fois. Le type `basic-auth` sur `indicat-db-app` n'est pas décoratif : CloudNativePG l'exige pour le secret de bootstrap. Pour garder des secrets dans git sans risque, deux outils : **sealed-secrets** (un contrôleur dans le cluster détient une clé privée ; tu commites des objets `SealedSecret` chiffrés avec sa clé publique, seul ce cluster peut les ouvrir) ou **SOPS** avec age ou un KMS (fichiers chiffrés de ton côté, déchiffrés par le pipeline ou par une intégration Flux/Argo). L'un ou l'autre remplace les commandes `create secret` ci-dessus ; rien d'autre sur cette page ne change.</Deep>

## PostgreSQL

<When is="DB" equals="cnpg">

<Guided>CloudNativePG est un opérateur : tu l'installes une fois, puis tu décris une base comme un objet `Cluster`, et il crée les pods, les volumes, les Services et, avec plusieurs instances, la réplication et la bascule automatique. C'est la façon de faire tourner PostgreSQL sur Kubernetes sans le materner.</Guided>

```bash
helm repo add cnpg https://cloudnative-pg.github.io/charts --force-update
helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait
```

<When is="TOPOLOGY" equals="single">

```yaml title="deploy/postgres.yaml" {5}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: indicat-db
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage:
    size: 10Gi
    storageClass: local-path
  bootstrap:
    initdb:
      database: indicat
      owner: indicat
      secret:
        name: indicat-db-app
```

</When>

<When is="TOPOLOGY" equals="ha">

```yaml title="deploy/postgres.yaml" {5}
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: indicat-db
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  storage:
    size: 10Gi
    storageClass: local-path
  bootstrap:
    initdb:
      database: indicat
      owner: indicat
      secret:
        name: indicat-db-app
```

<Note>Trois instances, une par nœud : l'opérateur les répartit par anti-affinité. Un primaire prend les écritures, deux réplicas répliquent en flux depuis lui ; si le nœud du primaire meurt, un réplica est promu en quelques secondes et le Service `-rw` suit.</Note>

</When>

```bash
kubectl -n ${NAMESPACE} apply -f deploy/postgres.yaml
kubectl -n ${NAMESPACE} get cluster indicat-db -w
```

<Deep>L'opérateur crée trois Services : `indicat-db-rw` (le primaire, pour les écritures), `indicat-db-ro` (les réplicas seulement) et `indicat-db-r` (n'importe quelle instance). L'application utilise `-rw`. `initdb` tourne une fois, sur le volume vide, en créant la base et le rôle propriétaire avec le mot de passe du Secret ; ensuite le Secret n'est relu qu'à la rotation. Les sauvegardes, c'est quelques lignes de plus dans le même objet : `backup.barmanObjectStore` vers un bucket S3 (ou tout stockage compatible S3) avec archivage des WAL, plus un objet `ScheduledBackup` pour la sauvegarde de base nocturne, et la restauration à un instant donné vient gratuitement. Fais-le avant que de vraies données arrivent ici : sur `local-path`, le volume meurt avec le nœud. `kubectl cnpg status indicat-db` (le plugin kubectl des releases CNPG) donne la vue complète, retard de réplication compris.</Deep>

<Check cmd={"kubectl -n ${NAMESPACE} get cluster indicat-db -o jsonpath='{.status.phase}'"} expect="Cluster in healthy state" />

</When>

<When is="DB" equals="statefulset">

<Guided>Sans opérateur, PostgreSQL est un StatefulSet : un pod au nom stable (`indicat-db-0`) et un volume qui le suit. Un Service headless donne au pod un nom DNS. C'est la chose la plus simple qui marche, et elle ne fait rien pour toi quand le pod ou le disque lâche.</Guided>

```yaml title="deploy/postgres.yaml" {7,21-24,34-38}
apiVersion: v1
kind: Service
metadata:
  name: indicat-db
spec:
  clusterIP: None
  selector: { app: indicat-db }
  ports:
    - port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: indicat-db
spec:
  serviceName: indicat-db
  replicas: 1
  selector:
    matchLabels: { app: indicat-db }
  template:
    metadata:
      labels: { app: indicat-db }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432
          env:
            - { name: POSTGRES_DB, value: indicat }
            - { name: POSTGRES_USER, value: indicat }
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef: { name: indicat-db-app, key: password }
            - { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
          readinessProbe:
            exec: { command: [pg_isready, -U, indicat] }
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 10Gi
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/postgres.yaml
kubectl -n ${NAMESPACE} rollout status statefulset/indicat-db
```

<Deep>`clusterIP: None` rend le Service headless : `indicat-db` résout directement vers l'adresse du pod, et `indicat-db-0.indicat-db` nomme toujours ce pod précis. `PGDATA` dans un sous-répertoire est le contournement classique du répertoire `lost+found` que certains volumes portent à leur racine, et qui fait refuser `initdb`. Le PVC issu de `volumeClaimTemplates` n'est pas supprimé avec le StatefulSet : `kubectl delete pvc data-indicat-db-0` est un acte séparé et volontaire. Les sauvegardes sont pour toi : un CronJob qui lance `pg_dump` vers un stockage objet est le minimum. Pas de réplication, pas de bascule ; c'est ce que le choix de l'opérateur t'achète.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} exec indicat-db-0 -- pg_isready -U indicat" expect="/var/run/postgresql:5432 - accepting connections" />

</When>

<When flag="PRIVATE_REGISTRY">

## Pull secret

<Guided>Le kubelet de chaque nœud tire l'image lui-même, il lui faut donc des identifiants pour le registre. Un Secret `docker-registry` les contient ; l'attacher au ServiceAccount par défaut du namespace fait que chaque pod créé dedans l'utilise, sans une ligne dans les manifestes.</Guided>

```bash
kubectl -n ${NAMESPACE} create secret docker-registry regcred \
  --docker-server=$(echo ${APP_IMAGE} | cut -d/ -f1) \
  --docker-username=${REGISTRY_USER} --docker-password="${REGISTRY_TOKEN}" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ${NAMESPACE} patch serviceaccount default -p '{"imagePullSecrets":[{"name":"regcred"}]}'
```

<Deep>Le serveur est le premier segment de <V name="APP_IMAGE" /> : `ghcr.io` pour GitHub, `registry.gitlab.com` pour GitLab. Sur GHCR le jeton est un personal access token classique avec `read:packages`, ou, mieux, un jeton fin limité à ce paquet. Un `ImagePullBackOff` sur un pod avec `401 Unauthorized` dans `kubectl describe pod` veut dire que le jeton est faux ou expiré ; les pods en cours ne sont pas touchés tant qu'ils ne redémarrent pas sur un autre nœud.</Deep>

<Check cmd={"kubectl -n ${NAMESPACE} get serviceaccount default -o jsonpath='{.imagePullSecrets[0].name}'"} expect="regcred" />

</When>

## Migrations

<Guided>Les changements de schéma doivent être appliqués avant que le nouveau code tourne. Un Job lance la même image une fois avec la commande de migration, et le rollout l'attend. Les Jobs sont immuables, donc il est supprimé et recréé à chaque déploiement.</Guided>

```yaml title="deploy/migrate.yaml" {14}
apiVersion: batch/v1
kind: Job
metadata:
  name: indicat-migrate
  labels: { app: indicat-migrate }
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ${APP_IMAGE}
          command: ["npm", "run", "migrate"]
          envFrom:
            - secretRef: { name: indicat-secrets }
```

<Note>`npm run migrate` est le point d'entrée des migrations d'indicat au moment d'écrire ; vérifie le `package.json` du dépôt si ça échoue.</Note>

```bash
kubectl -n ${NAMESPACE} delete job indicat-migrate --ignore-not-found
kubectl -n ${NAMESPACE} apply -f deploy/migrate.yaml
kubectl -n ${NAMESPACE} wait --for=condition=complete job/indicat-migrate --timeout=300s
```

<Deep>L'alternative est un `initContainer` dans le Deployment qui lance la même commande : plus simple, mais avec plusieurs réplicas chaque nouveau pod lance les migrations en parallèle, et une lente retarde tous les pods. Le Job tourne une fois, avant tout le reste, et ses logs restent lisibles dix minutes (`ttlSecondsAfterFinished`). `backoffLimit: 2` réessaie deux fois en cas d'échec ; une migration qui échoue trois fois, ça se regarde, ça ne se relance pas.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} wait --for=condition=complete job/indicat-migrate --timeout=10s" expect="job.batch/indicat-migrate condition met" />

## L'application

<Guided>Trois fichiers pour une seule chose : le Deployment fait tourner les pods, le Service leur donne une adresse, l'Ingress publie cette adresse sur <V name="APP_DOMAIN" /> avec un certificat. Ensemble, ils sont l'application.</Guided>

<Tabs group="indicat-manifests">

<Tab label="deployment.yaml">

<Annotated>

```yaml title="deploy/deployment.yaml" {6,8,17-22}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: indicat
spec:
  replicas: ${APP_REPLICAS}
  selector:
    matchLabels: { app: indicat }
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }           # (1)
  template:
    metadata:
      labels: { app: indicat }
    spec:
      containers:
        - name: indicat
          image: ${APP_IMAGE}
          ports:
            - containerPort: ${APP_PORT}
          envFrom:
            - secretRef: { name: indicat-secrets }              # (2)
          readinessProbe:
            httpGet: { path: /healthz, port: ${APP_PORT} }      # (3)
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: ${APP_PORT} }      # (4)
            initialDelaySeconds: 15
            periodSeconds: 10
          resources:
            requests: { cpu: 100m, memory: 128Mi }              # (5)
            limits: { cpu: 500m, memory: 256Mi }
```

1. Ne jamais retirer un pod avant que son remplaçant soit Ready : un rollout ajoute un pod, l'attend, puis retire un ancien. Avec <V name="APP_REPLICAS" /> réplicas, les utilisateurs ne voient rien.
2. Chaque clé du Secret devient une variable d'environnement : `DATABASE_URL`, `SECRET_KEY`.
3. Readiness : tant qu'elle échoue, le pod est sorti du Service mais laissé tranquille. Démarrage lent, base perdue : pas de trafic, pas de redémarrage.
4. Liveness : quand elle échoue, le kubelet redémarre le conteneur. D'où le délai, sinon un démarrage lent devient une boucle de redémarrages.
5. Les requests sont ce que le scheduler réserve et ce contre quoi le HPA mesure ; les limits sont là où le conteneur est bridé (CPU) ou tué (mémoire). Mesure avec `kubectl top` après une semaine et ajuste.

</Annotated>

</Tab>

<Tab label="service.yaml">

```yaml title="deploy/service.yaml"
apiVersion: v1
kind: Service
metadata:
  name: indicat
spec:
  selector: { app: indicat }
  ports:
    - port: 80
      targetPort: ${APP_PORT}
```

</Tab>

<Tab label="ingress.yaml">

<When is="TLS" equals="letsencrypt">

```yaml title="deploy/ingress.yaml" {6,10-11}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: traefik
  tls:
    - hosts: ["${APP_DOMAIN}"]
      secretName: indicat-tls
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: indicat
                port: { number: 80 }
```

</When>

<When is="TLS" equals="selfsigned">

```yaml title="deploy/ingress.yaml" {6,10-11}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: indicat
  annotations:
    cert-manager.io/cluster-issuer: selfsigned
spec:
  ingressClassName: traefik
  tls:
    - hosts: ["${APP_DOMAIN}"]
      secretName: indicat-tls
  rules:
    - host: ${APP_DOMAIN}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: indicat
                port: { number: 80 }
```

</When>

</Tab>

</Tabs>

```bash
kubectl -n ${NAMESPACE} apply -f deploy/deployment.yaml -f deploy/service.yaml -f deploy/ingress.yaml
kubectl -n ${NAMESPACE} rollout status deploy/indicat
```

<Check cmd="kubectl -n ${NAMESPACE} get pods -l app=indicat --field-selector=status.phase=Running --no-headers | wc -l" expect="${APP_REPLICAS}" />

<When is="TLS" equals="letsencrypt">

<Check cmd={"curl -s -o /dev/null -w '%{http_code}' https://${APP_DOMAIN}/healthz"} expect="200" />

</When>

<When is="TLS" equals="selfsigned">

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' https://${APP_DOMAIN}/healthz"} expect="200" />

</When>

<Details summary="Si les pods ne passent jamais Ready">
Commence par les événements et les logs :

```bash
kubectl -n ${NAMESPACE} describe pod -l app=indicat | tail -20
kubectl -n ${NAMESPACE} logs -l app=indicat --tail=50
```

Par ordre de probabilité :

- `ImagePullBackOff` : mauvais nom ou tag d'image, ou pull secret manquant ou expiré.
- `Readiness probe failed: connection refused` : le processus n'écoute pas sur <V name="APP_PORT" />, ou pas sur toutes les interfaces (`0.0.0.0`).
- Les logs montrent une erreur de base : `DATABASE_URL` a le mauvais hôte ou mot de passe. Décode le Secret pour voir ce que voit le pod : `kubectl get secret indicat-secrets -o jsonpath='{.data.DATABASE_URL}' | base64 -d`.
- `OOMKilled` dans le statut du pod : monte `limits.memory`.
</Details>

## Autoscaling

<Guided>Le HorizontalPodAutoscaler ajoute des pods quand l'usage CPU moyen dépasse 70 % des requests, et en retire quand il redescend, entre <V name="APP_REPLICAS" /> et 6. Il a besoin de metrics-server, que k3s livre.</Guided>

```yaml title="deploy/hpa.yaml"
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: indicat
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: indicat
  minReplicas: ${APP_REPLICAS}
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/hpa.yaml
```

<Deep>L'utilisation est relative à `requests.cpu`, donc les 100m de request ci-dessus comptent : à 70m de moyenne par pod, le HPA monte. La descente attend cinq minutes par défaut (`behavior.scaleDown.stabilizationWindowSeconds`), pour qu'un pic ne fasse pas osciller le nombre de pods. Une fois que le HPA possède `replicas`, ne le fixe plus dans le Deployment : chaque `kubectl apply` le remettrait. Retire la ligne `replicas:` de `deployment.yaml` après le premier rollout, ou accepte qu'elle soit écrasée une minute plus tard.</Deep>

<Check cmd="kubectl -n ${NAMESPACE} top pods -l app=indicat --no-headers | wc -l" expect="${APP_REPLICAS}" />

## Au quotidien : rollout, rollback, logs, exec

<Guided>Tout ce que tu faisais avec `systemctl` et `journalctl` a un équivalent kubectl. Les cinq que tu utiliseras chaque jour :</Guided>

```bash
kubectl -n ${NAMESPACE} rollout status deploy/indicat
kubectl -n ${NAMESPACE} rollout history deploy/indicat
kubectl -n ${NAMESPACE} rollout undo deploy/indicat
kubectl -n ${NAMESPACE} logs -l app=indicat --tail=100 -f
kubectl -n ${NAMESPACE} exec -it deploy/indicat -- sh
```

<Deep>Un Deployment garde ses ReplicaSets précédents (dix par défaut, `revisionHistoryLimit`) : `rollout undo` remonte le précédent, avec la même stratégie sans coupure. Il rétablit l'image et le template de pod, pas la base : une migration qui a supprimé une colonne n'est pas défaite par lui, c'est pourquoi les migrations doivent rester compatibles avec la version précédente (ajouter, déployer, retirer plus tard). `logs -l app=indicat` entremêle tous les pods ; `--prefix` étiquette chaque ligne du nom du pod. `exec` sur un `deploy/` choisit un pod ; pour un pod précis, nomme-le.</Deep>

## Plus d'un réplica

Avec <V name="APP_REPLICAS" /> pods, trois choses qui étaient gratuites sur un serveur demandent une décision : les sessions, les migrations, et ce qui se passe pendant la maintenance d'un nœud.

<Deep>**Sessions.** Deux pods ne partagent pas leur mémoire. Si indicat garde les sessions en processus, un utilisateur oscille entre connecté et déconnecté au gré du round-robin du Service. Soit tu ranges les sessions en base ou dans un Redis, soit tu fais de la session un cookie signé (`SECRET_KEY` sert exactement à ça). Les sessions collantes sur l'Ingress (l'annotation `sticky` de Traefik sur le Service) sont le contournement, pas la solution. **Migrations.** Le Job tourne une fois, mais les anciens pods continuent sur le nouveau schéma jusqu'à ce que le rollout les remplace : chaque migration doit être compatible avec la version précédente. Étendre, déployer, contracter. **Perturbation.** `kubectl drain` sur un nœud (mises à jour, maintenance) évince les pods ; sans limite, il peut évincer les deux d'un coup. Un `PodDisruptionBudget` avec `minAvailable: 1` sur `app: indicat` fait attendre drain qu'un remplaçant soit Ready avant d'évincer le second pod. Cinq lignes, à avoir dès que les réplicas dépassent un :</Deep>

<Deep>

```yaml title="deploy/pdb.yaml"
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: indicat
spec:
  minAvailable: 1
  selector:
    matchLabels: { app: indicat }
```

</Deep>

## Terminé

indicat répond sur <V name="APP_DOMAIN" /> depuis <V name="APP_REPLICAS" /> pods, avec sa base, ses secrets, des migrations qui tournent avant chaque rollout, et un autoscaler. Chaque objet est un fichier dans `deploy/`, exactement ce dont la page suivante a besoin : un pipeline qui construit l'image à chaque push, la tague avec le commit, et applique ces fichiers avec un ServiceAccount qui ne peut toucher qu'à <V name="NAMESPACE" />.
````

````yaml title="content/kubernetes/deploy-indicat/diagram.yaml"
# The gist: users reach Traefik, which routes to the indicat pods, which talk
# to PostgreSQL. Quick: five boxes. Guided adds the Secret, the operator and the
# migrations Job. Deep adds the Service, the HPA and cert-manager's part.
title: { en: "indicat on the cluster", fr: "indicat sur le cluster" }
caption:
  en: "Traefik terminates TLS for ${APP_DOMAIN} and routes to the indicat Service, which spreads requests over the pods. The pods read their configuration from a Secret and write to PostgreSQL through the database Service. The image comes from the registry at each pod start."
  fr: "Traefik termine le TLS pour ${APP_DOMAIN} et route vers le Service indicat, qui répartit les requêtes sur les pods. Les pods lisent leur configuration dans un Secret et écrivent dans PostgreSQL via le Service de la base. L'image vient du registre à chaque démarrage de pod."

groups:
  - id: cluster
    label: { en: "Cluster (kube-system and operators)", fr: "Cluster (kube-system et opérateurs)" }
    desc:
      en: "What the previous page installed and the operator this page adds. Shared by every application on the cluster."
      fr: "Ce que la page précédente a installé et l'opérateur que celle-ci ajoute. Partagé par toutes les applications du cluster."
  - id: ns
    label: { en: "Your namespace", fr: "Ton namespace" }
    desc:
      en: "Namespace ${NAMESPACE}: every object of the application lives here. Delete the namespace and the application is gone, volumes included."
      fr: "Namespace ${NAMESPACE} : tout objet de l'application vit ici. Supprime le namespace et l'application disparaît, volumes compris."

nodes:
  - id: users
    kind: user
    label: { en: "Users", fr: "Utilisateurs" }
    sub: "https://${APP_DOMAIN}"
    desc:
      en: "Browsers. They reach any node on 443 and are served whichever pod the Service picks."
      fr: "Des navigateurs. Ils atteignent n'importe quel nœud sur le 443 et sont servis par le pod que choisit le Service."
  - id: registry
    kind: cloud
    label: { en: "Registry", fr: "Registre" }
    sub: "${APP_IMAGE}"
    desc:
      en: "Where the image lives. Pulled by the kubelet of each node when a pod starts; with a private registry, using the regcred pull secret."
      fr: "Là où vit l'image. Tirée par le kubelet de chaque nœud au démarrage d'un pod ; avec un registre privé, via le pull secret regcred."
  - id: traefik
    kind: net
    label: { en: "Traefik", fr: "Traefik" }
    sub: "Ingress · TLS ${APP_DOMAIN}"
    in: cluster
    desc:
      en: "The ingress controller from the previous page. The Ingress of this page tells it: this host goes to the indicat Service, with this certificate."
      fr: "L'ingress controller de la page précédente. L'Ingress de cette page lui dit : cet hôte va vers le Service indicat, avec ce certificat."
    deep:
      sub: ":443 · SNI ${APP_DOMAIN} · Secret indicat-tls"
  - id: certmanager
    kind: service
    label: { en: "cert-manager", fr: "cert-manager" }
    sub: "ClusterIssuer → indicat-tls"
    in: cluster
    level: deep
    desc:
      en: "Sees the annotation on the Ingress and fills the indicat-tls Secret, exactly as for the hello-world."
      fr: "Voit l'annotation sur l'Ingress et remplit le Secret indicat-tls, exactement comme pour le hello-world."
  - id: operator
    kind: service
    label: { en: "CloudNativePG", fr: "CloudNativePG" }
    sub: "cnpg-system · operator"
    in: cluster
    level: guided
    when: { is: DB, equals: cnpg }
    desc:
      en: "The operator: it reads the Cluster object and creates the PostgreSQL pods, their volumes, the Services and the failover logic. One install serves every database on the cluster."
      fr: "L'opérateur : il lit l'objet Cluster et crée les pods PostgreSQL, leurs volumes, les Services et la logique de bascule. Une installation sert toutes les bases du cluster."
  - id: svc
    kind: net
    label: { en: "Service indicat", fr: "Service indicat" }
    sub: "ClusterIP :80 → ${APP_PORT}"
    in: ns
    level: deep
    desc:
      en: "A stable name and address in front of the pods. Round-robins connections across the Ready ones; a pod failing its readiness probe drops out."
      fr: "Un nom et une adresse stables devant les pods. Répartit les connexions sur ceux qui sont Ready ; un pod qui rate sa sonde de readiness en sort."
  - id: app
    kind: server
    label: { en: "indicat", fr: "indicat" }
    sub: "${APP_REPLICAS} pods · :${APP_PORT}"
    in: ns
    focus: true
    desc:
      en: "The Deployment: ${APP_REPLICAS} pods of ${APP_IMAGE}, each with a liveness and a readiness probe on /healthz, CPU and memory limits, and its environment from the Secret."
      fr: "Le Deployment : ${APP_REPLICAS} pods de ${APP_IMAGE}, chacun avec une sonde de liveness et de readiness sur /healthz, des limites CPU et mémoire, et son environnement depuis le Secret."
    deep:
      sub: "Deployment · ${APP_REPLICAS} × ${APP_IMAGE} · :${APP_PORT} · /healthz · HPA 70 % CPU"
  - id: secret
    kind: file
    label: { en: "Secret indicat-secrets", fr: "Secret indicat-secrets" }
    sub: "DATABASE_URL · SECRET_KEY"
    in: ns
    level: guided
    desc:
      en: "Two keys, injected as environment variables. Base64 in etcd, not encrypted: whoever reads Secrets in ${NAMESPACE} reads the password."
      fr: "Deux clés, injectées en variables d'environnement. En base64 dans etcd, pas chiffrées : qui lit les Secrets de ${NAMESPACE} lit le mot de passe."
  - id: migrate
    kind: service
    label: { en: "Job indicat-migrate", fr: "Job indicat-migrate" }
    sub: "runs once before each rollout"
    in: ns
    level: guided
    desc:
      en: "The same image, started once with the migration command. The rollout waits for it to complete, so no pod ever runs against an old schema."
      fr: "La même image, lancée une fois avec la commande de migration. Le rollout attend qu'il se termine, donc aucun pod ne tourne sur un vieux schéma."
  - id: postgres
    kind: store
    label: { en: "PostgreSQL", fr: "PostgreSQL" }
    sub: "indicat-db-rw:5432"
    in: ns
    when: { is: DB, equals: cnpg }
    desc:
      en: "A CloudNativePG Cluster named indicat-db. The -rw Service always points at the primary; -ro at the replicas when there are any."
      fr: "Un Cluster CloudNativePG nommé indicat-db. Le Service -rw pointe toujours vers le primaire ; -ro vers les réplicas quand il y en a."
    deep:
      sub: "Cluster indicat-db · PG 16 · 1 or 3 instances · PVC local-path 10Gi · svc -rw / -ro / -r"
  - id: postgres-sts
    kind: store
    label: { en: "PostgreSQL", fr: "PostgreSQL" }
    sub: "indicat-db:5432"
    in: ns
    when: { is: DB, equals: statefulset }
    desc:
      en: "One pod from a StatefulSet with the postgres:16 image, its data on a PersistentVolumeClaim. No replication, no automatic failover."
      fr: "Un pod d'un StatefulSet avec l'image postgres:16, ses données sur un PersistentVolumeClaim. Pas de réplication, pas de bascule automatique."
    deep:
      sub: "StatefulSet indicat-db · postgres:16 · headless Service · PVC local-path 10Gi"

edges:
  - from: users
    to: traefik
    label: "HTTPS"
    deep: { label: "L7 HTTPS · L4 TCP 443 · SNI ${APP_DOMAIN}" }
    desc:
      en: "Encrypted with the certificate cert-manager issued for ${APP_DOMAIN}."
      fr: "Chiffré avec le certificat émis par cert-manager pour ${APP_DOMAIN}."
  - from: traefik
    to: app
    label: "Ingress"
    max: guided
    guided: { label: "Ingress · host ${APP_DOMAIN}" }
    desc:
      en: "Plain HTTP inside the cluster: Traefik forwards to the Service, which picks a pod."
      fr: "HTTP en clair dans le cluster : Traefik transmet au Service, qui choisit un pod."
  - from: traefik
    to: svc
    label: "HTTP · host ${APP_DOMAIN}"
    level: deep
    desc:
      en: "Traefik resolves the Service's endpoints itself and load-balances across the Ready pods."
      fr: "Traefik résout lui-même les endpoints du Service et répartit sur les pods Ready."
  - from: svc
    to: app
    label: ":${APP_PORT}"
    level: deep
    desc:
      en: "Port 80 of the Service maps to ${APP_PORT} in the container."
      fr: "Le port 80 du Service correspond au ${APP_PORT} dans le conteneur."
  - from: app
    to: postgres
    label: "DATABASE_URL"
    deep: { label: "TCP 5432 · postgresql://indicat@indicat-db-rw/indicat" }
    desc:
      en: "The connection string points at the -rw Service, so a failover to a replica is invisible to the application."
      fr: "La chaîne de connexion pointe vers le Service -rw, donc une bascule vers un réplica est invisible pour l'application."
  - from: app
    to: postgres-sts
    label: "DATABASE_URL"
    deep: { label: "TCP 5432 · postgresql://indicat@indicat-db/indicat" }
    desc:
      en: "The connection string points at the headless Service, which resolves to the single pod."
      fr: "La chaîne de connexion pointe vers le Service headless, qui résout vers l'unique pod."
  - from: registry
    to: app
    label: "pull"
    dashed: true
    deep: { label: "image pull · regcred if private" }
    desc:
      en: "Each node pulls the image when it first runs a pod of it. Tags are cached: a new build must have a new tag, which the next page does with the commit SHA."
      fr: "Chaque nœud tire l'image la première fois qu'il en exécute un pod. Les tags sont mis en cache : un nouveau build doit avoir un nouveau tag, ce que fait la page suivante avec le SHA du commit."
  - from: secret
    to: app
    label: "envFrom"
    dashed: true
    level: guided
    desc:
      en: "Every key of the Secret becomes an environment variable in the container."
      fr: "Chaque clé du Secret devient une variable d'environnement dans le conteneur."
  - from: migrate
    to: postgres
    label: "migrate"
    dashed: true
    level: guided
    desc:
      en: "Applies the schema changes before the new pods start."
      fr: "Applique les changements de schéma avant que les nouveaux pods démarrent."
  - from: migrate
    to: postgres-sts
    label: "migrate"
    dashed: true
    level: guided
    desc:
      en: "Applies the schema changes before the new pods start."
      fr: "Applique les changements de schéma avant que les nouveaux pods démarrent."
  - from: operator
    to: postgres
    label: "manages"
    dashed: true
    level: guided
    desc:
      en: "Creates and watches the PostgreSQL pods, promotes a replica when the primary dies, runs backups if configured."
      fr: "Crée et surveille les pods PostgreSQL, promeut un réplica quand le primaire meurt, lance les sauvegardes si configurées."
  - from: certmanager
    to: traefik
    label: "indicat-tls"
    dashed: true
    level: deep
    desc:
      en: "The certificate Secret, written by cert-manager and read by Traefik."
      fr: "Le Secret du certificat, écrit par cert-manager et lu par Traefik."
````

---

# Writing a page for Runfold

A self-contained brief. Hand it to a person or a model with a subject ("set up a VPS to host a Next.js site behind nginx with TLS") and you get back the files a page is made of. Nothing else is needed to write; the site's test suite then checks the result.

## 1. What the reader gets, and why it shapes the writing

A page is one tutorial. The reader fills in **their context once** — hostnames, ports, paths, and a few **choices** of stack (Debian or RHEL, nginx or Apache, TLS from Let's Encrypt or self-signed) — and every command on the page is rewritten with their values. Sections that do not apply to their choices disappear.

The reader also picks a **reading level**, once for the whole site:

| Level | EN / FR | What it shows |
|---|---|---|
| Run | Run / Automatique | Only the `<Run>` block: a script that does the whole page. "Too lazy to read? Run it." |
| Quick | Quick / Express | The plain paragraphs and the commands. "It worked? Fine." |
| Guided | Guided / Détaillé | Quick + the `<Guided>` explanations: why, and how, for someone who has never done it. |
| Deep | Deep / Exhaustif | Everything, plus `<Deep>`: mechanism, alternatives, gotchas, what happens under the hood. |

So a page is **written once, in layers**, not four times. Every paragraph you write belongs to a layer. Plain text is Quick; wrap the rest.

Each page also carries a **gist**: a small diagram at the top (boxes, containers, arrows, no coordinates) that follows the reader's choices and level. And each page exists in **English and French**, each with its own file, same structure.

## 2. The files

```
content/<series>/series.yaml                 what all pages of the series share (title, order, shared variables/choices)
content/<series>/<page>/tuto.yaml            this page's contract: metadata, its own variables/choices
content/<series>/<page>/page-en.mdx          the prose, English
content/<series>/<page>/page-fr.mdx          the prose, French (same heading skeleton)
content/<series>/<page>/diagram.yaml         the gist (optional but expected)
```

Slugs are kebab-case (`secure-ssh-on-a-fresh-server`). Variable and choice keys are `UPPER_SNAKE_CASE`.

A page **inherits** the series' groups, variables and choices and may add its own; it must **never redeclare** a key the series declares. Two different pages may declare the same key (the reader's value is shared across the series).

Deliver every file in full, each in its own fenced block with the path as title.

## 3. `series.yaml` (only when creating a series)

```yaml
title:   { en: NetBox, fr: NetBox }
summary:
  en: >-
    One or two sentences: what the series takes the reader from and to.
  fr: >-
    Une ou deux phrases : d'où part la série et où elle mène.
order: [install-from-scratch, configure-for-your-team]   # page slugs, reading order

groups:                        # sections of the "Your values" panel
  - id: host
    label: { en: Server, fr: Serveur }
    desc:  { en: Where it runs and how it is reached., fr: Où ça tourne et comment on l'atteint. }
  - id: auth
    label: { en: Directory, fr: Annuaire }
    when:  { flag: LDAP }      # the whole group disappears when the choice is off

vars:                          # same shape as in tuto.yaml, see below
  - key: NETBOX_HOST
    kind: hostname
    group: host
    default: netbox.example.com
    label:  { en: NetBox hostname, fr: Nom d'hôte de NetBox }
    hint:   { en: …, fr: … }
    impact: { en: …, fr: … }

choices:
  - key: OS
    type: select
    label: { en: Distribution, fr: Distribution }
    default: ubuntu
    options:
      - { value: ubuntu, label: { en: Ubuntu 24.04, fr: Ubuntu 24.04 } }
      - { value: rhel,   label: { en: RHEL 9 / Rocky / Alma, fr: RHEL 9 / Rocky / Alma } }
  - key: LDAP
    type: boolean
    label: { en: Authenticate against LDAP, fr: Authentifier via LDAP }
    default: false
```

## 4. `tuto.yaml`

```yaml
# Inherits from ../series.yaml: <list the inherited keys here as a comment>.
title:   { en: Secure SSH on a fresh server, fr: Sécuriser l'accès SSH d'un serveur neuf }
summary:
  en: >-
    Two or three lines. What the reader has at the end. Concrete.
  fr: >-
    Deux ou trois lignes. Ce que le lecteur a à la fin. Concret.
difficulty: beginner           # beginner | intermediate | advanced
tags: [ssh, linux, security]   # lowercase, used by search filters
authors: [thudal]
created: 2026-09-26            # YYYY-MM-DD
minutes: 10                    # hands-on time
validated: Debian 12 · Rocky 9 # upstream version / platform it was written for

groups:
  - id: access
    label: { en: Access, fr: Accès }

vars:
  - key: SSH_PORT
    kind: port                 # text | ip | cidr | port | hostname | domain | user | email | secret | path | url | sshkey
    group: access
    default: "1234"            # always a string; "" for none (secrets)
    label: { en: SSH port, fr: Port SSH }
    hint:                      # what it is, one or two sentences — shown in the ? tip
      en: The port SSH listens on after hardening. Anything but 22 removes most automated noise.
      fr: Le port sur lequel SSH écoutera après durcissement. Tout sauf 22 supprime l'essentiel du bruit automatisé.
    impact:                    # consequences of getting it wrong, where it is reused — also in the tip
      en: Reused by the firewall and fail2ban. Open it in the firewall before restarting SSH, or you lock yourself out.
      fr: Réutilisé par le pare-feu et fail2ban. Ouvre-le dans le pare-feu avant de redémarrer SSH, sinon tu te verrouilles dehors.
    when: { flag: FAIL2BAN }   # optional: only relevant when a choice holds

choices:
  - key: FAIL2BAN
    type: boolean
    label: { en: Ban brute-force attempts (fail2ban), fr: Bannir le brute force (fail2ban) }
    hint:  { en: Recommended on any server reachable from the internet., fr: Recommandé sur tout serveur joignable depuis internet. }
    default: true
```

Rules:
- `kind: secret` for passwords and tokens: masked in the panel, never printed in the print view, never carried by share links. `default: ""`.
- Every value a reader could reasonably change is a variable. Hard-code only true constants (a well-known port of a protocol, a package name).
- `hint` and `impact` are the **whole explanation** of a variable; the page prose should not repeat them.
- Anything the reader **must** provide (their public key, their token) is a variable with `required: true` and no example default (marked in the panel). A block that uses an empty or invalid value is marked red and cannot be copied. Never write an example that looks like code in a block (`ssh-ed25519 AAAA… user@host`, `YOUR.IP`, `<user>`): readers paste it as is (the test refuses these).
- `kind: sshkey` checks a whole OpenSSH public key line (`ssh-ed25519 AAAAC3…`).
- `${EDITOR}` is reserved: the app fills it with the reader's editor (vim by default, nano in the panel). Write `sudo ${EDITOR} /etc/x` whenever the reader edits a file by hand. Never declare it.
- Conditions (`when`, and `<When>` in prose): `{ flag: KEY }`, `{ notFlag: KEY }`, `{ is: KEY, equals: value }`, `{ is: KEY, oneOf: [a, b] }`. They may only reference choices.

## 5. `page-en.mdx` / `page-fr.mdx`

### Skeleton

```mdx
{/* First pass — to be validated against <official docs URL> before publishing. */}

One paragraph (Quick): what this page does, in what order, for whom.

<Run>

One sentence saying what the script does, on which machine, as which user, and what must be in place first.

```bash
#!/usr/bin/env bash
set -euo pipefail
# <Title> — ${MAIN_VAR}
… the whole page as a script, using ${VARS} …
```

</Run>

## Before you start

<Guided>What you need on hand: accounts, access, prerequisites, and where the commands run.</Guided>

<Check cmd="…" expect="…" />

## First step

Plain sentence saying what to do.

```bash
command with ${VARS}
```

<Guided>Why this step, what the command does, what to look at.</Guided>

<Deep>The mechanism, the alternative, the gotcha, the thing that bites in production.</Deep>

<Check cmd="…" expect="…" />

## Second step
…

## Done

What the reader now has. What the next page of the series does.
```

The **French file has the same headings, in the same order, the same count** (the test checks it). It is written in natural French with "tu" (tutoiement), not a word-for-word translation.

### The syntax

| Syntax | Meaning |
|---|---|
| `${SSH_PORT}` inside a code fence | replaced by the reader's value; the copy button copies the filled command |
| `<V name="SSH_PORT" />` | the same, inline in prose |
| plain markdown | visible from **Quick** |
| `<Guided>…</Guided>` | visible from **Guided** |
| `<Deep>…</Deep>` | visible at **Deep** only |
| `<Note>…</Note>` | an aside, from Guided |
| `<Warn>…</Warn>` | always visible: anything that can lock you out, lose data or cost money |
| `<When is="OS" equals="debian">…</When>` | conditional on a select choice; also `oneOf="a,b"` |
| `<When flag="FAIL2BAN">…</When>` / `<When notFlag="…">` | conditional on a boolean choice |
| `<Run>…</Run>` | the only thing shown at Run: a script or a single file that does the whole page |
| `<Check cmd="…" expect="…" />` | a verification with a checkbox, tracked per page. Also `id="…"` to name it. |
| `<Details summary="If it fails">…</Details>` | collapsible, closed by default |
| `<Tabs group="x"><Tab label="/etc/a.conf">…</Tab><Tab label="/etc/b.conf">…</Tab></Tabs>` | several files of one feature, side by side |
| ` ```ini title="/etc/x.conf" {1,3-5} ` | caption, highlighted lines |
| ` ```bash on="mac" ` · ` ```bash on="server" as="debian" ` · `as="${USERNAME}"` | where the block is typed and as whom: a coloured badge and edge, one colour per terminal |
| ` ```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}" ` | **the whole content** of a file: shown with its path, copied as a ready `sudo tee … <<'EOF'` command (or as content only, for an editor). `sudo` is added outside /home and /tmp; force with `sudo` / `sudo="false"` |
| ` ```bash interactive ` | the command asks something (password, confirmation): badge, and it must be alone in its block |
| `$${X}` | a literal `${X}` (GitHub Actions `$${{ … }}`, JS template literals) |
| `<Annotated>` + `# (1)` markers at line ends + a numbered list after the fence | annotations: stripped at Quick, badges at Guided, inline at Deep |
| `## Heading` | a step: numbered automatically, listed in the outline. `###` for sub-steps. |

Details that matter:
- A `<Check>` is a real command whose expected output is **stable**: a version line, `active (running)`, an HTTP `200`. Two to six per page. `expect` may contain `${VARS}` but never a secret. If the command mixes `'` and `"`, write `cmd={"…"}`.
- Fences: ` ```bash ` for shell (a `$` prompt is drawn in front of what the reader types, not in front of comments or continuation lines), ` ```yaml `, ` ```python `, ` ```ini `, ` ```nginx `. One fence per command group; keep them short enough to read.
- Inside `<Run>`, the script must work with the variables and the choices: branch it with `<When>` blocks around separate fences when the stack differs. It runs unattended, so `set -euo pipefail`, no prompts, no `sudo` password expectations that the intro does not state.
- Do not put MDX components inside a fence, and do not put `\"` inside a component attribute (use `cmd={"…"}`).
- Blank line before and after every component, and around fences inside components.
- A component whose content spans several paragraphs or holds a list must be written as a block: the opening tag alone on its line, a blank line, the content, a blank line, the closing tag alone on its line. `<Guided>One line.</Guided>` is fine; `<Guided>Intro:\n\n1. item</Guided>` does not compile.
- New pages carry `status: draft` in `tuto.yaml` until their author has run them end to end.

Rules that come from a real run (the test suite checks the first four):
- **Where and as whom.** When a page uses more than one terminal (the laptop, the server as `debian`, the server as yourself), every block says so with `on=` / `as=`. A tired reader follows the badges, not the sentences.
- **One file, one block, complete.** A file is shown once, whole, with `file="…"`. Never "now add this line before `log {`". When a choice changes the file, `<When>` picks between complete versions of it. The Run script writes exactly the file the page shows.
- **Interactive commands alone.** `adduser`, `passwd`, `ssh-keygen` without `-N`, `ssh-copy-id`: alone in their block, marked `interactive`, or the rest of a paste lands in the password prompt.
- **No fake examples** in blocks (see `required` above).
- **A check proves the right thing.** A `<Check>` that tests an SSH login forces the method under test: `ssh -i KEY -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes …`. A check that passes thanks to a password before the step that forbids passwords is how people lock themselves out.
- **No unexplained jargon** in the Quick text: "the bare domain, `example.com`, nothing in front" rather than "the apex".

### Voice

Direct, concrete, an experienced engineer talking to a colleague. Short sentences. No marketing, no "simply", no "just". Say what a command does before showing it when it is not obvious. Say when a step is dangerous before the reader runs it (`<Warn>`). Name the file being edited. When a fact is from memory and must be confirmed, keep the common well-known form and add a `<Note>` saying where to confirm it.

Length: 200–400 lines per language file. Accuracy over volume.

## 6. `diagram.yaml` — the gist

```yaml
title:   { en: "One server, four pieces", fr: "Un serveur, quatre briques" }
caption:
  en: "One or two sentences read under the drawing."
  fr: "Une ou deux phrases lues sous le schéma."

groups:                                       # dashed containers
  - id: server
    label: { en: "Your server", fr: "Ton serveur" }
    desc:  { en: "…", fr: "…" }               # shown on hover

nodes:
  - id: users                                 # kebab-case
    kind: user                                # user | client | server | service | store | file | net | cloud (the icon)
    label: { en: "Your team", fr: "Ton équipe" }
    sub: "https://${NETBOX_HOST}"             # second line, may use ${VARS}
    desc:  { en: "What this piece does.", fr: "Ce que fait cette brique." }
  - id: nginx
    kind: net
    label: { en: "nginx", fr: "nginx" }
    sub: "443 → 8001"
    in: server                                # container
    when: { is: WEB, equals: nginx }          # follows the reader's choices
    desc:  { en: "…", fr: "…" }
    guided: { sub: ":443 → 127.0.0.1:8001" }  # overrides from Guided up
    deep:   { sub: "TLS ends here · :443 → 127.0.0.1:8001 · HTTP/1.1" }
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    in: server
    level: guided                             # appears from Guided up (level: deep for Deep only)
    desc:  { en: "…", fr: "…" }
  - id: netbox
    kind: server
    label: { en: "NetBox", fr: "NetBox" }
    in: server
    focus: true                               # the thing the page is about, drawn in the accent
    desc:  { en: "…", fr: "…" }

edges:
  - { from: users, to: nginx, label: "HTTPS", max: quick, desc: { en: "…", fr: "…" } }          # a simplification replaced from Guided
  - { from: users, to: firewall, label: "HTTPS · TCP 443", level: guided, deep: { label: "L7 HTTPS · L4 TCP 443 · L3 IP" }, desc: { en: "…", fr: "…" } }
  - { from: firewall, to: nginx, level: guided, desc: { en: "…", fr: "…" } }
  - { from: nginx, to: netbox, desc: { en: "…", fr: "…" } }
  - { from: netbox, to: ldap, label: "bind", dashed: true, when: { flag: LDAP }, desc: { en: "…", fr: "…" } }   # dashed = secondary relation
```

Rules:
- Solid arrows lay the boxes out left to right; dashed arrows are secondary and do not move anything. No coordinates ever.
- Quick shows 4–7 boxes; Guided adds the pieces a first-timer should know exist; Deep shows every layer (DNS, TLS, ports, sockets, units, log files).
- `desc` on every node, edge and group, both languages; it is what the reader gets on hover.
- Only reference variables and choices declared for the page (series + page).

## 7. Checklist before delivering

- [ ] Every user-facing string exists in `en` and `fr`.
- [ ] Every `${VAR}` and `<V name>` in the prose is declared (series or page); every `<When>` references a declared choice.
- [ ] No key redeclared that the series already declares.
- [ ] Same `##`/`###` headings, same order, same count, in both language files.
- [ ] A `<Run>` block that does the whole page with the variables.
- [ ] 2–6 `<Check>`s with stable expected output; no secret in `expect`.
- [ ] `<Warn>` before anything that locks out, deletes or costs.
- [ ] The first-pass comment at the top of each `.mdx`, naming the docs to validate against.
- [ ] `diagram.yaml` with `desc` everywhere, `focus` on one node, levels used.
- [ ] Every file delivered once, in full, as a `file="…"` block; the Run script writes the same content.
- [ ] `on=` / `as=` on every block when the page uses more than one terminal; `interactive` commands alone.
- [ ] What the reader must provide is a `required` variable, never an example in a block.

## 8. A worked request

> Write the page `host-a-nextjs-site` for a new series `vps` (title "A VPS from scratch"): from a freshly delivered Debian 12 VPS to a Next.js site served by nginx with a Let's Encrypt certificate, the app run by systemd, deployed by `git pull` + `npm run build`. Series variables: SERVER_IP (ip), USERNAME (user, the deploy user), DOMAIN (domain). Page variables: APP_DIR (path, default /srv/site), REPO_URL (url), NODE_MAJOR (text, default "22"), APP_PORT (port, default "3000"). Choices: TLS (select letsencrypt | selfsigned, default letsencrypt), PM (select systemd | pm2, default systemd). Deliver series.yaml, tuto.yaml, page-en.mdx, page-fr.mdx, diagram.yaml.

The answer is five fenced blocks, complete, following sections 3–6 and passing the checklist in 7.
