# Source of "Fermer la porte publique"

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

````yaml title="content/private-vps-behind-wireguard/series.yaml"
title:
  en: A private VPS behind WireGuard
  fr: Un VPS privé derrière WireGuard
summary:
  en: >-
    From a hardened Debian VPS (pages 1 to 3 of "A static site on an OVH VPS") to a server that
    answers no one on the internet but your own devices, through a WireGuard tunnel you run
    yourself: no third party in the path, a single UDP port open, every service reachable only
    from inside.
  fr: >-
    D'un VPS Debian durci (pages 1 à 3 de « Un site statique sur un VPS OVH ») à un serveur qui
    ne répond à personne sur internet sauf à tes propres appareils, à travers un tunnel WireGuard
    que tu tiens toi-même : aucun tiers sur le chemin, un seul port UDP ouvert, chaque service
    joignable uniquement de l'intérieur.
order:
  - open-a-wireguard-tunnel
  - close-the-public-door

groups:
  - id: server
    label: { en: Server, fr: Serveur }
    desc: { en: The VPS as the previous series left it., fr: Le VPS tel que la série précédente l'a laissé. }
  - id: tunnel
    label: { en: Tunnel, fr: Tunnel }
    desc: { en: The private network between the server and your devices., fr: Le réseau privé entre le serveur et tes appareils. }
  - id: laptop
    label: { en: Your Mac, fr: Ton Mac }
    desc: { en: How your Mac reaches the server once the tunnel is up., fr: Comment ton Mac atteint le serveur une fois le tunnel monté. }

vars:
  - key: SERVER_IP
    kind: ip
    group: server
    default: 203.0.113.10
    label: { en: Server public IPv4, fr: IPv4 publique du serveur }
    hint:
      en: The public IPv4 address of the VPS, the one you used with ssh vps until now.
      fr: L'adresse IPv4 publique du VPS, celle que tu utilisais jusqu'ici avec ssh vps.
    impact:
      en: Becomes the tunnel's endpoint on every device. After this series it is the only address of yours the internet sees, and only on one UDP port.
      fr: Devient l'extrémité du tunnel sur chaque appareil. Après cette série, c'est la seule adresse à toi que voit internet, et sur un seul port UDP.
  - key: USERNAME
    kind: user
    group: server
    default: ops
    label: { en: Your admin user, fr: Ton utilisateur admin }
    hint:
      en: The account created by "Secure the server in the first hour", with sudo and a password.
      fr: Le compte créé par « Sécuriser le serveur dans la première heure », avec sudo et un mot de passe.
    impact:
      en: Every server command runs as this user with sudo. Its password is also the KVM console login, your way back in if the tunnel breaks.
      fr: Chaque commande serveur tourne sous ce compte avec sudo. Son mot de passe est aussi celui de la console KVM, ton chemin de retour si le tunnel casse.
  - key: SSH_PORT
    kind: port
    group: server
    default: "1234"
    label: { en: SSH port, fr: Port SSH }
    hint:
      en: The port sshd listens on since the hardening page.
      fr: Le port sur lequel sshd écoute depuis la page de durcissement.
    impact:
      en: Page 2 closes it to the internet and opens it on the tunnel interface only. A different value from the server's means the firewall closes the wrong port.
      fr: La page 2 le ferme à internet et l'ouvre seulement sur l'interface du tunnel. Une valeur différente de celle du serveur, et le pare-feu ferme le mauvais port.
  - key: SSH_KEY_FILE
    kind: path
    group: laptop
    default: ~/.ssh/id_ed25519_vps
    label: { en: SSH key of the server, fr: Clé SSH du serveur }
    hint:
      en: The private key on your Mac that opens the server, created when ordering the VPS.
      fr: La clé privée de ton Mac qui ouvre le serveur, créée à la commande du VPS.
    impact:
      en: Written into the new ssh shortcut. A wrong path and ssh offers no key, then fails with Permission denied (publickey).
      fr: Écrite dans le nouveau raccourci ssh. Un mauvais chemin et ssh ne présente aucune clé, puis échoue avec Permission denied (publickey).
  - key: SSH_ALIAS
    kind: hostname
    group: laptop
    default: cave
    label: { en: Shortcut through the tunnel, fr: Raccourci par le tunnel }
    hint:
      en: The name you type after ssh to reach the server through the tunnel. One word, lowercase.
      fr: Le nom que tu tapes après ssh pour atteindre le serveur par le tunnel. Un mot, en minuscules.
    impact:
      en: Replaces ssh vps once the public door is closed. Scripts that still say ssh vps (deploys, backups) must switch to it.
      fr: Remplace ssh vps une fois la porte publique fermée. Les scripts qui disent encore ssh vps (déploiements, sauvegardes) doivent passer à celui-ci.
  - key: WG_PORT
    kind: port
    group: tunnel
    default: "51820"
    label: { en: WireGuard port (UDP), fr: Port WireGuard (UDP) }
    hint:
      en: The UDP port the server listens on for the tunnel. 51820 is WireGuard's usual port; any free UDP port works.
      fr: Le port UDP sur lequel le serveur attend le tunnel. 51820 est le port habituel de WireGuard ; n'importe quel port UDP libre convient.
    impact:
      en: Opened in the firewall and written in every device's configuration. After page 2, it is the only open port of the server.
      fr: Ouvert dans le pare-feu et écrit dans la configuration de chaque appareil. Après la page 2, c'est le seul port ouvert du serveur.
  - key: WG_SERVER_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.1
    label: { en: Server address in the tunnel, fr: Adresse du serveur dans le tunnel }
    hint:
      en: A private IPv4 address that exists only inside the tunnel. The server takes it with a /24, so the tunnel network is its first three numbers.
      fr: "Une adresse IPv4 privée qui n'existe que dans le tunnel. Le serveur la prend en /24 : le réseau du tunnel, ce sont ses trois premiers nombres."
    impact:
      en: Every service of the series listens on it. It must not overlap a network you use (home box, office VPN), or traffic goes to the wrong place.
      fr: Chaque service de la série écoute dessus. Elle ne doit chevaucher aucun réseau que tu utilises (box, VPN du bureau), sinon le trafic part au mauvais endroit.
  - key: WG_MAC_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.2
    label: { en: Mac address in the tunnel, fr: Adresse du Mac dans le tunnel }
    hint:
      en: Your Mac's address inside the tunnel, in the same /24 as the server's.
      fr: L'adresse de ton Mac dans le tunnel, dans le même /24 que celle du serveur.
    impact:
      en: The server accepts packets from your Mac's key only with this source address. Two devices with the same address and one of them stops working.
      fr: Le serveur n'accepte les paquets de la clé de ton Mac qu'avec cette adresse source. Deux appareils avec la même adresse, et l'un des deux cesse de marcher.
  - key: WG_PHONE_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.3
    when: { flag: PHONE }
    label: { en: Phone address in the tunnel, fr: Adresse du téléphone dans le tunnel }
    hint:
      en: Your phone's address inside the tunnel, in the same /24, different from the Mac's.
      fr: L'adresse de ton téléphone dans le tunnel, dans le même /24, différente de celle du Mac.
    impact:
      en: "Same rule as the Mac's: one address per device, inside the server's /24."
      fr: "Même règle que pour le Mac : une adresse par appareil, dans le /24 du serveur."

choices:
  - key: PHONE
    type: boolean
    label: { en: Also connect a phone, fr: Connecter aussi un téléphone }
    hint:
      en: Adds a second device to the tunnel, with its own key. You can come back and turn it on later.
      fr: Ajoute un second appareil au tunnel, avec sa propre clé. Tu peux revenir l'activer plus tard.
    default: false
````

````yaml title="content/private-vps-behind-wireguard/close-the-public-door/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, USERNAME, SSH_PORT, SSH_KEY_FILE, SSH_ALIAS, WG_PORT, WG_SERVER_ADDR, WG_MAC_ADDR, WG_PHONE_ADDR, PHONE.
# No page variables: this page only moves a firewall rule and proves the result.
title:
  en: Close the public door
  fr: Fermer la porte publique
summary:
  en: >-
    SSH taken off the internet and kept on the tunnel only, checked from outside, proven to
    survive a reboot, with the way back in written down before you need it. The server's only
    open port is now WireGuard's, which answers nothing to anyone without a key.
  fr: >-
    SSH retiré d'internet et gardé sur le tunnel seulement, vérifié depuis l'extérieur, prouvé
    après un redémarrage, avec le chemin du retour écrit avant d'en avoir besoin. Le seul port
    ouvert du serveur est désormais celui de WireGuard, qui ne répond rien à qui n'a pas de clé.
difficulty: intermediate
tags: [wireguard, ssh, ufw, firewall, debian, ovh, security]
authors: [thudal]
created: 2026-10-02
minutes: 15
validated: Debian 13 (trixie) on OVHcloud VPS · ufw 0.36
status: draft             # not yet run end to end by its author

choices:
  - key: REMOVE_VPS_ALIAS
    type: boolean
    label: { en: Remove the old ssh vps shortcut, fr: Retirer l'ancien raccourci ssh vps }
    hint:
      en: Once the public door is closed, ssh vps only times out. Removing it avoids waiting on it by habit.
      fr: Une fois la porte publique fermée, ssh vps ne fait plus qu'attendre. Le retirer t'évite de l'utiliser par habitude.
    default: true
````

````mdx title="content/private-vps-behind-wireguard/close-the-public-door/page-en.mdx"
{/* First pass — to be validated before publishing against man ufw (Debian 13), https://www.wireguard.com/ , OVH's "KVM console" and "Rescue mode" guides on https://docs.ovhcloud.com , and the WireGuard app for macOS (tunnel export). */}

The tunnel works: SSH no longer needs to be on the internet. This page opens SSH on the tunnel interface, removes the public rule, checks from outside that the door is really closed, and reboots the server to prove everything comes back on its own. Before closing, it puts the way back in somewhere safe: the KVM console, and a copy of your keys.

<Run>

Two parts. **Part 1, on the server**, connected **through the tunnel** (`ssh `<V name="SSH_ALIAS" />): paste the first block into `close-door.sh` with <V name="EDITOR" />, run `sudo bash close-door.sh`. The script refuses to run if the tunnel has never been used. **Part 2, on your Mac**: run the next block in a terminal. It checks the door from outside<When flag="REMOVE_VPS_ALIAS">, removes the `vps` shortcut</When> and reboots the server; wait a minute and run `ssh `<V name="SSH_ALIAS" /> again.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Close the public door — SSH ${SSH_PORT} on wg0 only. Through ssh ${SSH_ALIAS}: sudo bash close-door.sh
[ "$(id -u)" -eq 0 ] || { echo 'Run me with: sudo bash close-door.sh'; exit 1; }
systemctl is-active --quiet wg-quick@wg0 || { echo "wg0 is not active: do page 1 first."; exit 1; }
systemctl is-enabled --quiet wg-quick@wg0 || { echo "wg-quick@wg0 does not start at boot: systemctl enable wg-quick@wg0"; exit 1; }
wg show wg0 latest-handshakes | awk '$2 > 0 { ok = 1 } END { exit !ok }' || { echo "No device has completed a handshake yet: prove the tunnel first (page 1)."; exit 1; }
ufw allow in on wg0 to any port ${SSH_PORT} proto tcp
ufw delete allow ${SSH_PORT}/tcp || true
ufw status verbose
```

```bash on="mac"
nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo "public door: STILL OPEN" || echo "public door: closed"
ssh ${SSH_ALIAS} echo via-wireguard
```

<When flag="REMOVE_VPS_ALIAS">

```bash on="mac"
cp ~/.ssh/config ~/.ssh/config.bak
awk '/^Host[[:space:]]/ { skip = ($2 == "vps") } !skip' ~/.ssh/config.bak > ~/.ssh/config
```

</When>

```bash on="mac" interactive
ssh -t ${SSH_ALIAS} sudo systemctl reboot
```

<Warn>Before running part 1, store a copy of your keys (step "Keep the way back in"): after this page, your Mac is the only thing that opens the server, apart from the KVM console.</Warn>

</Run>

## Before you start

<Guided>Page 1 must be done: `ssh `<V name="SSH_ALIAS" /> answers through the tunnel. Everything below happens in a session opened **through the tunnel**, never through `ssh vps`: that is the session this page keeps, so it is the one that must work.</Guided>

<Check cmd="ssh ${SSH_ALIAS} echo via-wireguard" expect="via-wireguard" />

## Keep the way back in

After this page, two things open the server: your Mac (its WireGuard key **and** its SSH key), and the KVM console in the OVH control panel. Check the second one now, and make a copy of the first.

<Warn>Open the KVM console (OVH control panel → your VPS → `...` → **KVM**) and log in as <V name="USERNAME" /> with your sudo password. If that does not work, stop here: without KVM, a broken tunnel leaves you out for good, apart from rescue mode.</Warn>

Then store in your password manager:

- the SSH key <V name="SSH_KEY_FILE" /> (the private file, already encrypted by its passphrase);
- the tunnel configuration, exported from the WireGuard app on the Mac (menu **Tunnels** or **File** → **Export Tunnels to Zip…**).

<Guided>A Mac lost, stolen or dead, and the server can no longer be reached over SSH. With these two copies, another computer gets back in within ten minutes: import the tunnel into the app, copy the SSH key, `ssh `<V name="SSH_ALIAS" />. The zip holds the WireGuard private key in clear: it must exist only in the password manager, not in Downloads.</Guided>

<Deep>The OVH account becomes the master key: it gives the KVM console, rescue mode (another system booted with your disk mounted) and the reinstall button. What you close here is the internet; the OVH account stays a door. Its two-factor authentication (page "Order the VPS") is therefore not optional. The exact label of the export menu depends on the app's version; if it does not exist, copy the tunnel's text (**Edit**, select all) into a secure note.</Deep>

## Take a snapshot

<Warn>Optional, and paid: the Snapshot option costs about €0.36 incl. VAT per month. Check the price on ovhcloud.com. A snapshot taken now brings you back in one click to a server where SSH is still public.</Warn>

<Guided>If you have the option, take a snapshot in the control panel (VPS page, Snapshot section) before going on. Otherwise, the KVM console and the step back in "If the tunnel breaks" are enough: nothing on this page deletes data.</Guided>

## Open SSH on the tunnel only

In your `ssh `<V name="SSH_ALIAS" /> session, allow SSH on the `wg0` interface, **then** remove the public rule:

```bash on="server" as="${USERNAME}"
sudo ufw allow in on wg0 to any port ${SSH_PORT} proto tcp
sudo ufw delete allow ${SSH_PORT}/tcp
sudo ufw status verbose
```

`ufw status` must now show two rules (plus their `(v6)` twins): <V name="WG_PORT" />`/udp ALLOW IN Anywhere` and <V name="SSH_PORT" />`/tcp on wg0 ALLOW IN Anywhere`.

<Guided>Order matters: the new rule first, the old one second. Your session goes through the tunnel, so it survives both commands; even a public session would survive, since ufw lets established connections through. sshd itself does not change: it still listens everywhere, and the firewall decides where it can be reached from.</Guided>

<Deep>Why not `ListenAddress `<V name="WG_SERVER_ADDR" /> in sshd? Because if `wg0` is not up yet when sshd starts (boot order, a broken interface), sshd cannot find the address and does not start at all; and the day the tunnel breaks, reopening the public port would no longer be enough, since sshd would only listen on an address that does not exist. Filtering at the firewall keeps sshd independent of the tunnel, and reopening access in an emergency takes one command. `in on wg0` matches the packet's input interface: a packet from the internet with a forged 10.x source address comes in on the public interface and is refused. fail2ban keeps its sshd jail; it now only sees your own connections through the tunnel.</Deep>

## Check from outside

From your Mac, try the public door: it must not answer. Then go back through the tunnel:

```bash on="mac"
nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo "public door: STILL OPEN" || echo "public door: closed"
ssh ${SSH_ALIAS} echo via-wireguard
```

<Check cmd="nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo open || echo closed" expect="closed" />

<Guided>`nc -z` only tries to open a TCP connection, without sending anything; `-G 5` gives up after five seconds. The tunnel only covers <V name="WG_SERVER_ADDR" />, so this test to <V name="SERVER_IP" /> really goes over the internet, like a stranger's. A firewall that refuses drops the packet without answering: the test waits, then fails.</Guided>

<When flag="REMOVE_VPS_ALIAS">

## Remove the vps shortcut

`ssh vps` leads nowhere now. Remove its block from `~/.ssh/config`, keeping a copy:

```bash on="mac"
cp ~/.ssh/config ~/.ssh/config.bak
awk '/^Host[[:space:]]/ { skip = ($2 == "vps") } !skip' ~/.ssh/config.bak > ~/.ssh/config
```

<Guided>The script copies the file line by line and skips everything after `Host vps` up to the next `Host`. Your file keeps its mode `600`, because the redirection rewrites the existing file instead of creating a new one. Scripts from other series that say `ssh vps` (deploys, backups) must switch to `ssh `<V name="SSH_ALIAS" />.</Guided>

</When>

## Prove it survives a reboot

A setting that does not survive a reboot will lock you out one day, on the night of an automatic update. Reboot now, while you are watching:

```bash on="mac" interactive
ssh -t ${SSH_ALIAS} sudo systemctl reboot
```

Wait a minute, then:

<Check cmd="ssh ${SSH_ALIAS} systemctl is-active wg-quick@wg0" expect="active" />

<Guided>`-t` gives the remote command a terminal, so sudo can ask for your password. After the reboot, the WireGuard app on the Mac has nothing to do: the tunnel has no connection state, and the next packet redoes the handshake.</Guided>

<Deep>If it does not come back within three minutes, go to the next step through the KVM console. The likeliest causes: `wg-quick@wg0` is not enabled at boot (`systemctl is-enabled wg-quick@wg0`), or a file in `/etc/wireguard/` lost its mode. `journalctl -b -u wg-quick@wg0` shows what happened at boot.</Deep>

## If the tunnel breaks

<Details summary="Get in through the KVM console and reopen SSH while you repair">

In the OVH control panel, open the **KVM console**, log in as <V name="USERNAME" /> with your password. Reopen the public door:

```bash on="server" as="${USERNAME}"
sudo ufw allow ${SSH_PORT}/tcp
```

You can now get in from your Mac through the public address, with your key, and diagnose in comfort:

```bash on="mac"
ssh -p ${SSH_PORT} -i ${SSH_KEY_FILE} -o IdentitiesOnly=yes ${USERNAME}@${SERVER_IP}
```

On the server, `sudo systemctl status wg-quick@wg0`, `sudo wg show` and `journalctl -u wg-quick@wg0` almost always tell what is wrong. Once `ssh `<V name="SSH_ALIAS" /> is back, close again: `sudo ufw delete allow ${SSH_PORT}/tcp`.

</Details>

<Details summary="If you lost the Mac">

On the new computer, import the exported tunnel and the SSH key stored above: everything works again, nothing to change on the server. Then revoke the old Mac, since its WireGuard key still exists somewhere: generate a new pair in the app, replace `PublicKey` in the `# mac` block of `/etc/wireguard/wg0.conf`, `sudo systemctl restart wg-quick@wg0`, and remove the old SSH key from `~/.ssh/authorized_keys`.

Without a copy of the keys, the KVM console remains: reopen the public door as above, then add the new SSH public key to `~/.ssh/authorized_keys`. Typing it in the console is painful (and sensitive to the keyboard layout); OVH's rescue mode lets you edit the file from another system.

</Details>

<Note>In the KVM console the keyboard is often QWERTY: a password with special characters may type differently there. That is why the hardening page recommends letters and digits.</Note>

## What is still visible

Seen from the internet, your server now answers ping, and nothing else. Port <V name="WG_PORT" />/udp is open, but mute to anyone without a key.

<Guided>A scanner sees a machine that is up, with no service. It cannot tell the WireGuard port from a closed one, since WireGuard only answers packets signed by a key it knows.</Guided>

<Deep>To go further: ufw accepts ping (ICMP echo) by default, in `/etc/ufw/before.rules` and `before6.rules`; refusing it makes the machine quieter, at the cost of a diagnostic tool. On the way out, ufw lets everything leave (`default allow outgoing`): a compromised service could send data anywhere. Filtering egress (allowing only the Debian mirrors, DNS, NTP) is the logical complement to this page, for later. And what this firewall does not see: OVH, which runs the machine, sees its disk and memory. The answer to that is not network: it is encrypting on the Mac what must stay unreadable to the host.</Deep>

## Done

| What | Before | After this page |
|---|---|---|
| Ports open to the internet | <V name="SSH_PORT" />/tcp, <V name="WG_PORT" />/udp | <V name="WG_PORT" />/udp only, mute without a key |
| SSH | reachable from the whole internet | through the tunnel only, `ssh `<V name="SSH_ALIAS" /> |
| After a reboot | untested | proven |
| Way back in | KVM console | KVM console, keys stored, step back in one command |

The server is now private: only your devices get in. The rest of the series installs what should live there, listening only on <V name="WG_SERVER_ADDR" />.
````

````mdx title="content/private-vps-behind-wireguard/close-the-public-door/page-fr.mdx"
{/* Premier jet — à valider avant publication contre man ufw (Debian 13), https://www.wireguard.com/ , les guides OVH « Console KVM » et « Mode rescue » sur https://docs.ovhcloud.com , et l'app WireGuard pour macOS (export des tunnels). */}

Le tunnel marche : SSH n'a plus besoin d'être sur internet. Cette page ouvre SSH sur l'interface du tunnel, retire la règle publique, vérifie depuis l'extérieur que la porte est vraiment fermée, et redémarre le serveur pour prouver que tout revient seul. Avant de fermer, elle range le chemin du retour : la console KVM, et une copie de tes clés.

<Run>

Deux parties. **Partie 1, sur le serveur**, connecté **par le tunnel** (`ssh `<V name="SSH_ALIAS" />) : colle le premier bloc dans `close-door.sh` avec <V name="EDITOR" />, lance `sudo bash close-door.sh`. Le script refuse de tourner si le tunnel n'a jamais servi. **Partie 2, sur ton Mac** : lance le bloc suivant dans un terminal. Il vérifie la fermeture depuis l'extérieur<When flag="REMOVE_VPS_ALIAS">, retire le raccourci `vps`</When> et redémarre le serveur ; attends une minute et refais `ssh `<V name="SSH_ALIAS" />.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Fermer la porte publique — SSH ${SSH_PORT} sur wg0 seulement. Via ssh ${SSH_ALIAS} : sudo bash close-door.sh
[ "$(id -u)" -eq 0 ] || { echo 'Lance-moi avec : sudo bash close-door.sh'; exit 1; }
systemctl is-active --quiet wg-quick@wg0 || { echo "wg0 n'est pas actif : fais d'abord la page 1."; exit 1; }
systemctl is-enabled --quiet wg-quick@wg0 || { echo "wg-quick@wg0 ne démarre pas au boot : systemctl enable wg-quick@wg0"; exit 1; }
wg show wg0 latest-handshakes | awk '$2 > 0 { ok = 1 } END { exit !ok }' || { echo "Aucun appareil n'a encore fait de poignée de main : prouve d'abord le tunnel (page 1)."; exit 1; }
ufw allow in on wg0 to any port ${SSH_PORT} proto tcp
ufw delete allow ${SSH_PORT}/tcp || true
ufw status verbose
```

```bash on="mac"
nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo "porte publique : ENCORE OUVERTE" || echo "porte publique : fermée"
ssh ${SSH_ALIAS} echo via-wireguard
```

<When flag="REMOVE_VPS_ALIAS">

```bash on="mac"
cp ~/.ssh/config ~/.ssh/config.bak
awk '/^Host[[:space:]]/ { skip = ($2 == "vps") } !skip' ~/.ssh/config.bak > ~/.ssh/config
```

</When>

```bash on="mac" interactive
ssh -t ${SSH_ALIAS} sudo systemctl reboot
```

<Warn>Avant de lancer la partie 1, range une copie de tes clés (étape « Ranger le chemin du retour ») : après cette page, ton Mac est la seule chose qui ouvre le serveur, en dehors de la console KVM.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut la page 1 terminée : `ssh `<V name="SSH_ALIAS" /> répond par le tunnel. Tout ce qui suit se fait dans une session ouverte **par le tunnel**, jamais par `ssh vps` : c'est elle que la page garde, c'est donc elle qui doit marcher.</Guided>

<Check cmd="ssh ${SSH_ALIAS} echo via-wireguard" expect="via-wireguard" />

## Ranger le chemin du retour

Après cette page, deux choses ouvrent le serveur : ton Mac (sa clé WireGuard **et** sa clé SSH), et la console KVM de l'espace client OVH. Vérifie la seconde maintenant, et fais une copie de la première.

<Warn>Ouvre la console KVM (espace client OVH → ton VPS → `...` → **KVM**) et connecte-toi en <V name="USERNAME" /> avec ton mot de passe sudo. Si ça ne marche pas, arrête-toi ici : sans KVM, un tunnel cassé te laisse dehors pour de bon, à part le mode rescue.</Warn>

Puis range dans ton gestionnaire de mots de passe :

- la clé SSH <V name="SSH_KEY_FILE" /> (le fichier privé, déjà chiffré par sa phrase de passe) ;
- la configuration du tunnel, exportée depuis l'app WireGuard du Mac (menu **Tunnels** ou **File** → **Export Tunnels to Zip…**).

<Guided>Un Mac perdu, volé ou mort, et le serveur devient injoignable par SSH. Avec ces deux copies, un autre ordinateur retrouve l'accès en dix minutes : importer le tunnel dans l'app, copier la clé SSH, `ssh `<V name="SSH_ALIAS" />. Le zip contient la clé privée WireGuard en clair : il ne doit exister que dans le gestionnaire de mots de passe, pas dans Téléchargements.</Guided>

<Deep>Le compte OVH devient la clé maîtresse : il donne la console KVM, le mode rescue (un autre système démarré avec ton disque monté) et le bouton de réinstallation. Ce que tu fermes ici, c'est internet ; le compte OVH, lui, reste une porte. Sa double authentification (page « Commander le VPS ») n'est donc pas optionnelle. Le libellé exact du menu d'export dépend de la version de l'app ; s'il n'existe pas, recopie le texte du tunnel (**Edit**, tout sélectionner) dans une note sécurisée.</Deep>

## Prendre un snapshot

<Warn>Facultatif, et payant : l'option Snapshot coûte environ 0,36 € TTC par mois. Vérifie le prix sur ovhcloud.com/fr/vps. Un snapshot pris maintenant ramène en un clic à un serveur où SSH est encore public.</Warn>

<Guided>Si tu as l'option, prends un snapshot dans l'espace client (page du VPS, section Snapshot) avant de continuer. Sinon, la console KVM et la marche arrière de l'étape « Si le tunnel casse » suffisent : rien sur cette page n'efface de données.</Guided>

## Ouvrir SSH sur le tunnel seulement

Dans ta session `ssh `<V name="SSH_ALIAS" />, autorise SSH sur l'interface `wg0`, **puis** retire la règle publique :

```bash on="server" as="${USERNAME}"
sudo ufw allow in on wg0 to any port ${SSH_PORT} proto tcp
sudo ufw delete allow ${SSH_PORT}/tcp
sudo ufw status verbose
```

`ufw status` doit maintenant montrer deux règles (plus leurs jumelles `(v6)`) : <V name="WG_PORT" />`/udp ALLOW IN Anywhere` et <V name="SSH_PORT" />`/tcp on wg0 ALLOW IN Anywhere`.

<Guided>L'ordre compte : la nouvelle règle d'abord, l'ancienne ensuite. Ta session passe par le tunnel, elle survit donc aux deux commandes ; et même une session publique survivrait, puisque ufw laisse passer les connexions déjà établies. sshd lui-même ne change pas : il écoute toujours partout, c'est le pare-feu qui décide d'où l'on peut l'atteindre.</Guided>

<Deep>Pourquoi pas `ListenAddress `<V name="WG_SERVER_ADDR" /> dans sshd ? Parce que si `wg0` n'est pas encore monté quand sshd démarre (ordre de boot, interface en panne), sshd ne trouve pas l'adresse et ne démarre pas du tout ; et le jour où le tunnel casse, rouvrir le port public ne suffirait plus, puisque sshd n'écouterait que sur une adresse qui n'existe pas. Filtrer au pare-feu garde sshd indépendant du tunnel, et rouvrir l'accès en secours tient en une commande. `in on wg0` compare l'interface d'entrée du paquet : un paquet venu d'internet avec une adresse source forgée en 10.x entre par l'interface publique, il est refusé. fail2ban garde sa jail sshd ; elle ne voit plus passer que tes propres connexions par le tunnel.</Deep>

## Vérifier depuis l'extérieur

Depuis ton Mac, essaie la porte publique : elle ne doit pas répondre. Puis repasse par le tunnel :

```bash on="mac"
nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo "porte publique : ENCORE OUVERTE" || echo "porte publique : fermée"
ssh ${SSH_ALIAS} echo via-wireguard
```

<Check cmd="nc -z -G 5 ${SERVER_IP} ${SSH_PORT} >/dev/null 2>&1 && echo open || echo closed" expect="closed" />

<Guided>`nc -z` tente seulement d'ouvrir une connexion TCP, sans rien envoyer ; `-G 5` abandonne au bout de cinq secondes. Le tunnel ne couvre que <V name="WG_SERVER_ADDR" />, donc ce test vers <V name="SERVER_IP" /> part bien par internet, comme celui d'un inconnu. Un pare-feu qui refuse jette le paquet sans répondre : le test attend, puis échoue.</Guided>

<When flag="REMOVE_VPS_ALIAS">

## Retirer le raccourci vps

`ssh vps` ne mène plus nulle part. Retire son bloc de `~/.ssh/config`, en gardant une copie :

```bash on="mac"
cp ~/.ssh/config ~/.ssh/config.bak
awk '/^Host[[:space:]]/ { skip = ($2 == "vps") } !skip' ~/.ssh/config.bak > ~/.ssh/config
```

<Guided>Le script recopie le fichier ligne à ligne et saute tout ce qui suit `Host vps` jusqu'au `Host` suivant. Ton fichier garde ses droits `600`, parce que la redirection réécrit le fichier existant au lieu d'en créer un nouveau. Les scripts d'autres séries qui disent `ssh vps` (déploiement, sauvegardes) doivent passer à `ssh `<V name="SSH_ALIAS" />.</Guided>

</When>

## Prouver que ça survit à un redémarrage

Un réglage qui ne survit pas à un redémarrage finira par te verrouiller dehors, la nuit d'une mise à jour automatique. Redémarre maintenant, pendant que tu regardes :

```bash on="mac" interactive
ssh -t ${SSH_ALIAS} sudo systemctl reboot
```

Attends une minute, puis :

<Check cmd="ssh ${SSH_ALIAS} systemctl is-active wg-quick@wg0" expect="active" />

<Guided>`-t` donne un terminal à la commande distante, pour que sudo puisse demander ton mot de passe. Après le redémarrage, l'app WireGuard du Mac n'a rien à faire : le tunnel n'a pas d'état de connexion, le prochain paquet refait la poignée de main.</Guided>

<Deep>Si ça ne revient pas au bout de trois minutes, passe à l'étape suivante par la console KVM. La cause la plus probable : `wg-quick@wg0` n'est pas activé au boot (`systemctl is-enabled wg-quick@wg0`), ou un fichier de `/etc/wireguard/` a perdu ses droits. `journalctl -b -u wg-quick@wg0` montre ce qui s'est passé au démarrage.</Deep>

## Si le tunnel casse

<Details summary="Rentrer par la console KVM et rouvrir SSH le temps de réparer">

Dans l'espace client OVH, ouvre la **console KVM**, connecte-toi en <V name="USERNAME" /> avec ton mot de passe. Rouvre la porte publique :

```bash on="server" as="${USERNAME}"
sudo ufw allow ${SSH_PORT}/tcp
```

Tu peux maintenant entrer depuis ton Mac par l'adresse publique, avec ta clé, et diagnostiquer confortablement :

```bash on="mac"
ssh -p ${SSH_PORT} -i ${SSH_KEY_FILE} -o IdentitiesOnly=yes ${USERNAME}@${SERVER_IP}
```

Côté serveur, `sudo systemctl status wg-quick@wg0`, `sudo wg show` et `journalctl -u wg-quick@wg0` disent presque toujours ce qui ne va pas. Une fois `ssh `<V name="SSH_ALIAS" /> revenu, referme : `sudo ufw delete allow ${SSH_PORT}/tcp`.

</Details>

<Details summary="Si tu as perdu le Mac">

Sur le nouvel ordinateur, importe le tunnel exporté et la clé SSH rangés plus haut : tout remarche, rien à toucher côté serveur. Révoque ensuite l'ancien Mac, puisque sa clé WireGuard existe encore quelque part : génère une nouvelle paire dans l'app, remplace `PublicKey` dans le bloc `# mac` de `/etc/wireguard/wg0.conf`, `sudo systemctl restart wg-quick@wg0`, et retire l'ancienne clé SSH de `~/.ssh/authorized_keys`.

Sans copie des clés, il reste la console KVM : rouvre la porte publique comme ci-dessus, puis ajoute la nouvelle clé SSH publique dans `~/.ssh/authorized_keys`. La taper dans la console est pénible (et sensible à la disposition du clavier) ; le mode rescue d'OVH permet de modifier le fichier depuis un autre système.

</Details>

<Note>Dans la console KVM, le clavier est souvent en QWERTY : un mot de passe avec des caractères spéciaux peut s'y taper différemment. C'est pour ça que la page de durcissement recommande lettres et chiffres.</Note>

## Ce qui reste visible

Vu d'internet, ton serveur répond maintenant au ping, et à rien d'autre. Le port <V name="WG_PORT" />/udp est ouvert, mais muet pour qui n'a pas de clé.

<Guided>Un scanner voit une machine allumée, sans service. Il ne peut pas distinguer le port WireGuard d'un port fermé, puisque WireGuard ne répond qu'aux paquets signés par une clé qu'il connaît.</Guided>

<Deep>Pour aller plus loin : ufw accepte le ping (ICMP echo) par défaut, dans `/etc/ufw/before.rules` et `before6.rules` ; le refuser rend la machine plus discrète, au prix d'un outil de diagnostic. Côté sortie, ufw laisse tout sortir (`default allow outgoing`) : un service compromis pourrait envoyer des données n'importe où. Filtrer la sortie (n'autoriser que les dépôts Debian, le DNS, le NTP) est le complément logique de cette page, pour plus tard. Et ce que ce pare-feu ne voit pas : OVH, qui fait tourner la machine, voit son disque et sa mémoire. Contre ça, la réponse n'est pas réseau : c'est de chiffrer côté Mac ce qui doit rester illisible pour l'hébergeur.</Deep>

## Terminé

| Quoi | Avant | Après cette page |
|---|---|---|
| Ports ouverts sur internet | <V name="SSH_PORT" />/tcp, <V name="WG_PORT" />/udp | <V name="WG_PORT" />/udp seulement, muet sans clé |
| SSH | joignable par tout internet | seulement par le tunnel, `ssh `<V name="SSH_ALIAS" /> |
| Après un redémarrage | non testé | prouvé |
| Chemin du retour | console KVM | console KVM, clés rangées, marche arrière en une commande |

Le serveur est désormais une cave : seuls tes appareils y entrent. La suite de la série y installe ce qui doit y vivre, en écoutant uniquement sur <V name="WG_SERVER_ADDR" />.
````

````yaml title="content/private-vps-behind-wireguard/close-the-public-door/diagram.yaml"
# Quick: Mac, tunnel, wg0, sshd, and the bots facing a closed door. Guided: the firewall rules and the KVM console.
# Deep: the OVH account as master key, and the copies of the keys.
title: { en: "One door left, and it is mute", fr: "Une seule porte, et elle est muette" }
caption:
  en: "From the internet, only ${WG_PORT}/udp is open, and it answers nobody without a key. SSH is reached at ${WG_SERVER_ADDR}:${SSH_PORT}, inside the tunnel. The KVM console is the way back in."
  fr: "Depuis internet, seul ${WG_PORT}/udp est ouvert, et il ne répond à personne sans clé. SSH s'atteint en ${WG_SERVER_ADDR}:${SSH_PORT}, dans le tunnel. La console KVM est le chemin du retour."

groups:
  - id: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The VPS after this page: one UDP port open to the internet, SSH only on the tunnel interface."
      fr: "Le VPS après cette page : un port UDP ouvert à internet, SSH seulement sur l'interface du tunnel."

nodes:
  - id: mac
    kind: client
    label: { en: "Your Mac", fr: "Ton Mac" }
    sub: "ssh ${SSH_ALIAS}"
    desc:
      en: "Holds the WireGuard key and the SSH key: with the KVM console, the only things that open the server."
      fr: "Détient la clé WireGuard et la clé SSH : avec la console KVM, les seules choses qui ouvrent le serveur."
  - id: bots
    kind: cloud
    label: { en: "The internet's bots", fr: "Les robots d'internet" }
    sub: "scans"
    desc:
      en: "Scanners now find no service: ${SSH_PORT}/tcp is dropped, ${WG_PORT}/udp stays silent without a key."
      fr: "Les scanners ne trouvent plus aucun service : ${SSH_PORT}/tcp est jeté, ${WG_PORT}/udp reste muet sans clé."
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "${WG_PORT}/udp"
    in: server
    desc:
      en: "ufw: inbound refused except ${WG_PORT}/udp from anywhere and ${SSH_PORT}/tcp on wg0 only."
      fr: "ufw : entrant refusé sauf ${WG_PORT}/udp depuis partout et ${SSH_PORT}/tcp sur wg0 seulement."
    guided:
      sub: "allow ${WG_PORT}/udp · allow in on wg0 ${SSH_PORT}/tcp"
  - id: wg0
    kind: net
    label: { en: "wg0", fr: "wg0" }
    sub: "${WG_SERVER_ADDR}"
    in: server
    desc:
      en: "The tunnel interface: the only way to reach anything on the server from outside."
      fr: "L'interface du tunnel : le seul moyen d'atteindre quoi que ce soit sur le serveur depuis l'extérieur."
  - id: sshd
    kind: server
    label: { en: "sshd", fr: "sshd" }
    sub: ":${SSH_PORT} via wg0"
    in: server
    focus: true
    desc:
      en: "Unchanged, still listening everywhere; the firewall only lets the tunnel reach it. That keeps it independent of wg0 at boot."
      fr: "Inchangé, il écoute toujours partout ; le pare-feu ne laisse que le tunnel l'atteindre. Ça le garde indépendant de wg0 au démarrage."
  - id: kvm
    kind: client
    label: { en: "KVM console", fr: "Console KVM" }
    sub: "${USERNAME} + password"
    level: guided
    desc:
      en: "A screen and keyboard on the VPS, in the OVH control panel. Works with the tunnel broken; reopens the public door in one command."
      fr: "Un écran et un clavier sur le VPS, dans l'espace client OVH. Marche tunnel cassé ; rouvre la porte publique en une commande."
  - id: ovh
    kind: cloud
    label: { en: "OVH account", fr: "Compte OVH" }
    sub: "2FA"
    level: deep
    desc:
      en: "Gives the KVM console, rescue mode and reinstall: the master key of the server, outside any firewall."
      fr: "Donne la console KVM, le mode rescue et la réinstallation : la clé maîtresse du serveur, hors de tout pare-feu."
  - id: copies
    kind: store
    label: { en: "Key copies", fr: "Copies des clés" }
    sub: { en: "password manager", fr: "gestionnaire de mots de passe" }
    level: deep
    desc:
      en: "The exported tunnel and the SSH key: another computer gets back in without touching the server."
      fr: "Le tunnel exporté et la clé SSH : un autre ordinateur retrouve l'accès sans toucher au serveur."

edges:
  - { from: mac, to: firewall, label: "UDP ${WG_PORT}", desc: { en: "The encrypted tunnel, the only traffic let in.", fr: "Le tunnel chiffré, le seul trafic qui entre." } }
  - { from: firewall, to: wg0, desc: { en: "Packets signed by a known key are decrypted.", fr: "Les paquets signés par une clé connue sont déchiffrés." } }
  - { from: wg0, to: sshd, label: "TCP ${SSH_PORT}", desc: { en: "SSH inside the tunnel, allowed by the rule on wg0.", fr: "SSH dans le tunnel, autorisé par la règle sur wg0." } }
  - { from: bots, to: firewall, label: "${SSH_PORT}/tcp ✕", dashed: true, desc: { en: "Dropped without an answer.", fr: "Jeté sans réponse." } }
  - { from: kvm, to: sshd, label: { en: "way back in", fr: "chemin du retour" }, dashed: true, level: guided, desc: { en: "Logs in on the console, can reopen ${SSH_PORT}/tcp while you repair.", fr: "Se connecte sur la console, peut rouvrir ${SSH_PORT}/tcp le temps de réparer." } }
  - { from: ovh, to: kvm, dashed: true, level: deep, desc: { en: "Whoever holds the OVH account holds the console.", fr: "Qui tient le compte OVH tient la console." } }
  - { from: copies, to: mac, dashed: true, level: deep, desc: { en: "Restore the keys on a new computer.", fr: "Restaurer les clés sur un nouvel ordinateur." } }
````

---

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