# Source of "Sécuriser l'accès SSH d'un serveur neuf"

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

````yaml title="content/example/series.yaml"
title:
  en: Example
  fr: Exemple
summary:
  en: >-
    One real, short tutorial that uses every feature of this site. It is the page the guided
    tour lands on, and the reference when writing new ones.
  fr: >-
    Un vrai tuto, court, qui utilise chaque fonctionnalité du site. C'est la page où atterrit
    la visite guidée, et la référence quand on en écrit de nouveaux.
order:
  - secure-ssh-on-a-fresh-server

groups:
  - id: server
    label: { en: Server, fr: Serveur }
    desc: { en: The machine you are securing., fr: La machine que tu sécurises. }

vars:
  - key: SERVER_IP
    kind: ip
    group: server
    default: 203.0.113.10
    label: { en: Server IP, fr: IP du serveur }
    hint:
      en: The public address of the server, as given by your provider.
      fr: L'adresse publique du serveur, telle que fournie par ton hébergeur.
    impact:
      en: Every ssh command on this page targets it. A typo here means "connection refused" everywhere.
      fr: Chaque commande ssh de cette page la vise. Une faute ici donne « connection refused » partout.
  - key: USERNAME
    kind: user
    group: server
    default: deploy
    label: { en: Your user, fr: Ton utilisateur }
    hint:
      en: The everyday account you will create, instead of root. Lowercase, no spaces.
      fr: Le compte de tous les jours que tu vas créer, à la place de root. Minuscules, sans espace.
    impact:
      en: Gets sudo rights and your SSH key. After hardening, root can no longer log in, so this is your only way in.
      fr: "Reçoit les droits sudo et ta clé SSH. Après durcissement, root ne peut plus se connecter : c'est ta seule porte."

choices:
  - key: OS
    type: select
    label: { en: Distribution, fr: Distribution }
    default: debian
    options:
      - { value: debian, label: { en: Debian / Ubuntu, fr: Debian / Ubuntu } }
      - { value: rhel, label: { en: RHEL / Rocky / Alma, fr: RHEL / Rocky / Alma } }
````

````yaml title="content/example/secure-ssh-on-a-fresh-server/tuto.yaml"
# Reference page: uses every feature. Inherits SERVER_IP, USERNAME and OS from ../series.yaml.

title:
  en: Secure SSH on a fresh server
  fr: Sécuriser l'accès SSH d'un serveur neuf
summary:
  en: >-
    Ten minutes after delivery, before anything else: a personal account, key-only login,
    a non-default port, a firewall, and a ban on brute force.
  fr: >-
    Dix minutes après la livraison, avant toute autre chose : un compte personnel, connexion par
    clé uniquement, un port non standard, un pare-feu, et un bannissement du brute force.
difficulty: beginner
tags: [ssh, linux, security, example]
authors: [thudal]
created: 2026-09-24
minutes: 10
validated: Debian 12 · Rocky 9
status: draft             # not yet run end to end by its author

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

