# Source of "Deploy on push with CI"

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-on-push-with-ci/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 on push with CI
  fr: Déployer à chaque push avec la CI
summary:
  en: >-
    A ServiceAccount that can only touch your namespace, its kubeconfig as a CI secret, and a
    pipeline that builds the image on every push, tags it with the commit, and rolls it out.
  fr: >-
    Un ServiceAccount qui ne peut toucher qu'à ton namespace, son kubeconfig en secret de CI,
    et un pipeline qui construit l'image à chaque push, la tague avec le commit, et la déploie.
difficulty: intermediate
tags: [kubernetes, ci, github-actions, gitlab-ci, kustomize, rbac]
authors: [thudal]
created: 2026-09-25
minutes: 35
validated: k3s v1.31 · GitHub Actions · GitLab 17
status: draft             # not yet run end to end by its author

groups:
  - id: ci
    label: { en: CI, fr: CI }
    desc: { en: The repository and the image it builds., fr: Le dépôt et l'image qu'il construit. }

vars:
  - key: REPO
    kind: text
    group: ci
    default: 7hUd41/indicat
    label: { en: Repository, fr: Dépôt }
    hint:
      en: owner/name on GitHub, or group/project on GitLab.
      fr: owner/name sur GitHub, ou groupe/projet sur GitLab.
    impact:
      en: Only used in commands that talk to the forge (gh, glab) and in the pipeline's comments. The pipeline itself uses the forge's built-in variables.
      fr: Utilisé seulement dans les commandes qui parlent à la forge (gh, glab) et dans les commentaires du pipeline. Le pipeline lui-même utilise les variables intégrées de la forge.
  - key: IMAGE_REPO
    kind: text
    group: ci
    default: ghcr.io/7hud41/indicat
    label: { en: Image name (no tag), fr: Nom de l'image (sans tag) }
    hint:
      en: The image of the previous page without its tag. On GitLab, registry.gitlab.com/group/project.
      fr: L'image de la page précédente sans son tag. Sur GitLab, registry.gitlab.com/groupe/projet.
    impact:
      en: The pipeline pushes IMAGE_REPO:commit-sha and kustomize rewrites every manifest that uses this name. It must match the image name in deploy/ exactly, or nothing is replaced.
      fr: Le pipeline pousse IMAGE_REPO:sha-du-commit et kustomize réécrit chaque manifeste qui utilise ce nom. Il doit correspondre exactement au nom d'image dans deploy/, sinon rien n'est remplacé.

choices:
  - key: CI
    type: select
    label: { en: CI system, fr: Système de CI }
    default: github
    options:
      - { value: github, label: { en: GitHub Actions, fr: GitHub Actions } }
      - { value: gitlab, label: { en: GitLab CI, fr: GitLab CI } }
````

````mdx title="content/kubernetes/deploy-on-push-with-ci/page-en.mdx"
{/* First pass — to be validated against kubernetes.io/docs (RBAC, ServiceAccount tokens, kustomize), docs.github.com/actions and docs.gitlab.com/ci before publishing. */}

The previous page deployed indicat by hand from your laptop, with a kubeconfig that is `cluster-admin`. This page hands the job to <When is="CI" equals="github">GitHub Actions</When><When is="CI" equals="gitlab">GitLab CI</When>: a push to `main` builds the image, tags it with the commit SHA, pushes it to the registry and rolls it out, using an identity that can only touch <V name="NAMESPACE" />.

<Run>

The script runs on your laptop, at the root of the <V name="REPO" /> checkout, with the admin kubeconfig of the first page. It creates the ServiceAccount, builds its kubeconfig, writes `deploy/kustomization.yaml` and the pipeline file. It prints the secret to store in CI; it does not push.

```bash
#!/usr/bin/env bash
set -euo pipefail
# CI deploy identity and pipeline — ${REPO} → ${NAMESPACE} on https://${NODE_IP}:6443
NS=${NAMESPACE}
kubectl -n $NS apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata: { name: ci-deploy }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: ci-deploy }
rules:
  - apiGroups: [""]
    resources: [pods, pods/log, services]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [apps]
    resources: [deployments, replicasets]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [autoscaling]
    resources: [horizontalpodautoscalers]
    verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: ci-deploy }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: ci-deploy }
subjects: [{ kind: ServiceAccount, name: ci-deploy, namespace: ${NAMESPACE} }]
---
apiVersion: v1
kind: Secret
metadata:
  name: ci-deploy-token
  annotations: { kubernetes.io/service-account.name: ci-deploy }
type: kubernetes.io/service-account-token
EOF
sleep 2
TOKEN=$(kubectl -n $NS get secret ci-deploy-token -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n $NS get secret ci-deploy-token -o jsonpath='{.data.ca\.crt}')
cat > ci-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
  - name: k3s
    cluster: { server: "https://${NODE_IP}:6443", certificate-authority-data: $CA }
users:
  - name: ci-deploy
    user: { token: $TOKEN }
contexts:
  - name: ci
    context: { cluster: k3s, user: ci-deploy, namespace: ${NAMESPACE} }
current-context: ci
EOF
kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deployments
cat > deploy/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]
images:
  - name: ${IMAGE_REPO}
    newTag: latest
EOF
```

<When is="CI" equals="github">

```bash
mkdir -p .github/workflows
cat > .github/workflows/deploy.yml <<'EOF'
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${IMAGE_REPO}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    env: { KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }} }
    steps:
      - uses: actions/checkout@v4
      - run: |
          mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
          sed -i "s|newTag: .*|newTag: ${{ github.sha }}|" deploy/kustomization.yaml
          kubectl delete job indicat-migrate --ignore-not-found
          kubectl apply -k deploy/ -l app=indicat-migrate
          kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
          kubectl apply -k deploy/
          kubectl rollout status deploy/indicat --timeout=300s
EOF
echo "Now: gh secret set KUBECONFIG_B64 < <(base64 -w0 ci-kubeconfig.yaml); git add deploy .github; git commit -m 'ci: deploy on push'; git push"
```

</When>

<When is="CI" equals="gitlab">

```bash
cat > .gitlab-ci.yml <<'EOF'
stages: [build, deploy]
build:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  variables: { DOCKER_TLS_CERTDIR: /certs }
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use
    - docker buildx build --push -t "${IMAGE_REPO}:$CI_COMMIT_SHA" .
deploy:
  stage: deploy
  image: { name: bitnami/kubectl:1.31, entrypoint: [""] }
  environment: production
  rules: [{ if: $CI_COMMIT_BRANCH == "main" }]
  script:
    - mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
    - sed -i "s|newTag: .*|newTag: $CI_COMMIT_SHA|" deploy/kustomization.yaml
    - kubectl delete job indicat-migrate --ignore-not-found
    - kubectl apply -k deploy/ -l app=indicat-migrate
    - kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
    - kubectl apply -k deploy/
    - kubectl rollout status deploy/indicat --timeout=300s
EOF
echo "Now: glab variable set KUBECONFIG_B64 --masked --protected < <(base64 -w0 ci-kubeconfig.yaml); git add deploy .gitlab-ci.yml; git commit -m 'ci: deploy on push'; git push"
```

</When>

<Warn>`ci-kubeconfig.yaml` is a credential for your namespace. Store it in CI, then delete the file; never commit it.</Warn>

</Run>

## Before you start

<Guided>You need the `deploy/` folder of the previous page committed in <V name="REPO" />, a `Dockerfile` at its root, the admin kubeconfig on your laptop, and <When is="CI" equals="github">the `gh` CLI logged in</When><When is="CI" equals="gitlab">the `glab` CLI logged in</When> (optional: everything it does can be clicked in the web UI). The API on <V name="NODE_IP" />:6443 must be reachable from the runners: for hosted runners, that means from the internet.</Guided>

<Deep>Opening 6443 to the internet is what the first page's firewall step did. It is acceptable because the API only accepts authenticated requests over TLS, and the pipeline's identity is limited to one namespace. If your policy forbids it, run a self-hosted runner inside the network (or in the cluster itself, with the actions-runner-controller or the GitLab agent) and close the port again.</Deep>

## A ServiceAccount for the pipeline

<Guided>The pipeline needs an identity, not your kubeconfig. A ServiceAccount, a Role that lists exactly what deploying needs, and a RoleBinding tying the two together, all inside <V name="NAMESPACE" />. A `Role` (not a `ClusterRole`) cannot reach outside its namespace whatever it says.</Guided>

```yaml title="deploy/ci-rbac.yaml" {10-11,26-27}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-deploy
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deploy
rules:
  - apiGroups: [""]
    resources: [pods, pods/log, services]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [apps]
    resources: [deployments, replicasets]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [autoscaling]
    resources: [horizontalpodautoscalers]
    verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deploy
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ci-deploy
subjects:
  - kind: ServiceAccount
    name: ci-deploy
    namespace: ${NAMESPACE}
```

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

<Deep>No `secrets` in the rules: the pipeline applies manifests, it does not create credentials, and a leaked token then cannot read `indicat-secrets`. No `persistentvolumeclaims` and no `cnpg` resources either: the database is not something a push should touch. `pods` and `pods/log` are there so the deploy job can show you why a rollout failed. If you later add a `PodDisruptionBudget` to `deploy/`, add `policy` / `poddisruptionbudgets` here, or the apply fails with `forbidden`.</Deep>

<Check cmd="kubectl -n kube-system auth can-i get secrets --as=system:serviceaccount:${NAMESPACE}:ci-deploy" expect="no" />

## A kubeconfig for the pipeline

<Guided>Since Kubernetes 1.24 a ServiceAccount has no token by default. A Secret of type `service-account-token` asks the control plane to issue a long-lived one; the token, the cluster CA and the API address then make a kubeconfig of their own.</Guided>

```yaml title="deploy/ci-token.yaml"
apiVersion: v1
kind: Secret
metadata:
  name: ci-deploy-token
  annotations:
    kubernetes.io/service-account.name: ci-deploy
type: kubernetes.io/service-account-token
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/ci-token.yaml
TOKEN=$(kubectl -n ${NAMESPACE} get secret ci-deploy-token -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n ${NAMESPACE} get secret ci-deploy-token -o jsonpath='{.data.ca\.crt}')
cat > ci-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
  - name: k3s
    cluster: { server: "https://${NODE_IP}:6443", certificate-authority-data: $CA }
users:
  - name: ci-deploy
    user: { token: $TOKEN }
contexts:
  - name: ci
    context: { cluster: k3s, user: ci-deploy, namespace: ${NAMESPACE} }
current-context: ci
EOF
```

<Deep>The alternative is `kubectl create token ci-deploy --duration=8760h`: a bound token that expires, which is the better security posture at the price of a yearly rotation you must remember. The Secret above never expires; revoking it means deleting the Secret (the token is rejected immediately) or the ServiceAccount. The `namespace` in the context lets the pipeline omit `-n` everywhere. The `certificate-authority-data` is the cluster CA from the first page, which is why the address must be one the API certificate covers: <V name="NODE_IP" /> was added with `--tls-san`.</Deep>

<Check cmd="kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deployments" expect="yes" />

## The CI secret and the registry

<Guided>The kubeconfig goes into CI as one base64 string, `KUBECONFIG_B64`, so newlines never get mangled. The registry needs no extra secret: <When is="CI" equals="github">the workflow's own `GITHUB_TOKEN` can push to GHCR when the workflow asks for `packages: write`</When><When is="CI" equals="gitlab">every job gets `CI_REGISTRY_USER` and `CI_REGISTRY_PASSWORD` for the project's registry</When>.</Guided>

<When is="CI" equals="github">

```bash
gh secret set KUBECONFIG_B64 --repo ${REPO} < <(base64 -w0 ci-kubeconfig.yaml)
rm ci-kubeconfig.yaml
```

<Note>Or in the browser: repository **Settings → Secrets and variables → Actions → New repository secret**. On macOS, `base64` has no `-w0`; use `base64 | tr -d '\n'`.</Note>

<Deep>A package pushed by a workflow is private by default and linked to the repository: the cluster's `regcred` from the previous page (a PAT with `read:packages`) keeps pulling it. If the package was created by hand before, link it to the repository in the package settings so the workflow token may push to it. `GITHUB_TOKEN` is scoped to the run and expires with it; nothing to rotate.</Deep>

</When>

<When is="CI" equals="gitlab">

```bash
glab variable set KUBECONFIG_B64 --masked --protected --repo ${REPO} < <(base64 -w0 ci-kubeconfig.yaml)
rm ci-kubeconfig.yaml
```

<Note>Or in the browser: project **Settings → CI/CD → Variables → Add variable**, tick *Mask* and *Protect*. On macOS, `base64` has no `-w0`; use `base64 | tr -d '\n'`.</Note>

<Deep>*Protected* means the variable only exists in pipelines on protected branches and tags, so a feature branch cannot deploy even if someone edits `.gitlab-ci.yml` there. *Masked* hides it in job logs; a value must be a single line of at least 8 characters, which base64 guarantees. `CI_REGISTRY_PASSWORD` is a job token valid for the run only, with push rights to the project's registry; the cluster pulls with a deploy token (**Settings → Repository → Deploy tokens**, scope `read_registry`), which is what `regcred` should hold on GitLab.</Deep>

<Check cmd="glab variable list --repo ${REPO} | grep -o KUBECONFIG_B64" expect="KUBECONFIG_B64" />

</When>

## kustomize: one place for the image tag

<Guided>The manifests of the previous page name the image with a fixed tag. Rather than `sed` on every file, a `kustomization.yaml` lists them and carries a single `images:` override; `kubectl apply -k` renders them with the new tag. The pipeline edits one line.</Guided>

```yaml title="deploy/kustomization.yaml" {4,6-8}
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]
images:
  - name: ${IMAGE_REPO}
    newTag: latest
```

<Deep>`images[].name` must be the image name as written in the manifests, without its tag; kustomize replaces the tag wherever that name appears, in the Job and in the Deployment alike. `namespace:` stamps every rendered object, so the files stay namespace-free and a staging overlay is one more `kustomization.yaml` pointing at the same resources with another namespace. `postgres.yaml` and the RBAC files are deliberately not listed: the pipeline should not be able to reapply them. Alternative without kustomize: `kubectl set image deploy/indicat indicat=IMAGE:TAG`, one command, but it leaves the migrations Job and the files in git out of sync with what runs.</Deep>

<Check cmd="kubectl kustomize deploy/ | grep -c 'image: ${IMAGE_REPO}:latest'" expect="2" />

## The pipeline

<Guided>Two jobs. *build* checks the code out, builds the image with buildx and pushes it as <V name="IMAGE_REPO" />:*commit-sha*. *deploy* decodes the kubeconfig, sets the tag in `kustomization.yaml`, runs the migrations Job, applies the rest and waits for the rollout. If any step fails, the pipeline is red and the previous version keeps running.</Guided>

<When is="CI" equals="github">

<Annotated>

```yaml title=".github/workflows/deploy.yml" {6-7,22,29,36-39}
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write                                        # (1)
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${IMAGE_REPO}:${{ github.sha }}          # (2)
          cache-from: type=gha
          cache-to: type=gha,mode=max                    # (3)
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production                              # (4)
    env:
      KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
    steps:
      - uses: actions/checkout@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Deploy
        run: |
          sed -i "s|newTag: .*|newTag: ${{ github.sha }}|" deploy/kustomization.yaml
          kubectl delete job indicat-migrate --ignore-not-found
          kubectl apply -k deploy/ -l app=indicat-migrate  # (5)
          kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
          kubectl apply -k deploy/
          kubectl rollout status deploy/indicat --timeout=300s
```

1. The minimum: read the code, write packages. The default token permissions are broader; stating them here narrows them for this workflow.
2. One tag per commit. The same SHA in `git log`, in the registry and in `kubectl get deploy -o wide`: no guessing what runs.
3. Layer cache in GitHub's cache service; a build that only changed application code takes seconds.
4. Ties the job to the `production` environment: its secrets, its protection rules (next step), and a deployment history in the repository's sidebar.
5. The Job first, alone, selected by its label; then everything. A migration that fails stops the pipeline before any pod changes.

</Annotated>

<Note>`kubectl` is preinstalled on `ubuntu-latest` runners. Pin the actions to a major version as above; Dependabot can bump them.</Note>

</When>

<When is="CI" equals="gitlab">

<Annotated>

```yaml title=".gitlab-ci.yml" {3-6,11,15-17,20-26}
stages: [build, deploy]
build:
  stage: build
  image: docker:27
  services: [docker:27-dind]                                          # (1)
  variables: { DOCKER_TLS_CERTDIR: /certs }
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use
    - docker buildx build --push -t "${IMAGE_REPO}:$CI_COMMIT_SHA" .  # (2)
deploy:
  stage: deploy
  image:
    name: bitnami/kubectl:1.31
    entrypoint: [""]                                                  # (3)
  environment: production                                             # (4)
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
    - sed -i "s|newTag: .*|newTag: $CI_COMMIT_SHA|" deploy/kustomization.yaml
    - kubectl delete job indicat-migrate --ignore-not-found
    - kubectl apply -k deploy/ -l app=indicat-migrate                  # (5)
    - kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
    - kubectl apply -k deploy/
    - kubectl rollout status deploy/indicat --timeout=300s
```

1. Docker-in-docker: a Docker daemon runs as a service container next to the job; the `docker` CLI in the job talks to it over TLS. The runner must allow privileged jobs, which gitlab.com's shared runners do.
2. One tag per commit. The same SHA in `git log`, in the registry and in `kubectl get deploy -o wide`: no guessing what runs.
3. The image's entrypoint is `kubectl` itself; GitLab needs a shell to run `script:`, hence the empty entrypoint.
4. Ties the job to the `production` environment: deployment history, and the protection rules of the next step.
5. The Job first, alone, selected by its label; then everything. A migration that fails stops the pipeline before any pod changes.

</Annotated>

<Note>On a self-managed GitLab, check that the runner has `privileged = true` for docker-in-docker, or use kaniko/buildah, which need no daemon.</Note>

</When>

```bash
git add deploy/ .github/ .gitlab-ci.yml 2>/dev/null; git commit -m "ci: deploy on push"
git push origin main
```

<When is="CI" equals="github">

<Check cmd="gh run list --repo ${REPO} --workflow deploy --limit 1 --json conclusion --jq '.[0].conclusion'" expect="success" />

</When>

<When is="CI" equals="gitlab">

<Check cmd="glab ci list --repo ${REPO} --per-page 1 | grep -o success" expect="success" />

</When>

<Check cmd={"[ \"$(kubectl -n ${NAMESPACE} get deploy indicat -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)\" = \"$(git rev-parse HEAD)\" ] && echo running HEAD"} expect="running HEAD" />

<Details summary="If the deploy job fails">
The job log has the kubectl error. In order of likelihood:

- `Unable to connect to the server`: <V name="NODE_IP" />:6443 is not reachable from the runner (firewall, private address).
- `forbidden`: the Role lacks a resource that `deploy/` now contains; add it to `ci-rbac.yaml`.
- `error: no objects passed to apply` on the Job step: the label `app: indicat-migrate` is missing on `migrate.yaml`.
- The migration Job fails: `kubectl logs job/indicat-migrate` from your laptop; the Deployment was not touched.
- `rollout status` times out: `ImagePullBackOff` because the image name in `deploy/` differs from <V name="IMAGE_REPO" /> (kustomize replaced nothing), or the pods fail their probes (previous page's troubleshooting applies).
</Details>

## Environments and protection

<Guided>A push to `main` now deploys to production. Two safety rails cost a minute: protect the branch (no direct pushes, reviewed merge requests only) and protect the environment (a named person approves the deploy job).</Guided>

<When is="CI" equals="github">

Repository **Settings → Environments → production**: add *Required reviewers*, and restrict *Deployment branches* to `main`. **Settings → Branches → Add rule** on `main`: require a pull request and status checks.

<Deep>With required reviewers the `deploy` job pauses until one of them approves in the Actions UI; the image is already built, so approval is the only delay. Environment secrets (as opposed to repository secrets) are only exposed to jobs targeting that environment: move `KUBECONFIG_B64` there (`gh secret set --env production`) and a workflow that does not declare `environment: production` cannot read it, even from `main`. A second environment `staging` with its own kubeconfig for a `staging` namespace is the same file with a different `environment:` and a `workflow_dispatch` trigger.</Deep>

</When>

<When is="CI" equals="gitlab">

Project **Settings → CI/CD → Protected environments**: add `production`, set *Allowed to deploy* to Maintainers and *Approvers* to the people who sign off. **Settings → Repository → Protected branches**: `main`, no one allowed to push, Maintainers allowed to merge.

<Deep>With approvers set, the `deploy` job waits in the environment's page until approved; the `rules:` on `main` plus the *protected* flag on the variable mean a branch pipeline never even sees the kubeconfig. For a staging namespace, add a second job with `environment: staging`, `rules` on merge requests, and a `KUBECONFIG_B64` scoped to that environment (variables have an *Environment scope* field), pointing at a `ci-deploy` ServiceAccount in the `staging` namespace.</Deep>

</When>

## Rollback

Every commit has its image. Rolling back is deploying an older one: re-run the pipeline of the last good commit, or, from your laptop, undo the rollout.

<When is="CI" equals="github">

```bash
gh run list --repo ${REPO} --workflow deploy --limit 5
gh run rerun <run-id> --repo ${REPO}
```

</When>

<When is="CI" equals="gitlab">

```bash
glab ci list --repo ${REPO} --per-page 5
glab ci retry <job-id> --repo ${REPO}
```

</When>

```bash
kubectl -n ${NAMESPACE} rollout undo deploy/indicat
```

<Deep>Re-running an old pipeline rebuilds nothing on GitHub if the image tag already exists (the push is a no-op) and redeploys that SHA; the environment's history shows exactly which commit is live. `rollout undo` is faster but leaves git ahead of the cluster: the next push deploys the bad commit's successor as if nothing happened. Either way the database is not rolled back, which is why migrations must stay backward compatible with the previous release. A `git revert` of the bad commit, pushed to `main`, is the rollback that leaves a trace and keeps git and the cluster in step.</Deep>

## Done

A push to `main` builds <V name="IMAGE_REPO" />:*sha*, migrates, rolls out, and stops on the first error, with an identity that cannot see past <V name="NAMESPACE" />. Your admin kubeconfig stays on your laptop.

<Deep>The next step, when the manifests become more than one folder, is GitOps: instead of the pipeline pushing to the cluster, an agent in the cluster (Argo CD or Flux) watches a git repository and applies whatever is there. The pipeline then only builds the image and commits the new tag into `kustomization.yaml`; no kubeconfig ever leaves the cluster, drift is detected and reverted, and every environment is a folder in git. Argo CD comes with a UI that shows the diff between git and the cluster; Flux is leaner and native to kustomize and helm. Either installs on this k3s in ten minutes and reads the `deploy/` folder as it is.</Deep>
````

````mdx title="content/kubernetes/deploy-on-push-with-ci/page-fr.mdx"
{/* Première passe — à valider contre kubernetes.io/docs (RBAC, tokens de ServiceAccount, kustomize), docs.github.com/actions et docs.gitlab.com/ci avant publication. */}

La page précédente déployait indicat à la main depuis ton portable, avec un kubeconfig qui est `cluster-admin`. Cette page confie le travail à <When is="CI" equals="github">GitHub Actions</When><When is="CI" equals="gitlab">GitLab CI</When> : un push sur `main` construit l'image, la tague avec le SHA du commit, la pousse dans le registre et la déploie, avec une identité qui ne peut toucher qu'à <V name="NAMESPACE" />.

<Run>

Le script tourne sur ton portable, à la racine du clone de <V name="REPO" />, avec le kubeconfig admin de la première page. Il crée le ServiceAccount, construit son kubeconfig, écrit `deploy/kustomization.yaml` et le fichier de pipeline. Il affiche le secret à ranger dans la CI ; il ne pousse pas.

```bash
#!/usr/bin/env bash
set -euo pipefail
# Identité de déploiement CI et pipeline — ${REPO} → ${NAMESPACE} sur https://${NODE_IP}:6443
NS=${NAMESPACE}
kubectl -n $NS apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata: { name: ci-deploy }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: ci-deploy }
rules:
  - apiGroups: [""]
    resources: [pods, pods/log, services]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [apps]
    resources: [deployments, replicasets]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [autoscaling]
    resources: [horizontalpodautoscalers]
    verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: ci-deploy }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: ci-deploy }
subjects: [{ kind: ServiceAccount, name: ci-deploy, namespace: ${NAMESPACE} }]
---
apiVersion: v1
kind: Secret
metadata:
  name: ci-deploy-token
  annotations: { kubernetes.io/service-account.name: ci-deploy }
type: kubernetes.io/service-account-token
EOF
sleep 2
TOKEN=$(kubectl -n $NS get secret ci-deploy-token -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n $NS get secret ci-deploy-token -o jsonpath='{.data.ca\.crt}')
cat > ci-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
  - name: k3s
    cluster: { server: "https://${NODE_IP}:6443", certificate-authority-data: $CA }
users:
  - name: ci-deploy
    user: { token: $TOKEN }
contexts:
  - name: ci
    context: { cluster: k3s, user: ci-deploy, namespace: ${NAMESPACE} }
current-context: ci
EOF
kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deployments
cat > deploy/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]
images:
  - name: ${IMAGE_REPO}
    newTag: latest
EOF
```

<When is="CI" equals="github">

```bash
mkdir -p .github/workflows
cat > .github/workflows/deploy.yml <<'EOF'
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${IMAGE_REPO}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    env: { KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }} }
    steps:
      - uses: actions/checkout@v4
      - run: |
          mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
          sed -i "s|newTag: .*|newTag: ${{ github.sha }}|" deploy/kustomization.yaml
          kubectl delete job indicat-migrate --ignore-not-found
          kubectl apply -k deploy/ -l app=indicat-migrate
          kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
          kubectl apply -k deploy/
          kubectl rollout status deploy/indicat --timeout=300s
EOF
echo "Ensuite : gh secret set KUBECONFIG_B64 < <(base64 -w0 ci-kubeconfig.yaml); git add deploy .github; git commit -m 'ci: deploy on push'; git push"
```

</When>

<When is="CI" equals="gitlab">

```bash
cat > .gitlab-ci.yml <<'EOF'
stages: [build, deploy]
build:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  variables: { DOCKER_TLS_CERTDIR: /certs }
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use
    - docker buildx build --push -t "${IMAGE_REPO}:$CI_COMMIT_SHA" .
deploy:
  stage: deploy
  image: { name: bitnami/kubectl:1.31, entrypoint: [""] }
  environment: production
  rules: [{ if: $CI_COMMIT_BRANCH == "main" }]
  script:
    - mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
    - sed -i "s|newTag: .*|newTag: $CI_COMMIT_SHA|" deploy/kustomization.yaml
    - kubectl delete job indicat-migrate --ignore-not-found
    - kubectl apply -k deploy/ -l app=indicat-migrate
    - kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
    - kubectl apply -k deploy/
    - kubectl rollout status deploy/indicat --timeout=300s
EOF
echo "Ensuite : glab variable set KUBECONFIG_B64 --masked --protected < <(base64 -w0 ci-kubeconfig.yaml); git add deploy .gitlab-ci.yml; git commit -m 'ci: deploy on push'; git push"
```

</When>

<Warn>`ci-kubeconfig.yaml` est un identifiant pour ton namespace. Range-le dans la CI, puis supprime le fichier ; ne le commite jamais.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut le dossier `deploy/` de la page précédente commité dans <V name="REPO" />, un `Dockerfile` à sa racine, le kubeconfig admin sur ton portable, et <When is="CI" equals="github">la CLI `gh` connectée</When><When is="CI" equals="gitlab">la CLI `glab` connectée</When> (facultatif : tout ce qu'elle fait se clique aussi dans l'interface web). L'API sur <V name="NODE_IP" />:6443 doit être joignable depuis les runners : pour des runners hébergés, ça veut dire depuis internet.</Guided>

<Deep>Ouvrir le 6443 sur internet, c'est ce qu'a fait l'étape pare-feu de la première page. C'est acceptable parce que l'API n'accepte que des requêtes authentifiées en TLS, et que l'identité du pipeline est limitée à un namespace. Si ta politique l'interdit, fais tourner un runner auto-hébergé dans le réseau (ou dans le cluster lui-même, avec actions-runner-controller ou l'agent GitLab) et referme le port.</Deep>

## Un ServiceAccount pour le pipeline

<Guided>Le pipeline a besoin d'une identité, pas de ton kubeconfig. Un ServiceAccount, un Role qui liste exactement ce que déployer demande, et un RoleBinding qui lie les deux, le tout dans <V name="NAMESPACE" />. Un `Role` (pas un `ClusterRole`) ne peut pas sortir de son namespace, quoi qu'il dise.</Guided>

```yaml title="deploy/ci-rbac.yaml" {10-11,26-27}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-deploy
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deploy
rules:
  - apiGroups: [""]
    resources: [pods, pods/log, services]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [apps]
    resources: [deployments, replicasets]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [autoscaling]
    resources: [horizontalpodautoscalers]
    verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deploy
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ci-deploy
subjects:
  - kind: ServiceAccount
    name: ci-deploy
    namespace: ${NAMESPACE}
```

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

<Deep>Pas de `secrets` dans les règles : le pipeline applique des manifestes, il ne crée pas d'identifiants, et un token qui fuite ne peut alors pas lire `indicat-secrets`. Pas de `persistentvolumeclaims` ni de ressources `cnpg` non plus : la base n'est pas quelque chose qu'un push devrait toucher. `pods` et `pods/log` sont là pour que le job de déploiement puisse te montrer pourquoi un rollout a échoué. Si tu ajoutes plus tard un `PodDisruptionBudget` dans `deploy/`, ajoute `policy` / `poddisruptionbudgets` ici, sinon l'apply échoue en `forbidden`.</Deep>

<Check cmd="kubectl -n kube-system auth can-i get secrets --as=system:serviceaccount:${NAMESPACE}:ci-deploy" expect="no" />

## Un kubeconfig pour le pipeline

<Guided>Depuis Kubernetes 1.24, un ServiceAccount n'a pas de token par défaut. Un Secret de type `service-account-token` demande au control plane d'en émettre un de longue durée ; le token, la CA du cluster et l'adresse de l'API font ensuite un kubeconfig à part entière.</Guided>

```yaml title="deploy/ci-token.yaml"
apiVersion: v1
kind: Secret
metadata:
  name: ci-deploy-token
  annotations:
    kubernetes.io/service-account.name: ci-deploy
type: kubernetes.io/service-account-token
```

```bash
kubectl -n ${NAMESPACE} apply -f deploy/ci-token.yaml
TOKEN=$(kubectl -n ${NAMESPACE} get secret ci-deploy-token -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n ${NAMESPACE} get secret ci-deploy-token -o jsonpath='{.data.ca\.crt}')
cat > ci-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
  - name: k3s
    cluster: { server: "https://${NODE_IP}:6443", certificate-authority-data: $CA }
users:
  - name: ci-deploy
    user: { token: $TOKEN }
contexts:
  - name: ci
    context: { cluster: k3s, user: ci-deploy, namespace: ${NAMESPACE} }
current-context: ci
EOF
```

<Deep>L'alternative est `kubectl create token ci-deploy --duration=8760h` : un token lié qui expire, meilleure posture de sécurité au prix d'une rotation annuelle à ne pas oublier. Le Secret ci-dessus n'expire jamais ; le révoquer, c'est supprimer le Secret (le token est rejeté immédiatement) ou le ServiceAccount. Le `namespace` dans le contexte permet au pipeline d'omettre `-n` partout. Le `certificate-authority-data` est la CA du cluster de la première page, et c'est pour ça que l'adresse doit être couverte par le certificat de l'API : <V name="NODE_IP" /> a été ajoutée avec `--tls-san`.</Deep>

<Check cmd="kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deployments" expect="yes" />

## Le secret de CI et le registre

<Guided>Le kubeconfig entre dans la CI comme une seule chaîne base64, `KUBECONFIG_B64`, pour que les sauts de ligne ne soient jamais abîmés. Le registre ne demande aucun secret en plus : <When is="CI" equals="github">le `GITHUB_TOKEN` du workflow peut pousser sur GHCR dès que le workflow demande `packages: write`</When><When is="CI" equals="gitlab">chaque job reçoit `CI_REGISTRY_USER` et `CI_REGISTRY_PASSWORD` pour le registre du projet</When>.</Guided>

<When is="CI" equals="github">

```bash
gh secret set KUBECONFIG_B64 --repo ${REPO} < <(base64 -w0 ci-kubeconfig.yaml)
rm ci-kubeconfig.yaml
```

<Note>Ou dans le navigateur : dépôt **Settings → Secrets and variables → Actions → New repository secret**. Sur macOS, `base64` n'a pas de `-w0` ; utilise `base64 | tr -d '\n'`.</Note>

<Deep>Un paquet poussé par un workflow est privé par défaut et lié au dépôt : le `regcred` du cluster de la page précédente (un PAT avec `read:packages`) continue de le tirer. Si le paquet avait été créé à la main avant, lie-le au dépôt dans les réglages du paquet pour que le token du workflow puisse y pousser. `GITHUB_TOKEN` est limité au run et expire avec lui ; rien à faire tourner.</Deep>

</When>

<When is="CI" equals="gitlab">

```bash
glab variable set KUBECONFIG_B64 --masked --protected --repo ${REPO} < <(base64 -w0 ci-kubeconfig.yaml)
rm ci-kubeconfig.yaml
```

<Note>Ou dans le navigateur : projet **Settings → CI/CD → Variables → Add variable**, coche *Mask* et *Protect*. Sur macOS, `base64` n'a pas de `-w0` ; utilise `base64 | tr -d '\n'`.</Note>

<Deep>*Protected* veut dire que la variable n'existe que dans les pipelines des branches et tags protégés, donc une branche de fonctionnalité ne peut pas déployer même si quelqu'un y modifie `.gitlab-ci.yml`. *Masked* la cache dans les logs des jobs ; la valeur doit tenir sur une ligne d'au moins 8 caractères, ce que le base64 garantit. `CI_REGISTRY_PASSWORD` est un job token valable pour le run seulement, avec droit de push sur le registre du projet ; le cluster tire avec un deploy token (**Settings → Repository → Deploy tokens**, portée `read_registry`), et c'est lui que `regcred` devrait contenir sur GitLab.</Deep>

<Check cmd="glab variable list --repo ${REPO} | grep -o KUBECONFIG_B64" expect="KUBECONFIG_B64" />

</When>

## kustomize : un seul endroit pour le tag d'image

<Guided>Les manifestes de la page précédente nomment l'image avec un tag fixe. Plutôt qu'un `sed` sur chaque fichier, un `kustomization.yaml` les liste et porte une seule surcharge `images:` ; `kubectl apply -k` les rend avec le nouveau tag. Le pipeline modifie une ligne.</Guided>

```yaml title="deploy/kustomization.yaml" {4,6-8}
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]
images:
  - name: ${IMAGE_REPO}
    newTag: latest
```

<Deep>`images[].name` doit être le nom de l'image tel qu'écrit dans les manifestes, sans son tag ; kustomize remplace le tag partout où ce nom apparaît, dans le Job comme dans le Deployment. `namespace:` estampille chaque objet rendu, donc les fichiers restent sans namespace et un overlay de staging est un `kustomization.yaml` de plus qui pointe vers les mêmes ressources avec un autre namespace. `postgres.yaml` et les fichiers RBAC ne sont volontairement pas listés : le pipeline ne devrait pas pouvoir les réappliquer. Alternative sans kustomize : `kubectl set image deploy/indicat indicat=IMAGE:TAG`, une commande, mais elle laisse le Job de migrations et les fichiers dans git désynchronisés de ce qui tourne.</Deep>

<Check cmd="kubectl kustomize deploy/ | grep -c 'image: ${IMAGE_REPO}:latest'" expect="2" />

## Le pipeline

<Guided>Deux jobs. *build* récupère le code, construit l'image avec buildx et la pousse en <V name="IMAGE_REPO" />:*sha-du-commit*. *deploy* décode le kubeconfig, met le tag dans `kustomization.yaml`, lance le Job de migrations, applique le reste et attend le rollout. Si une étape échoue, le pipeline est rouge et la version précédente continue de tourner.</Guided>

<When is="CI" equals="github">

<Annotated>

```yaml title=".github/workflows/deploy.yml" {6-7,22,29,36-39}
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write                                        # (1)
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${IMAGE_REPO}:${{ github.sha }}          # (2)
          cache-from: type=gha
          cache-to: type=gha,mode=max                    # (3)
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production                              # (4)
    env:
      KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
    steps:
      - uses: actions/checkout@v4
      - name: Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config
      - name: Deploy
        run: |
          sed -i "s|newTag: .*|newTag: ${{ github.sha }}|" deploy/kustomization.yaml
          kubectl delete job indicat-migrate --ignore-not-found
          kubectl apply -k deploy/ -l app=indicat-migrate  # (5)
          kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
          kubectl apply -k deploy/
          kubectl rollout status deploy/indicat --timeout=300s
```

1. Le minimum : lire le code, écrire des paquets. Les permissions par défaut du token sont plus larges ; les énoncer ici les restreint pour ce workflow.
2. Un tag par commit. Le même SHA dans `git log`, dans le registre et dans `kubectl get deploy -o wide` : plus à deviner ce qui tourne.
3. Cache de couches dans le service de cache de GitHub ; un build qui n'a changé que le code applicatif prend quelques secondes.
4. Rattache le job à l'environnement `production` : ses secrets, ses règles de protection (étape suivante), et un historique de déploiements dans la barre latérale du dépôt.
5. Le Job d'abord, seul, sélectionné par son label ; puis tout le reste. Une migration qui échoue arrête le pipeline avant qu'un pod change.

</Annotated>

<Note>`kubectl` est préinstallé sur les runners `ubuntu-latest`. Fige les actions à une version majeure comme ci-dessus ; Dependabot peut les monter.</Note>

</When>

<When is="CI" equals="gitlab">

<Annotated>

```yaml title=".gitlab-ci.yml" {3-6,11,15-17,20-26}
stages: [build, deploy]
build:
  stage: build
  image: docker:27
  services: [docker:27-dind]                                          # (1)
  variables: { DOCKER_TLS_CERTDIR: /certs }
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use
    - docker buildx build --push -t "${IMAGE_REPO}:$CI_COMMIT_SHA" .  # (2)
deploy:
  stage: deploy
  image:
    name: bitnami/kubectl:1.31
    entrypoint: [""]                                                  # (3)
  environment: production                                             # (4)
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - mkdir -p ~/.kube && echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config && chmod 600 ~/.kube/config
    - sed -i "s|newTag: .*|newTag: $CI_COMMIT_SHA|" deploy/kustomization.yaml
    - kubectl delete job indicat-migrate --ignore-not-found
    - kubectl apply -k deploy/ -l app=indicat-migrate                  # (5)
    - kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
    - kubectl apply -k deploy/
    - kubectl rollout status deploy/indicat --timeout=300s
```

1. Docker-in-docker : un démon Docker tourne dans un conteneur de service à côté du job ; la CLI `docker` du job lui parle en TLS. Le runner doit autoriser les jobs privilégiés, ce que font les runners partagés de gitlab.com.
2. Un tag par commit. Le même SHA dans `git log`, dans le registre et dans `kubectl get deploy -o wide` : plus à deviner ce qui tourne.
3. L'entrypoint de l'image est `kubectl` lui-même ; GitLab a besoin d'un shell pour lancer `script:`, d'où l'entrypoint vide.
4. Rattache le job à l'environnement `production` : historique de déploiements, et les règles de protection de l'étape suivante.
5. Le Job d'abord, seul, sélectionné par son label ; puis tout le reste. Une migration qui échoue arrête le pipeline avant qu'un pod change.

</Annotated>

<Note>Sur un GitLab auto-hébergé, vérifie que le runner a `privileged = true` pour docker-in-docker, ou utilise kaniko/buildah, qui n'ont pas besoin de démon.</Note>

</When>

```bash
git add deploy/ .github/ .gitlab-ci.yml 2>/dev/null; git commit -m "ci: deploy on push"
git push origin main
```

<When is="CI" equals="github">

<Check cmd="gh run list --repo ${REPO} --workflow deploy --limit 1 --json conclusion --jq '.[0].conclusion'" expect="success" />

</When>

<When is="CI" equals="gitlab">

<Check cmd="glab ci list --repo ${REPO} --per-page 1 | grep -o success" expect="success" />

</When>

<Check cmd={"[ \"$(kubectl -n ${NAMESPACE} get deploy indicat -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)\" = \"$(git rev-parse HEAD)\" ] && echo running HEAD"} expect="running HEAD" />

<Details summary="Si le job deploy échoue">
Le log du job contient l'erreur kubectl. Par ordre de probabilité :

- `Unable to connect to the server` : <V name="NODE_IP" />:6443 n'est pas joignable depuis le runner (pare-feu, adresse privée).
- `forbidden` : il manque au Role une ressource que `deploy/` contient maintenant ; ajoute-la dans `ci-rbac.yaml`.
- `error: no objects passed to apply` à l'étape du Job : le label `app: indicat-migrate` manque sur `migrate.yaml`.
- Le Job de migrations échoue : `kubectl logs job/indicat-migrate` depuis ton portable ; le Deployment n'a pas été touché.
- `rollout status` expire : `ImagePullBackOff` parce que le nom d'image dans `deploy/` diffère de <V name="IMAGE_REPO" /> (kustomize n'a rien remplacé), ou les pods ratent leurs sondes (le dépannage de la page précédente s'applique).
</Details>

## Environnements et protection

<Guided>Un push sur `main` déploie maintenant en production. Deux garde-fous coûtent une minute : protéger la branche (pas de push direct, seulement des merge requests relues) et protéger l'environnement (une personne nommée approuve le job de déploiement).</Guided>

<When is="CI" equals="github">

Dépôt **Settings → Environments → production** : ajoute des *Required reviewers*, et restreins *Deployment branches* à `main`. **Settings → Branches → Add rule** sur `main` : exige une pull request et des status checks.

<Deep>Avec des relecteurs requis, le job `deploy` se met en pause jusqu'à ce que l'un d'eux approuve dans l'interface Actions ; l'image est déjà construite, l'approbation est le seul délai. Les secrets d'environnement (par opposition aux secrets de dépôt) ne sont exposés qu'aux jobs qui ciblent cet environnement : déplace `KUBECONFIG_B64` là (`gh secret set --env production`) et un workflow qui ne déclare pas `environment: production` ne peut pas le lire, même depuis `main`. Un second environnement `staging` avec son propre kubeconfig pour un namespace `staging`, c'est le même fichier avec un autre `environment:` et un déclencheur `workflow_dispatch`.</Deep>

</When>

<When is="CI" equals="gitlab">

Projet **Settings → CI/CD → Protected environments** : ajoute `production`, mets *Allowed to deploy* sur Maintainers et *Approvers* sur les personnes qui valident. **Settings → Repository → Protected branches** : `main`, personne autorisé à pousser, Maintainers autorisés à merger.

<Deep>Avec des approbateurs, le job `deploy` attend dans la page de l'environnement jusqu'à approbation ; les `rules:` sur `main` plus le drapeau *protected* de la variable font qu'un pipeline de branche ne voit même jamais le kubeconfig. Pour un namespace de staging, ajoute un second job avec `environment: staging`, des `rules` sur les merge requests, et un `KUBECONFIG_B64` limité à cet environnement (les variables ont un champ *Environment scope*), qui pointe vers un ServiceAccount `ci-deploy` du namespace `staging`.</Deep>

</When>

## Rollback

Chaque commit a son image. Revenir en arrière, c'est déployer une plus ancienne : relance le pipeline du dernier bon commit, ou, depuis ton portable, annule le rollout.

<When is="CI" equals="github">

```bash
gh run list --repo ${REPO} --workflow deploy --limit 5
gh run rerun <run-id> --repo ${REPO}
```

</When>

<When is="CI" equals="gitlab">

```bash
glab ci list --repo ${REPO} --per-page 5
glab ci retry <job-id> --repo ${REPO}
```

</When>

```bash
kubectl -n ${NAMESPACE} rollout undo deploy/indicat
```

<Deep>Relancer un vieux pipeline ne reconstruit rien sur GitHub si le tag existe déjà (le push est sans effet) et redéploie ce SHA ; l'historique de l'environnement montre exactement quel commit est en ligne. `rollout undo` est plus rapide mais laisse git en avance sur le cluster : le prochain push déploie le successeur du mauvais commit comme si de rien n'était. Dans les deux cas la base n'est pas rétablie, et c'est pourquoi les migrations doivent rester compatibles avec la version précédente. Un `git revert` du mauvais commit, poussé sur `main`, est le rollback qui laisse une trace et garde git et le cluster au pas.</Deep>

## Terminé

Un push sur `main` construit <V name="IMAGE_REPO" />:*sha*, migre, déploie, et s'arrête à la première erreur, avec une identité qui ne voit pas au-delà de <V name="NAMESPACE" />. Ton kubeconfig admin reste sur ton portable.

<Deep>L'étape d'après, quand les manifestes deviennent plus qu'un dossier, c'est le GitOps : au lieu que le pipeline pousse vers le cluster, un agent dans le cluster (Argo CD ou Flux) surveille un dépôt git et applique ce qui s'y trouve. Le pipeline ne fait alors que construire l'image et commiter le nouveau tag dans `kustomization.yaml` ; aucun kubeconfig ne quitte jamais le cluster, la dérive est détectée et corrigée, et chaque environnement est un dossier dans git. Argo CD vient avec une interface qui montre le diff entre git et le cluster ; Flux est plus léger et natif kustomize et helm. L'un comme l'autre s'installe sur ce k3s en dix minutes et lit le dossier `deploy/` tel quel.</Deep>
````

````yaml title="content/kubernetes/deploy-on-push-with-ci/diagram.yaml"
# The gist: a push starts a pipeline that builds and pushes the image, then
# tells the cluster to run it. Quick: five boxes. Guided adds the ServiceAccount
# and the CI secret. Deep adds ports, the token and the kustomize step.
title: { en: "From git push to running pods", fr: "Du git push aux pods qui tournent" }
caption:
  en: "A push to main runs the pipeline: it builds the image, pushes it to the registry tagged with the commit SHA, then, with a kubeconfig limited to ${NAMESPACE}, applies the manifests with that tag and waits for the rollout."
  fr: "Un push sur main lance le pipeline : il construit l'image, la pousse dans le registre taguée avec le SHA du commit, puis, avec un kubeconfig limité à ${NAMESPACE}, applique les manifestes avec ce tag et attend le rollout."

groups:
  - id: forge
    label: { en: "Your forge", fr: "Ta forge" }
    desc:
      en: "GitHub or GitLab: the repository, the runners that execute the pipeline, and the registry that stores the images."
      fr: "GitHub ou GitLab : le dépôt, les runners qui exécutent le pipeline, et le registre qui stocke les images."
  - id: cluster
    label: { en: "Your cluster", fr: "Ton cluster" }
    desc:
      en: "The k3s cluster. The API on ${NODE_IP}:6443 must be reachable from the runners."
      fr: "Le cluster k3s. L'API sur ${NODE_IP}:6443 doit être joignable depuis les runners."

nodes:
  - id: dev
    kind: user
    label: { en: "You", fr: "Toi" }
    sub: "git push origin main"
    desc:
      en: "A push to main is the deployment. No kubectl on your side anymore, except to watch."
      fr: "Un push sur main, c'est le déploiement. Plus de kubectl de ton côté, sauf pour regarder."
  - id: repo
    kind: file
    label: { en: "Repository", fr: "Dépôt" }
    sub: "${REPO}"
    in: forge
    desc:
      en: "The application code, the Dockerfile, the deploy/ folder from the previous page, and the pipeline file."
      fr: "Le code de l'application, le Dockerfile, le dossier deploy/ de la page précédente, et le fichier de pipeline."
    deep:
      sub: "${REPO} · Dockerfile · deploy/ · pipeline file"
  - id: runner
    kind: service
    label: { en: "GitHub Actions", fr: "GitHub Actions" }
    sub: "build → push → deploy"
    in: forge
    focus: true
    when: { is: CI, equals: github }
    desc:
      en: "Two jobs on a hosted runner: build with buildx and push to GHCR, then deploy with kubectl using the KUBECONFIG_B64 secret."
      fr: "Deux jobs sur un runner hébergé : build avec buildx et push sur GHCR, puis deploy avec kubectl via le secret KUBECONFIG_B64."
    deep:
      sub: ".github/workflows/deploy.yml · ubuntu-latest · environment production"
  - id: runner-gl
    kind: service
    label: { en: "GitLab CI", fr: "GitLab CI" }
    sub: "build → deploy"
    in: forge
    focus: true
    when: { is: CI, equals: gitlab }
    desc:
      en: "Two stages: build with docker-in-docker and push to the project registry, then deploy with kubectl using the KUBECONFIG_B64 variable."
      fr: "Deux stages : build en docker-in-docker et push sur le registre du projet, puis deploy avec kubectl via la variable KUBECONFIG_B64."
    deep:
      sub: ".gitlab-ci.yml · docker:dind · environment production"
  - id: kubeconfig
    kind: file
    label: { en: "CI secret", fr: "Secret de CI" }
    sub: "KUBECONFIG_B64"
    in: forge
    level: guided
    desc:
      en: "The kubeconfig of the ci-deploy ServiceAccount, base64-encoded, stored as a masked secret. It can only act inside ${NAMESPACE}."
      fr: "Le kubeconfig du ServiceAccount ci-deploy, encodé en base64, stocké en secret masqué. Il ne peut agir que dans ${NAMESPACE}."
  - id: registry
    kind: cloud
    label: { en: "Registry", fr: "Registre" }
    sub: "${IMAGE_REPO}:<sha>"
    in: forge
    desc:
      en: "One image per commit, tagged with its SHA. Never 'latest' in production: a tag that moves cannot be rolled back."
      fr: "Une image par commit, taguée avec son SHA. Jamais « latest » en production : un tag qui bouge ne se rétablit pas."
  - id: api
    kind: server
    label: { en: "API server", fr: "API server" }
    sub: "${NODE_IP}:6443"
    in: cluster
    desc:
      en: "Receives the pipeline's kubectl calls, authenticated by the ServiceAccount token, authorised by its Role."
      fr: "Reçoit les appels kubectl du pipeline, authentifiés par le token du ServiceAccount, autorisés par son Role."
    deep:
      sub: "https://${NODE_IP}:6443 · token auth · RBAC"
  - id: sa
    kind: file
    label: { en: "ServiceAccount", fr: "ServiceAccount" }
    sub: "ci-deploy · Role · RoleBinding"
    in: cluster
    level: guided
    desc:
      en: "An identity for the pipeline with a Role limited to ${NAMESPACE}: deployments, jobs, services, ingresses. Nothing in other namespaces, no secrets."
      fr: "Une identité pour le pipeline avec un Role limité à ${NAMESPACE} : deployments, jobs, services, ingresses. Rien dans les autres namespaces, pas de secrets."
    deep:
      sub: "ci-deploy · Role in ${NAMESPACE} · long-lived token Secret"
  - id: deploy
    kind: service
    label: { en: "indicat", fr: "indicat" }
    sub: "Deployment · ${NAMESPACE}"
    in: cluster
    desc:
      en: "The Deployment from the previous page. Its image tag changes at each pipeline; the rollout replaces the pods one by one."
      fr: "Le Deployment de la page précédente. Son tag d'image change à chaque pipeline ; le rollout remplace les pods un par un."
    deep:
      sub: "Job indicat-migrate then Deployment indicat · image ${IMAGE_REPO}:<sha>"

edges:
  - from: dev
    to: repo
    label: "push"
    deep: { label: "git push · HTTPS or SSH" }
    desc:
      en: "The commit lands on main."
      fr: "Le commit arrive sur main."
  - from: repo
    to: runner
    label: "on: push"
    when: { is: CI, equals: github }
    desc:
      en: "The workflow triggers on pushes to main."
      fr: "Le workflow se déclenche sur les push vers main."
  - from: repo
    to: runner-gl
    label: "pipeline"
    when: { is: CI, equals: gitlab }
    desc:
      en: "The pipeline runs on pushes; the deploy stage only on main."
      fr: "Le pipeline tourne sur les push ; le stage deploy seulement sur main."
  - from: runner
    to: registry
    label: "docker push"
    when: { is: CI, equals: github }
    deep: { label: "buildx build --push · GITHUB_TOKEN · packages: write" }
    desc:
      en: "The runner logs in with its own GITHUB_TOKEN and pushes the image tagged with the commit SHA."
      fr: "Le runner se connecte avec son propre GITHUB_TOKEN et pousse l'image taguée avec le SHA du commit."
  - from: runner-gl
    to: registry
    label: "docker push"
    when: { is: CI, equals: gitlab }
    deep: { label: "buildx build --push · CI_REGISTRY_PASSWORD" }
    desc:
      en: "The job logs in with the built-in CI_REGISTRY_USER / CI_REGISTRY_PASSWORD and pushes the image tagged with the commit SHA."
      fr: "Le job se connecte avec les variables intégrées CI_REGISTRY_USER / CI_REGISTRY_PASSWORD et pousse l'image taguée avec le SHA du commit."
  - from: runner
    to: api
    label: "kubectl apply -k"
    when: { is: CI, equals: github }
    deep: { label: "kubectl apply -k deploy/ · rollout status · TCP 6443" }
    desc:
      en: "kustomize rewrites the image tag, the migrations Job runs, then the Deployment is applied and the job waits for the rollout."
      fr: "kustomize réécrit le tag d'image, le Job de migrations tourne, puis le Deployment est appliqué et le job attend le rollout."
  - from: runner-gl
    to: api
    label: "kubectl apply -k"
    when: { is: CI, equals: gitlab }
    deep: { label: "kubectl apply -k deploy/ · rollout status · TCP 6443" }
    desc:
      en: "kustomize rewrites the image tag, the migrations Job runs, then the Deployment is applied and the job waits for the rollout."
      fr: "kustomize réécrit le tag d'image, le Job de migrations tourne, puis le Deployment est appliqué et le job attend le rollout."
  - from: kubeconfig
    to: runner
    label: "secret"
    dashed: true
    level: guided
    when: { is: CI, equals: github }
    desc:
      en: "Injected into the deploy job only, decoded to ~/.kube/config on the runner."
      fr: "Injecté dans le job deploy seulement, décodé vers ~/.kube/config sur le runner."
  - from: kubeconfig
    to: runner-gl
    label: "variable"
    dashed: true
    level: guided
    when: { is: CI, equals: gitlab }
    desc:
      en: "A masked, protected CI/CD variable, decoded to ~/.kube/config in the deploy job."
      fr: "Une variable CI/CD masquée et protégée, décodée vers ~/.kube/config dans le job deploy."
  - from: sa
    to: api
    label: "token · RBAC"
    dashed: true
    level: guided
    desc:
      en: "The API checks the token, then the Role: anything outside ${NAMESPACE} is forbidden."
      fr: "L'API vérifie le token, puis le Role : tout ce qui sort de ${NAMESPACE} est interdit."
  - from: api
    to: deploy
    label: "rollout"
    desc:
      en: "New ReplicaSet with the new tag; pods replaced one at a time, old ones kept until the new are Ready."
      fr: "Nouveau ReplicaSet avec le nouveau tag ; pods remplacés un par un, les anciens gardés jusqu'à ce que les nouveaux soient Ready."
  - from: registry
    to: deploy
    label: "pull :sha"
    dashed: true
    desc:
      en: "Each node pulls the exact image of the commit being deployed."
      fr: "Chaque nœud tire l'image exacte du commit déployé."
````

---

# 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.