vars:
  - key: SSH_PORT
    kind: port
    group: access
    default: "1234"
    label: { en: SSH port, fr: Port SSH }
    hint:
      en: The port SSH will listen 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:
      en: Reused by the firewall and by fail2ban. It must be opened in the firewall before SSH is restarted, or you lock yourself out.
      fr: Réutilisé par le pare-feu et par fail2ban. Il doit être ouvert dans le pare-feu avant de redémarrer SSH, sinon tu te verrouilles dehors.
  - key: MY_IP
    kind: ip
    group: access
    label: { en: Your own public IP, fr: Ton IP publique }
    hint:
      en: The address you connect from, as the server sees it (curl -s https://ifconfig.me). Only needed if fail2ban bans you.
      fr: L'adresse depuis laquelle tu te connectes, vue par le serveur (curl -s https://ifconfig.me). Utile seulement si fail2ban te bannit.

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
````

````mdx title="content/example/secure-ssh-on-a-fresh-server/page-en.mdx"
{/* Reference page. Every authoring feature appears here at least once; keep it that way when adding features. */}

A server straight from the provider answers to `root` with a password, on port 22, to the whole internet. Within the hour, bots are trying passwords. This page closes those doors in five steps, each one verified before the next.

<Run>

The script does every step for **<V name="SERVER_IP" />**. Run it **on the server**, as root, right after delivery, with your public key already in `/root/.ssh/authorized_keys`.

<When is="OS" equals="debian">

```bash
#!/usr/bin/env bash
set -euo pipefail
# Secure SSH — ${SERVER_IP} (Debian/Ubuntu)
adduser --disabled-password --gecos "" ${USERNAME}
usermod -aG sudo ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
apt-get update && apt-get install -y ufw
ufw allow ${SSH_PORT}/tcp
ufw --force enable
```

</When>

<When is="OS" equals="rhel">

```bash
#!/usr/bin/env bash
set -euo pipefail
# Secure SSH — ${SERVER_IP} (RHEL family)
useradd -m ${USERNAME}
usermod -aG wheel ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
semanage port -a -t ssh_port_t -p tcp ${SSH_PORT} || semanage port -m -t ssh_port_t -p tcp ${SSH_PORT}
firewall-cmd --permanent --add-port=${SSH_PORT}/tcp
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload
```

</When>

<When flag="FAIL2BAN">

<When is="OS" equals="debian">

```bash
apt-get install -y fail2ban
printf '[sshd]\nenabled = true\nport = ${SSH_PORT}\nmaxretry = 3\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable --now fail2ban
```

</When>

<When is="OS" equals="rhel">

```bash
dnf install -y epel-release && dnf install -y fail2ban
printf '[sshd]\nenabled = true\nport = ${SSH_PORT}\nmaxretry = 3\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable --now fail2ban
```

</When>

</When>

```bash
sshd -t && systemctl restart ssh 2>/dev/null || systemctl restart sshd
echo "Done. From your machine: ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP}"
```

<Warn>Keep your current root session open until you have logged in with the new user on the new port from a second terminal.</Warn>

</Run>

## Before you start

<Guided>You need the server's IP, root access (password or key, as delivered), and an SSH key pair on **your** machine. If you have never made one:</Guided>

```bash interactive
ssh-keygen -t ed25519 -C "${USERNAME}@laptop"
```

<Deep>Ed25519 keys are short, fast and have no weak parameters to get wrong, which is why every current guide prefers them to RSA. The comment after `-C` is free text; it only helps you recognise the key in `authorized_keys` later.</Deep>

<Check cmd="cat ~/.ssh/id_ed25519.pub" expect="ssh-ed25519 AAAA… ${USERNAME}@laptop" />

| What | Before | After this page |
|---|---|---|
| Who can log in | `root`, anyone with the password | <V name="USERNAME" />, with a key |
| Port | 22 | <V name="SSH_PORT" /> |
| Wrong passwords | unlimited | <When flag="FAIL2BAN">3, then banned for an hour</When><When notFlag="FAIL2BAN">passwords are refused anyway</When> |

## Create your own user

<Guided>Working as `root` all day is how one typo erases a server. We create an everyday account, give it `sudo`, and copy root's authorised key so it can log in right away.</Guided>

<When is="OS" equals="debian">

```bash
ssh root@${SERVER_IP}
```

```bash interactive
adduser ${USERNAME}
```

```bash
usermod -aG sudo ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
```

</When>

<When is="OS" equals="rhel">

```bash
ssh root@${SERVER_IP}
useradd -m ${USERNAME}
```

```bash interactive
passwd ${USERNAME}
```

```bash
usermod -aG wheel ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
```

<Note>On the RHEL family the sudo group is called `wheel`, not `sudo`.</Note>

</When>

<Deep>`rsync --archive --chown` copies `~/.ssh` with its permissions intact and hands it to the new user in one go. Permissions matter: SSH refuses a key file readable by others, silently, and you would be left wondering why the key "does not work".</Deep>

<Check cmd="ssh ${USERNAME}@${SERVER_IP} sudo -n true && echo OK" expect="OK" />

<Details summary="If sudo asks for a password">
Debian and Ubuntu grant password-less sudo to nobody by default; `sudo -n` then fails. Either type the password (fine), or allow the group without password:

```bash
echo '%sudo ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-sudo-nopasswd
```

Do this only if the account is protected by a key, which is what the next step enforces.
</Details>

## Harden the SSH daemon

<Guided>Three settings do most of the work: a new port, no root login, keys only. They go in a drop-in file, so the distribution's own `sshd_config` stays untouched by upgrades.</Guided>

<Annotated>

```ini title="/etc/ssh/sshd_config.d/00-hardening.conf" {1-3}
Port ${SSH_PORT}                       # (1)
PermitRootLogin no                     # (2)
PasswordAuthentication no              # (3)
KbdInteractiveAuthentication no        # (4)
```

1. Moving off port 22 does not make SSH more secure by itself, but it removes almost all automated login attempts, which keeps logs readable and fail2ban quiet.
2. Root cannot log in at all, even with a key. Elevation happens through `sudo` from your own account, which leaves a trace.
3. Only keys are accepted. A password, however long, can be guessed; a 256-bit key cannot.
4. Closes the second password door: keyboard-interactive is what PAM uses to prompt for a password even when `PasswordAuthentication` is off.

</Annotated>

<Deep>sshd reads the files of `sshd_config.d/` **in alphabetical order, before** the main file, and keeps the **first** value it reads for each setting. That is why the drop-in is called `00-…`: cloud images (OVH and others) ship a `50-cloud-init.conf`, sometimes with `PasswordAuthentication yes`, which would win over a `99-…` file. Keep one drop-in per concern; `sudo sshd -T` prints the values actually in force.</Deep>

<Warn>Do not restart SSH yet. The firewall must accept port <V name="SSH_PORT" /> first, or the restart cuts you off.</Warn>

<Check cmd="sudo sshd -t && echo syntax OK" expect="syntax OK" />

## Firewall

<Guided>Only the new SSH port stays open. Anything else you host later (80, 443…) gets added when you host it.</Guided>

<When is="OS" equals="debian">

```bash
sudo apt install -y ufw
sudo ufw allow ${SSH_PORT}/tcp
sudo ufw enable
```

<Check cmd="sudo ufw status" expect="Status: active
${SSH_PORT}/tcp                  ALLOW       Anywhere" />

</When>

<When is="OS" equals="rhel">

```bash
sudo semanage port -a -t ssh_port_t -p tcp ${SSH_PORT}
sudo firewall-cmd --permanent --add-port=${SSH_PORT}/tcp
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload
```

<Deep>SELinux only lets `sshd` bind to ports labelled `ssh_port_t`; without the `semanage` line the daemon starts, logs a denial, and keeps listening on 22 only. `firewalld` ships with the `ssh` service (port 22) allowed: we remove it since 22 will be closed.</Deep>

<Check cmd="sudo firewall-cmd --list-ports" expect="${SSH_PORT}/tcp" />

</When>

Now restart SSH and, **from a second terminal**, log in on the new port:

<When is="OS" equals="debian">

```bash
sudo systemctl restart ssh
```

</When>

<When is="OS" equals="rhel">

```bash
sudo systemctl restart sshd
```

</When>

<Check cmd="ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP} echo connected" expect="connected" />

<Details summary="If the new connection fails">
Your first terminal is still logged in: nothing is lost. In order of likelihood:

- The firewall does not list <V name="SSH_PORT" />: re-run the firewall step.
- `sshd -t` reports an error: fix the drop-in, restart again.
- The provider has its own network firewall in front of the server (common at OVH, Hetzner, cloud providers): open the port there too.
- You are testing from the first terminal by mistake: use a new one.
</Details>

<When flag="FAIL2BAN">

## Ban brute force

<Guided>With passwords off, guessing is useless, but bots still hammer the port. fail2ban reads the log and bans an address after a few failures, keeping the noise out of your logs and CPU.</Guided>

<When is="OS" equals="debian">

```bash
sudo apt install -y fail2ban
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y epel-release
sudo dnf install -y fail2ban
```

</When>

<Tabs group="fail2ban-files">

<Tab label="jail.d/sshd.local">

```ini title="/etc/fail2ban/jail.d/sshd.local"
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
bantime = 1h
```

</Tab>

<Tab label="jail.d/defaults.local">

Default jail settings, applied to every jail:

```ini title="/etc/fail2ban/jail.d/defaults.local" {3}
[DEFAULT]
bantime.increment = true
ignoreip = 127.0.0.1/8 ${SERVER_IP}
findtime = 10m
```

</Tab>

</Tabs>

```bash
sudo systemctl enable --now fail2ban
```

<Check cmd="sudo fail2ban-client status sshd | head -3" expect="Status for the jail: sshd
|- Filter
|  |- Currently failed: 0" />

<Details summary="If you ban yourself">
Three typos in a row from your own address and you are out for an hour. From another address (phone hotspot, the provider's console), unban yourself:

```bash
sudo fail2ban-client set sshd unbanip ${MY_IP}
```

To never ban your office or home, add `ignoreip = 203.0.113.0/24` to the jail.
</Details>

</When>

## Done

Root is locked out, passwords are refused, the port is off the beaten path<When flag="FAIL2BAN">, and repeat offenders are banned</When>. From now on you connect with:

```bash
ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP}
```

<Note>Add it to `~/.ssh/config` on your machine as a `Host` entry and you will never type the port again.</Note>
````

````mdx title="content/example/secure-ssh-on-a-fresh-server/page-fr.mdx"
{/* Page de référence. Chaque fonctionnalité d'écriture y apparaît au moins une fois ; à maintenir quand on en ajoute. */}

Un serveur qui sort de chez l'hébergeur répond à `root` avec un mot de passe, sur le port 22, à tout internet. Dans l'heure, des robots essaient des mots de passe. Cette page ferme ces portes en cinq étapes, chacune vérifiée avant la suivante.

<Run>

Le script fait toutes les étapes pour **<V name="SERVER_IP" />**. Lance-le **sur le serveur**, en root, juste après la livraison, ta clé publique déjà dans `/root/.ssh/authorized_keys`.

<When is="OS" equals="debian">

```bash
#!/usr/bin/env bash
set -euo pipefail
# Sécuriser SSH — ${SERVER_IP} (Debian/Ubuntu)
adduser --disabled-password --gecos "" ${USERNAME}
usermod -aG sudo ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
apt-get update && apt-get install -y ufw
ufw allow ${SSH_PORT}/tcp
ufw --force enable
```

</When>

<When is="OS" equals="rhel">

```bash
#!/usr/bin/env bash
set -euo pipefail
# Sécuriser SSH — ${SERVER_IP} (famille RHEL)
useradd -m ${USERNAME}
usermod -aG wheel ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
semanage port -a -t ssh_port_t -p tcp ${SSH_PORT} || semanage port -m -t ssh_port_t -p tcp ${SSH_PORT}
firewall-cmd --permanent --add-port=${SSH_PORT}/tcp
firewall-cmd --permanent --remove-service=ssh
firewall-cmd --reload
```

</When>

<When flag="FAIL2BAN">

<When is="OS" equals="debian">

```bash
apt-get install -y fail2ban
printf '[sshd]\nenabled = true\nport = ${SSH_PORT}\nmaxretry = 3\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable --now fail2ban
```

</When>

<When is="OS" equals="rhel">

```bash
dnf install -y epel-release && dnf install -y fail2ban
printf '[sshd]\nenabled = true\nport = ${SSH_PORT}\nmaxretry = 3\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable --now fail2ban
```

</When>

</When>

```bash
sshd -t && systemctl restart ssh 2>/dev/null || systemctl restart sshd
echo "Terminé. Depuis ta machine : ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP}"
```

<Warn>Garde ta session root actuelle ouverte tant que tu ne t'es pas connecté avec le nouvel utilisateur sur le nouveau port depuis un second terminal.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut l'IP du serveur, l'accès root (mot de passe ou clé, tel que livré), et une paire de clés SSH sur **ta** machine. Si tu n'en as jamais fait :</Guided>

```bash interactive
ssh-keygen -t ed25519 -C "${USERNAME}@laptop"
```

<Deep>Les clés Ed25519 sont courtes, rapides et n'ont aucun paramètre faible à mal choisir, c'est pourquoi tous les guides actuels les préfèrent à RSA. Le commentaire après `-C` est du texte libre ; il sert seulement à reconnaître la clé dans `authorized_keys` plus tard.</Deep>

<Check cmd="cat ~/.ssh/id_ed25519.pub" expect="ssh-ed25519 AAAA… ${USERNAME}@laptop" />

| Quoi | Avant | Après cette page |
|---|---|---|
| Qui peut se connecter | `root`, quiconque a le mot de passe | <V name="USERNAME" />, avec une clé |
| Port | 22 | <V name="SSH_PORT" /> |
| Mots de passe faux | illimités | <When flag="FAIL2BAN">3, puis banni une heure</When><When notFlag="FAIL2BAN">les mots de passe sont refusés de toute façon</When> |

## Créer ton propre utilisateur

<Guided>Travailler en `root` toute la journée, c'est comme ça qu'une faute de frappe efface un serveur. On crée un compte de tous les jours, on lui donne `sudo`, et on copie la clé autorisée de root pour qu'il puisse se connecter tout de suite.</Guided>

<When is="OS" equals="debian">

```bash
ssh root@${SERVER_IP}
```

```bash interactive
adduser ${USERNAME}
```

```bash
usermod -aG sudo ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
```

</When>

<When is="OS" equals="rhel">

```bash
ssh root@${SERVER_IP}
useradd -m ${USERNAME}
```

```bash interactive
passwd ${USERNAME}
```

```bash
usermod -aG wheel ${USERNAME}
rsync --archive --chown=${USERNAME}:${USERNAME} ~/.ssh /home/${USERNAME}
```

<Note>Dans la famille RHEL, le groupe sudo s'appelle `wheel`, pas `sudo`.</Note>

</When>

<Deep>`rsync --archive --chown` copie `~/.ssh` avec ses permissions intactes et le confie au nouvel utilisateur en une fois. Les permissions comptent : SSH refuse un fichier de clé lisible par d'autres, silencieusement, et tu te demanderais pourquoi la clé « ne marche pas ».</Deep>

<Check cmd="ssh ${USERNAME}@${SERVER_IP} sudo -n true && echo OK" expect="OK" />

<Details summary="Si sudo demande un mot de passe">
Debian et Ubuntu n'accordent le sudo sans mot de passe à personne par défaut ; `sudo -n` échoue alors. Soit tu tapes le mot de passe (très bien), soit tu autorises le groupe sans mot de passe :

```bash
echo '%sudo ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-sudo-nopasswd
```

Fais-le seulement si le compte est protégé par une clé, ce que l'étape suivante impose.
</Details>

## Durcir le démon SSH

<Guided>Trois réglages font l'essentiel du travail : un nouveau port, pas de connexion root, clés uniquement. Ils vont dans un fichier complémentaire, pour que le `sshd_config` de la distribution reste intact lors des mises à jour.</Guided>

<Annotated>

```ini title="/etc/ssh/sshd_config.d/00-hardening.conf" {1-3}
Port ${SSH_PORT}                       # (1)
PermitRootLogin no                     # (2)
PasswordAuthentication no              # (3)
KbdInteractiveAuthentication no        # (4)
```

1. Quitter le port 22 ne rend pas SSH plus sûr en soi, mais ça supprime presque toutes les tentatives automatisées, ce qui garde les logs lisibles et fail2ban silencieux.
2. Root ne peut plus se connecter du tout, même avec une clé. L'élévation passe par `sudo` depuis ton propre compte, ce qui laisse une trace.
3. Seules les clés sont acceptées. Un mot de passe, aussi long soit-il, se devine ; une clé de 256 bits, non.
4. Ferme la seconde porte des mots de passe : keyboard-interactive est ce que PAM utilise pour demander un mot de passe même quand `PasswordAuthentication` est désactivé.

</Annotated>

<Deep>sshd lit les fichiers de `sshd_config.d/` **par ordre alphabétique, avant** le fichier principal, et garde la **première** valeur lue pour chaque réglage. D'où le nom `00-…` : les images cloud (OVH et d'autres) déposent un `50-cloud-init.conf`, parfois avec `PasswordAuthentication yes`, qui l'emporterait sur un fichier `99-…`. Un fichier par sujet ; `sudo sshd -T` affiche les valeurs réellement en vigueur.</Deep>

<Warn>Ne redémarre pas SSH tout de suite. Le pare-feu doit d'abord accepter le port <V name="SSH_PORT" />, sinon le redémarrage te coupe.</Warn>

<Check cmd="sudo sshd -t && echo syntax OK" expect="syntax OK" />

## Pare-feu

<Guided>Seul le nouveau port SSH reste ouvert. Tout ce que tu hébergeras plus tard (80, 443…) s'ajoute au moment où tu l'héberges.</Guided>

<When is="OS" equals="debian">

```bash
sudo apt install -y ufw
sudo ufw allow ${SSH_PORT}/tcp
sudo ufw enable
```

<Check cmd="sudo ufw status" expect="Status: active
${SSH_PORT}/tcp                  ALLOW       Anywhere" />

</When>

<When is="OS" equals="rhel">

```bash
sudo semanage port -a -t ssh_port_t -p tcp ${SSH_PORT}
sudo firewall-cmd --permanent --add-port=${SSH_PORT}/tcp
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload
```

<Deep>SELinux ne laisse `sshd` écouter que sur les ports étiquetés `ssh_port_t` ; sans la ligne `semanage`, le démon démarre, journalise un refus, et continue d'écouter sur 22 seulement. `firewalld` est livré avec le service `ssh` (port 22) autorisé : on le retire puisque 22 va fermer.</Deep>

<Check cmd="sudo firewall-cmd --list-ports" expect="${SSH_PORT}/tcp" />

</When>

Redémarre maintenant SSH et, **depuis un second terminal**, connecte-toi sur le nouveau port :

<When is="OS" equals="debian">

```bash
sudo systemctl restart ssh
```

</When>

<When is="OS" equals="rhel">

```bash
sudo systemctl restart sshd
```

</When>

<Check cmd="ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP} echo connected" expect="connected" />

<Details summary="Si la nouvelle connexion échoue">
Ton premier terminal est toujours connecté : rien n'est perdu. Par ordre de probabilité :

- Le pare-feu ne liste pas <V name="SSH_PORT" /> : refais l'étape pare-feu.
- `sshd -t` signale une erreur : corrige le fichier complémentaire, redémarre à nouveau.
- L'hébergeur a son propre pare-feu réseau devant le serveur (courant chez OVH, Hetzner, les clouds) : ouvre le port là aussi.
- Tu testes depuis le premier terminal par erreur : ouvres-en un nouveau.
</Details>

<When flag="FAIL2BAN">

## Bannir le brute force

<Guided>Sans mots de passe, deviner ne sert à rien, mais les robots martèlent quand même le port. fail2ban lit le journal et bannit une adresse après quelques échecs, ce qui garde le bruit hors de tes logs et de ton CPU.</Guided>

<When is="OS" equals="debian">

```bash
sudo apt install -y fail2ban
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y epel-release
sudo dnf install -y fail2ban
```

</When>

<Tabs group="fail2ban-files">

<Tab label="jail.d/sshd.local">

```ini title="/etc/fail2ban/jail.d/sshd.local"
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
bantime = 1h
```

</Tab>

<Tab label="jail.d/defaults.local">

Réglages par défaut, appliqués à toutes les jails :

```ini title="/etc/fail2ban/jail.d/defaults.local" {3}
[DEFAULT]
bantime.increment = true
ignoreip = 127.0.0.1/8 ${SERVER_IP}
findtime = 10m
```

</Tab>

</Tabs>

```bash
sudo systemctl enable --now fail2ban
```

<Check cmd="sudo fail2ban-client status sshd | head -3" expect="Status for the jail: sshd
|- Filter
|  |- Currently failed: 0" />

<Details summary="Si tu te bannis toi-même">
Trois fautes de frappe d'affilée depuis ta propre adresse et tu es dehors pour une heure. Depuis une autre adresse (partage de connexion du téléphone, console de l'hébergeur), débannis-toi :

```bash
sudo fail2ban-client set sshd unbanip ${MY_IP}
```

Pour ne jamais bannir ton bureau ou ta maison, ajoute `ignoreip = 203.0.113.0/24` à la jail.
</Details>

</When>

## Terminé

Root est dehors, les mots de passe sont refusés, le port est hors des sentiers battus<When flag="FAIL2BAN">, et les récidivistes sont bannis</When>. Désormais tu te connectes avec :

```bash
ssh -p ${SSH_PORT} ${USERNAME}@${SERVER_IP}
```

<Note>Ajoute-le dans `~/.ssh/config` sur ta machine comme entrée `Host` et tu ne taperas plus jamais le port.</Note>
````

````yaml title="content/example/secure-ssh-on-a-fresh-server/diagram.yaml"
# Quick: five boxes. Guided: the config file that drives sshd and the layers
# on the arrows. Deep: the key file, the log, and the loop that feeds fail2ban.
# `desc` is what the reader gets when hovering a box or an arrow.
title: { en: "Before and after, in one picture", fr: "Avant et après, en une image" }
caption:
  en: "Only your key reaches sshd, on your port, through the firewall. Root and passwords are refused; with fail2ban, repeat offenders are banned before they even reach sshd."
  fr: "Seule ta clé atteint sshd, sur ton port, à travers le pare-feu. Root et mots de passe sont refusés ; avec fail2ban, les récidivistes sont bannis avant même d'atteindre sshd."

groups:
  - id: server
    label: { en: "Your server", fr: "Ton serveur" }
    desc:
      en: "The fresh machine. Everything inside this box is what the page changes."
      fr: "La machine neuve. Tout ce qui est dans cette boîte est ce que la page modifie."

nodes:
  - id: you
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "~/.ssh/id_ed25519"
    desc:
      en: "Where the private key lives. It never leaves this machine; only its public half goes to the server."
      fr: "Là où vit la clé privée. Elle ne quitte jamais cette machine ; seule sa moitié publique va sur le serveur."
    deep:
      sub: "ssh client · ~/.ssh/id_ed25519 (private key)"
  - id: bots
    kind: cloud
    label: { en: "The internet's bots", fr: "Les robots d'internet" }
    sub: "root / password…"
    desc:
      en: "Within minutes of going online, scanners try root and common passwords on port 22. They are the reason for everything else here."
      fr: "Quelques minutes après la mise en ligne, des scanners essaient root et des mots de passe courants sur le port 22. Ils sont la raison de tout le reste."
    deep:
      sub: "scanners · TCP 22 · root / password lists"
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "${SSH_PORT}/tcp only"
    in: server
    desc:
      en: "ufw: the only inbound port open is ${SSH_PORT}. Open it before moving SSH, or you lock yourself out."
      fr: "ufw : le seul port entrant ouvert est ${SSH_PORT}. Ouvre-le avant de déplacer SSH, sinon tu te verrouilles dehors."
    guided:
      sub: "ufw · allow ${SSH_PORT}/tcp · deny in"
    deep:
      sub: "ufw (nftables) · L3/L4 · allow ${SSH_PORT}/tcp · default deny in"
  - id: sshd
    kind: server
    label: { en: "sshd", fr: "sshd" }
    sub: "keys only · no root"
    in: server
    focus: true
    desc:
      en: "The SSH daemon, after hardening: listens on ${SSH_PORT}, accepts keys only, refuses root. Three lines of configuration."
      fr: "Le démon SSH, après durcissement : écoute sur ${SSH_PORT}, n'accepte que les clés, refuse root. Trois lignes de configuration."
    deep:
      sub: "Port ${SSH_PORT} · PubkeyAuthentication yes · PasswordAuthentication no · PermitRootLogin no"
  - id: config
    kind: file
    label: { en: "sshd_config", fr: "sshd_config" }
    sub: "/etc/ssh/sshd_config.d/"
    in: server
    level: guided
    desc:
      en: "A drop-in file rather than the main sshd_config: package upgrades leave it alone, and a diff shows only your changes."
      fr: "Un fichier de surcharge plutôt que le sshd_config principal : les mises à jour du paquet n'y touchent pas, et un diff ne montre que tes changements."
    deep:
      sub: "/etc/ssh/sshd_config.d/hardening.conf · read at start"
  - id: keys
    kind: file
    label: { en: "authorized_keys", fr: "authorized_keys" }
    sub: "/home/${USERNAME}/.ssh/"
    in: server
    level: deep
    desc:
      en: "The public keys allowed to log in as ${USERNAME}. One line per key; permissions must be strict or sshd ignores the file."
      fr: "Les clés publiques autorisées à se connecter en ${USERNAME}. Une ligne par clé ; les permissions doivent être strictes, sinon sshd ignore le fichier."
  - id: log
    kind: file
    label: { en: "Auth log", fr: "Journal d'auth" }
    sub: "journald · sshd"
    in: server
    level: deep
    when: { flag: FAIL2BAN }
    desc:
      en: "Every login attempt, success or failure, with the source IP. fail2ban reads it to decide who to ban."
      fr: "Chaque tentative de connexion, réussie ou non, avec l'IP source. fail2ban le lit pour décider qui bannir."
  - id: fail2ban
    kind: service
    label: { en: "fail2ban", fr: "fail2ban" }
    sub: "3 failures → 1h ban"
    in: server
    when: { flag: FAIL2BAN }
    desc:
      en: "Watches the log; after three failures from one IP it adds a firewall rule dropping that IP for an hour. Keeps the noise out of sshd."
      fr: "Surveille le journal ; après trois échecs depuis une IP, il ajoute une règle de pare-feu qui la rejette pendant une heure. Épargne le bruit à sshd."
    deep:
      sub: "jail sshd · port ${SSH_PORT} · maxretry 3 · bantime 1h"
  - id: user
    kind: user
    label: { en: "Your account", fr: "Ton compte" }
    sub: "${USERNAME} · sudo"
    in: server
    desc:
      en: "A personal account with sudo. You log in as ${USERNAME}; root is only ever reached through sudo, which is logged."
      fr: "Un compte personnel avec sudo. Tu te connectes en ${USERNAME} ; root n'est atteint que par sudo, qui est journalisé."
    deep:
      sub: "${USERNAME} · sudo group · a shell, no root"

edges:
  - from: you
    to: firewall
    label: "ssh -p ${SSH_PORT}"
    guided: { label: "ssh -p ${SSH_PORT} · TCP" }
    deep: { label: "L7 SSH-2 · L4 TCP ${SSH_PORT} · L3 IP" }
    desc: { en: "Your connection, on the new port. Keep the old session open until this one works.", fr: "Ta connexion, sur le nouveau port. Garde l'ancienne session ouverte tant que celle-ci ne marche pas." }
  - from: bots
    to: firewall
    label: "22, refused"
    dashed: true
    deep: { label: "TCP 22 SYN → DROP" }
    desc: { en: "Port 22 is closed: the packets are dropped, the scanner sees nothing.", fr: "Le port 22 est fermé : les paquets sont rejetés, le scanner ne voit rien." }
  - from: firewall
    to: sshd
    deep: { label: "accept ${SSH_PORT}" }
    desc: { en: "Only traffic to ${SSH_PORT} gets through to the daemon.", fr: "Seul le trafic vers ${SSH_PORT} passe jusqu'au démon." }
  - from: config
    to: sshd
    label: "loads"
    dashed: true
    level: guided
    desc: { en: "Read at start. Test with `sshd -t` and reload; a typo here can lock you out.", fr: "Lu au démarrage. Teste avec `sshd -t` puis recharge ; une faute de frappe ici peut te verrouiller dehors." }
  - from: sshd
    to: keys
    label: "checks key"
    dashed: true
    level: deep
    desc: { en: "sshd looks for your public key in the file; the client proves it holds the private half.", fr: "sshd cherche ta clé publique dans le fichier ; le client prouve qu'il détient la moitié privée." }
  - from: sshd
    to: user
    deep: { label: "key matches → session" }
    desc: { en: "The key matched: a shell as ${USERNAME}, never as root.", fr: "La clé correspond : un shell en ${USERNAME}, jamais en root." }
  - from: sshd
    to: log
    label: "logs failures"
    dashed: true
    level: deep
    when: { flag: FAIL2BAN }
    desc: { en: "Each failed attempt is one line in the journal, with the source IP.", fr: "Chaque tentative échouée est une ligne dans le journal, avec l'IP source." }
  - from: log
    to: fail2ban
    label: "reads"
    dashed: true
    level: deep
    when: { flag: FAIL2BAN }
    desc: { en: "fail2ban tails the journal and counts failures per IP.", fr: "fail2ban suit le journal et compte les échecs par IP." }
  - from: fail2ban
    to: firewall
    label: "ban"
    dashed: true
    when: { flag: FAIL2BAN }
    deep: { label: "ban IP · nftables set" }
    desc: { en: "The ban is a firewall rule: the IP is dropped for an hour, then the rule is removed.", fr: "Le ban est une règle de pare-feu : l'IP est rejetée pendant une heure, puis la règle est retirée." }
````

---

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