# Source of "Secure the server in the first hour"

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

````yaml title="content/ovh-vps-static-site/series.yaml"
title:
  en: A static site on an OVH VPS
  fr: Un site statique sur un VPS OVH
summary:
  en: >-
    From a Next.js project on your laptop to a site served over HTTPS from your own Debian VPS at
    OVH, with a domain managed at Infomaniak, atomic deploys, backups and monitoring. Eight pages,
    in the order you do them.
  fr: >-
    D'un projet Next.js sur ton portable à un site servi en HTTPS depuis ton propre VPS Debian chez
    OVH, avec un domaine géré chez Infomaniak, des déploiements atomiques, des sauvegardes et une
    surveillance. Huit pages, dans l'ordre où tu les fais.
order:
  - prepare-the-laptop
  - order-the-vps
  - secure-the-server
  - point-the-domain
  - serve-with-caddy
  - deploy-releases
  - backups-and-monitoring
  - launch-checklist

groups:
  - id: server
    label: { en: Server, fr: Serveur }
    desc: { en: The VPS and how you reach it., fr: Le VPS et comment tu l'atteins. }
  - id: site
    label: { en: Site, fr: Site }
    desc: { en: The domain and where the files live on the server., fr: Le domaine et l'endroit où vivent les fichiers sur le serveur. }
  - id: laptop
    label: { en: Your laptop, fr: Ton portable }
    desc: { en: Where the code is written and built., fr: Là où le code est écrit et construit. }

vars:
  - key: SERVER_IP
    kind: ip
    group: server
    default: 203.0.113.10
    label: { en: Server IPv4, fr: IPv4 du serveur }
    hint:
      en: The public IPv4 address of the VPS, shown in the OVH control panel and in the delivery email.
      fr: L'adresse IPv4 publique du VPS, affichée dans l'espace client OVH et dans l'email de livraison.
    impact:
      en: Used by every ssh command, by the DNS A record and by the deploy script. A typo means timeouts everywhere.
      fr: Utilisée par chaque commande ssh, par l'enregistrement DNS A et par le script de déploiement. Une faute donne des délais d'attente partout.
  - key: SERVER_IPV6
    kind: text
    group: server
    default: "2001:db8::10"
    label: { en: Server IPv6, fr: IPv6 du serveur }
    hint:
      en: The public IPv6 address of the VPS, in the IP section of the OVH control panel. Without the /prefix.
      fr: L'adresse IPv6 publique du VPS, dans la section IP de l'espace client OVH. Sans le /préfixe.
    impact:
      en: Goes into the DNS AAAA record. A wrong AAAA is worse than none — IPv6 visitors get timeouts and the certificate can fail.
      fr: Va dans l'enregistrement DNS AAAA. Un AAAA faux est pire que pas d'AAAA — les visiteurs IPv6 tombent en délai d'attente et le certificat peut échouer.
  - key: USERNAME
    kind: user
    group: server
    default: ops
    label: { en: Your admin user, fr: Ton utilisateur admin }
    hint:
      en: Your everyday account on the server, with sudo. Lowercase, no spaces.
      fr: Ton compte de tous les jours sur le serveur, avec sudo. Minuscules, sans espace.
    impact:
      en: After hardening, root and the provider's default account can no longer log in. This account is your only way in besides the OVH console.
      fr: "Après durcissement, root et le compte par défaut de l'hébergeur ne peuvent plus se connecter : ce compte est ta seule porte, en dehors de la console OVH."
  - key: SSH_PUBKEY
    kind: sshkey
    group: server
    required: true
    label: { en: Your server's public key, fr: Clé publique du serveur }
    hint:
      en: "The whole line of ~/.ssh/id_ed25519_vps.pub, the key made for this server on page 2 (pbcopy < ~/.ssh/id_ed25519_vps.pub, then paste here)."
      fr: "La ligne entière de ~/.ssh/id_ed25519_vps.pub, la clé créée pour ce serveur à la page 2 (pbcopy < ~/.ssh/id_ed25519_vps.pub, puis colle ici)."
    impact:
      en: Written as is into your account's authorized_keys on page 3. Once passwords are refused, this key is your only way in besides the OVH console.
      fr: "Écrite telle quelle dans le authorized_keys de ton compte à la page 3. Une fois les mots de passe refusés, cette clé est ta seule porte, en dehors de la console OVH."
  - key: SSH_PORT
    kind: port
    group: server
    default: "1234"
    label: { en: SSH port, fr: Port SSH }
    hint:
      en: The port SSH listens on after hardening. Anything but 22 removes most automated noise.
      fr: Le port sur lequel SSH écoute après durcissement. Tout sauf 22 supprime l'essentiel du bruit automatisé.
    impact:
      en: Reused by the firewall, fail2ban, your ~/.ssh/config and the deploy script. Open it in the firewall before restarting SSH, or you lock yourself out.
      fr: Réutilisé par le pare-feu, fail2ban, ton ~/.ssh/config et le script de déploiement. Ouvre-le dans le pare-feu avant de redémarrer SSH, sinon tu te verrouilles dehors.
  - key: DEPLOY_USER
    kind: user
    group: server
    default: deploy
    label: { en: Deploy user, fr: Utilisateur de déploiement }
    hint:
      en: A second account with no sudo, used only to upload the site. It owns the web root and nothing else.
      fr: Un second compte sans sudo, qui sert seulement à envoyer le site. Il possède la racine web et rien d'autre.
    impact:
      en: It logs in with its own key (~/.ssh/id_ed25519_deploy on your laptop), separate from your admin key. A leaked deploy key can change the site, not the server.
      fr: "Il se connecte avec sa propre clé (~/.ssh/id_ed25519_deploy sur ton portable), distincte de ta clé admin : une clé de déploiement volée peut changer le site, pas le serveur."
  - key: DOMAIN
    kind: domain
    group: site
    default: example.com
    label: { en: Domain, fr: Domaine }
    hint:
      en: The apex domain the site answers on, without www and without https://.
      fr: Le domaine racine sur lequel répond le site, sans www et sans https://.
    impact:
      en: Used in DNS, in the Caddyfile (which requests the certificate for it) and in every check. www redirects to it.
      fr: Utilisé dans le DNS, dans le Caddyfile (qui demande le certificat pour lui) et dans chaque vérification. www redirige vers lui.
  - key: WEB_ROOT
    kind: path
    group: site
    default: /var/www/example.com
    label: { en: Web root, fr: Racine web }
    hint:
      en: The directory on the server that holds releases/ and the current symlink.
      fr: Le répertoire du serveur qui contient releases/ et le lien symbolique current.
    impact:
      en: Caddy serves WEB_ROOT/current; the deploy script writes into WEB_ROOT/releases. Both must agree.
      fr: Caddy sert WEB_ROOT/current ; le script de déploiement écrit dans WEB_ROOT/releases. Les deux doivent être d'accord.
  - key: ADMIN_EMAIL
    kind: email
    group: site
    required: true
    label: { en: Admin email, fr: Email admin }
    hint:
      en: An address you read. Let's Encrypt, DMARC reports and the uptime monitor write to it.
      fr: Une adresse que tu lis. Let's Encrypt, les rapports DMARC et la surveillance de disponibilité y écrivent.
    impact:
      en: If nobody reads it, you learn about an expiring certificate or a down site from your visitors.
      fr: Si personne ne la lit, tu apprends l'expiration d'un certificat ou la panne du site par tes visiteurs.
  - key: LOCAL_DIR
    kind: path
    group: laptop
    default: ~/sites/example.com/src
    label: { en: Project folder, fr: Dossier du projet }
    hint:
      en: Where the Next.js project lives on your laptop (the folder with package.json). No spaces in the path.
      fr: L'emplacement du projet Next.js sur ton portable (le dossier qui contient package.json). Pas d'espace dans le chemin.
    impact:
      en: Every local command starts with cd into it. The deploy script is written there.
      fr: Chaque commande locale commence par un cd dedans. Le script de déploiement y est écrit.
````

````yaml title="content/ovh-vps-static-site/secure-the-server/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.

title:
  en: Secure the server in the first hour
  fr: Sécuriser le serveur dans la première heure
summary:
  en: >-
    From OVH's default `debian` login to your own admin account with a key and a sudo password,
    SSH on its own port with passwords and root refused, a firewall, bans on brute force, and
    security updates that install themselves. Every lock is tested before the old door closes.
  fr: >-
    Du compte `debian` livré par OVH à ton propre compte admin avec une clé et un mot de passe
    sudo, SSH sur son propre port sans mot de passe ni root, un pare-feu, le bannissement du brute
    force, et des mises à jour de sécurité qui s'installent seules. Chaque verrou est testé avant
    de fermer l'ancienne porte.
difficulty: intermediate
tags: [ssh, ufw, fail2ban, unattended-upgrades, debian, ovh, security]
authors: [thudal]
created: 2026-09-26
minutes: 30
validated: Debian 13 (trixie) on OVHcloud VPS · same on Debian 12
status: draft             # not yet run end to end by its author

groups:
  - id: upkeep
    label: { en: Clock and updates, fr: Horloge et mises à jour }
    desc: { en: Time zone and when the server may reboot on its own., fr: Fuseau horaire et heure à laquelle le serveur peut redémarrer seul. }

vars:
  - key: USER_PASSWORD
    kind: secret
    group: server
    default: ""
    label: { en: Sudo password, fr: Mot de passe sudo }
    hint:
      en: The password of your admin user, asked by sudo and by the OVH KVM console. Generate it and store it in your password manager.
      fr: Le mot de passe de ton utilisateur admin, demandé par sudo et par la console KVM d'OVH. Génère-le et range-le dans ton gestionnaire de mots de passe.
    impact:
      en: Never used for SSH (keys only). Once the debian account is gone, it is the only way to use sudo and to log in on the KVM console. Letters and digits only save you from keyboard-layout surprises on the console.
      fr: "Jamais utilisé pour SSH (clés uniquement). Une fois le compte debian retiré, c'est le seul moyen d'utiliser sudo et de te connecter sur la console KVM. Lettres et chiffres uniquement : ça t'évite les surprises de disposition de clavier sur la console."
  - key: SERVER_NAME
    kind: hostname
    group: server
    default: web1
    label: { en: Server name, fr: Nom du serveur }
    hint:
      en: The short host name of the VPS (no dots), shown in your prompt and in logs. Replaces OVH's vps-xxxxxxxx name.
      fr: Le nom court du VPS (sans point), affiché dans ton invite et dans les journaux. Remplace le nom vps-xxxxxxxx d'OVH.
    impact:
      en: Cosmetic for the network, but it goes into /etc/hosts; if the two disagree, every sudo prints "unable to resolve host".
      fr: Cosmétique pour le réseau, mais il va dans /etc/hosts ; si les deux divergent, chaque sudo affiche « unable to resolve host ».
  - key: TIMEZONE
    kind: text
    group: upkeep
    default: Europe/Paris
    label: { en: Time zone, fr: Fuseau horaire }
    hint:
      en: An IANA zone name, as listed by timedatectl list-timezones.
      fr: Un nom de zone IANA, tel que listé par timedatectl list-timezones.
    impact:
      en: Sets the clock shown in logs and the meaning of the automatic reboot time. A name that does not exist is refused by timedatectl.
      fr: Règle l'heure affichée dans les journaux et le sens de l'heure de redémarrage automatique. Un nom inexistant est refusé par timedatectl.
  - key: REBOOT_TIME
    kind: text
    group: upkeep
    default: "04:30"
    when: { flag: AUTO_REBOOT }
    label: { en: Automatic reboot time, fr: Heure de redémarrage automatique }
    hint:
      en: HH:MM in the server's time zone. The server reboots at this time only when an installed update requires it.
      fr: HH:MM dans le fuseau du serveur. Le serveur redémarre à cette heure uniquement quand une mise à jour installée l'exige.
    impact:
      en: The site is down for about a minute during the reboot. Pick the hour with the fewest visitors.
      fr: Le site est coupé environ une minute pendant le redémarrage. Choisis l'heure où il y a le moins de visiteurs.
  - key: MY_IP
    kind: ip
    group: server
    label: { en: Your own public IP, fr: Ton IP publique }
    when: { flag: FAIL2BAN }
    hint:
      en: The address you connect from, as the server sees it (curl -s https://ifconfig.me). Only needed to unban or whitelist yourself in fail2ban.
      fr: L'adresse depuis laquelle tu te connectes, vue par le serveur (curl -s https://ifconfig.me). Utile seulement pour te débannir ou t'exclure de fail2ban.

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
  - key: AUTO_REBOOT
    type: boolean
    label: { en: Reboot automatically after security updates, fr: Redémarrer automatiquement après les mises à jour de sécurité }
    hint:
      en: When an update (a new kernel, usually) needs a reboot, the server reboots at the time you choose instead of waiting for you.
      fr: Quand une mise à jour (un nouveau noyau, en général) exige un redémarrage, le serveur redémarre à l'heure choisie au lieu de t'attendre.
    default: true
  - key: REMOVE_DEBIAN_USER
    type: boolean
    label: { en: Delete OVH's default debian account, fr: Supprimer le compte debian livré par OVH }
    hint:
      en: On, the account and its home are deleted once yours works. Off, the account is locked and kept.
      fr: Activé, le compte et son dossier sont supprimés une fois le tien fonctionnel. Désactivé, le compte est verrouillé et conservé.
    default: true
````

````mdx title="content/ovh-vps-static-site/secure-the-server/page-en.mdx"
{/* First pass — to be validated against https://docs.ovhcloud.com/en/guides/bare-metal-cloud/virtual-private-servers/secure-your-vps, man sshd_config (OpenSSH, Debian 13) and https://wiki.debian.org/UnattendedUpgrades before publishing. */}

The VPS answers on port 22 to the whole internet, as `debian`, with password-less sudo. This page moves you to your own account, puts SSH on its own port with keys only, turns on a firewall<When flag="FAIL2BAN">, bans brute force</When> and makes security updates install themselves. The order matters: every new door is tested from a second terminal before the old one closes.

<Run>

Three parts. **Part 1** runs on the server as `debian` right after delivery (key login working, sudo without password as OVH delivers it, your sudo password filled in the values panel): paste every fence before Part 2 into one file with `${EDITOR} secure.sh`, run `sudo bash secure.sh`, then `shred -u secure.sh`, since it contains your password. **Part 2** runs on your laptop: it adds the `vps` shortcut with the server key and tests it. `authorized_keys` is written from the server's public key in the values panel; the script stops if it is empty or invalid. **Part 3** runs on the server as <V name="USERNAME" /> from `ssh vps`, saved as `finish.sh` and run with `sudo bash finish.sh` (sudo asks your password once).

```bash on="server" as="debian"
#!/usr/bin/env bash
set -euo pipefail
# Secure the server, part 1 — ${SERVER_NAME} (${SERVER_IP}). As debian: sudo bash secure.sh
[ "$(id -u)" -eq 0 ] || { echo "Run it with: sudo bash secure.sh"; exit 1; }
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait >/dev/null 2>&1 || true
IFS= read -r PASSWORD_LINE <<'EOF'
${USERNAME}:${USER_PASSWORD}
EOF
case "$PASSWORD_LINE" in "${USERNAME}:"|*USER_PASSWORD*) echo "Fill in the sudo password in the values panel first."; exit 1;; esac
for i in $(seq 30); do apt-get -o DPkg::Lock::Timeout=300 update && break; sleep 10; done
apt-get -o DPkg::Lock::Timeout=300 -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade
hostnamectl set-hostname ${SERVER_NAME}
[ ! -d /etc/cloud/cloud.cfg.d ] || printf 'preserve_hostname: true\nmanage_etc_hosts: false\n' > /etc/cloud/cloud.cfg.d/99-hostname.cfg
sed -i '/^127\.0\.1\.1[[:space:]]/d' /etc/hosts
echo "127.0.1.1 ${SERVER_NAME}" >> /etc/hosts
timedatectl set-timezone ${TIMEZONE}
id ${USERNAME} >/dev/null 2>&1 || adduser --disabled-password --gecos "" ${USERNAME}
printf '%s\n' "$PASSWORD_LINE" | chpasswd
groupadd -f sshusers && usermod -aG sudo,sshusers ${USERNAME}
install -d -m 700 -o ${USERNAME} -g ${USERNAME} /home/${USERNAME}/.ssh
printf '%s\n' '${SSH_PUBKEY}' > /home/${USERNAME}/.ssh/authorized_keys
chown ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh/authorized_keys && chmod 600 /home/${USERNAME}/.ssh/authorized_keys
ssh-keygen -lf /home/${USERNAME}/.ssh/authorized_keys | grep -q '(ED25519)' || { echo "authorized_keys is empty or invalid: fill in the server's public key in the values panel."; exit 1; }
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowGroups sshusers
EOF
sshd -t
[ "$(sshd -T | grep -c '^port ')" -eq 1 ] || { echo "Another file sets a Port: grep -rn ^Port /etc/ssh"; exit 1; }
# Firewall: the new port AND 22 stay open until part 3
apt-get -o DPkg::Lock::Timeout=300 install -y ufw unattended-upgrades apt-listchanges
ufw default deny incoming && ufw default allow outgoing
ufw allow ${SSH_PORT}/tcp && ufw allow 22/tcp
ufw --force enable
printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > /etc/apt/apt.conf.d/20auto-upgrades
# Switch SSH to the new port. This session stays open.
if systemctl is-active --quiet ssh.socket; then systemctl daemon-reload; systemctl restart ssh.socket; else systemctl restart ssh; fi
ss -Htln "sport = :${SSH_PORT}" | grep . >/dev/null || { echo "sshd is not listening on ${SSH_PORT}. Keep this session open."; exit 1; }
ip -6 addr show scope global | grep -F '${SERVER_IPV6}' >/dev/null || echo "Note: ${SERVER_IPV6} is not configured, see the IPv6 step."
```

<When flag="AUTO_REBOOT">

```bash on="server" as="debian"
printf 'Unattended-Upgrade::Automatic-Reboot "true";\nUnattended-Upgrade::Automatic-Reboot-Time "${REBOOT_TIME}";\n' > /etc/apt/apt.conf.d/52unattended-upgrades-local
```

</When>

<When flag="FAIL2BAN">

```bash on="server" as="debian"
apt-get -o DPkg::Lock::Timeout=300 install -y fail2ban python3-systemd
printf '[DEFAULT]\nbantime.increment = true\n\n[sshd]\nenabled = true\nport = ${SSH_PORT}\nbackend = systemd\nmaxretry = 3\nfindtime = 10m\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable fail2ban && systemctl restart fail2ban
```

</When>

Part 2, on your laptop: paste it into a **new** terminal. Answer `yes` to the host key question: same key, new port.

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host vps$' ~/.ssh/config || printf '\nHost vps\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ~/.ssh/id_ed25519_vps\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh vps echo connected
```

Part 3, only once `connected` came back: `exit` the `debian` session, `ssh vps`, and put every fence from here on into `finish.sh`.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Secure the server, part 3 — as ${USERNAME} over ssh vps: sudo bash finish.sh
[ "$(printenv SUDO_USER || true)" = "${USERNAME}" ] || { echo "Run it as ${USERNAME} (ssh vps) with: sudo bash finish.sh"; exit 1; }
ufw delete allow 22/tcp || true
rm -f /etc/sudoers.d/90-cloud-init-users
```

<When flag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
if id debian >/dev/null 2>&1; then loginctl terminate-user debian || true; sleep 2; pkill -KILL -u debian || true; userdel -r debian || true; fi
! id debian >/dev/null 2>&1 || { echo "debian still exists: delete it by hand with userdel -r debian"; exit 1; }
rm -rf /home/debian
```

</When>

<When notFlag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
! id debian >/dev/null 2>&1 || usermod -L -e 1 debian
```

</When>

```bash on="server" as="${USERNAME}"
[ ! -f /var/run/reboot-required ] || { echo "Rebooting to load the updates. Reconnect with: ssh vps"; systemctl reboot; }
```

<Warn>Do not close the part 1 session until `ssh vps echo connected` works. If it fails, that open session is how you fix it: see "If the new connection fails" in the firewall step. The snapshot at the end is done by hand in the control panel.</Warn>

</Run>

## Before you start

Check that the delivered login works and that `debian` has sudo without a password, then log in as `debian` in a first terminal and stay logged in: a second terminal on your laptop tests each change from the outside.

Two terminals, and the badge on each block says which one:

- **Terminal A**, badge *server · debian*: your first session, opened now. It is your lifeline: do not close it until this page tells you to. Every `sudo` command up to the switch-over runs here.
- **Terminal B**, badge *Mac*: a terminal on your laptop, for the tests from outside.

<V name="USERNAME" /> takes over only once the new door is tested (a third session, `ssh vps`). The server key is the one made on page 2, `~/.ssh/id_ed25519_vps`.

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} sudo -n true && echo OK" expect="OK" />

<Warn>This page changes how you get into the server. If a step goes wrong and your open session is gone, the way back in is the **KVM console** in the OVH control panel: the `...` button next to your VPS → **KVM**. It logs in with a password, never a key. Find it now, before starting. A snapshot taken now (a paid option, see the last step) adds a one-click way back to the delivered state.</Warn>

## Update everything

Upgrade, then reboot if Debian left the flag file that says an update needs it (a new kernel, usually), and log in again as `debian`:

```bash on="server" as="debian"
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y vim
[ -f /var/run/reboot-required ] && sudo reboot
```

<Note>If apt says the dpkg lock is held, wait a minute and run it again: the server is finishing its first-boot updates.</Note>

<Guided>An image is built weeks or months before you install it. Updating first means the rest of the page runs on patched software, and the automatic updates set up later start from a clean base.</Guided>

<Deep>`apt upgrade` never removes a package; `apt full-upgrade` may remove one when that is the only way to finish (a conflict, a renamed dependency). Inside a stable release both usually do the same thing, and `full-upgrade` is what Debian's release notes use. If a blue text screen asks about a modified configuration file, keep the installed version (the default answer): the drop-ins this page writes live in separate files for exactly that reason.</Deep>

## Name and clock

Give the server its name, keep cloud-init from putting OVH's name back, and set the time zone. `timedatectl` must then show your zone and `System clock synchronized: yes`.

```bash on="server" as="debian"
sudo hostnamectl set-hostname ${SERVER_NAME}
printf 'preserve_hostname: true\nmanage_etc_hosts: false\n' | sudo tee /etc/cloud/cloud.cfg.d/99-hostname.cfg
sudo sed -i '/^127\.0\.1\.1[[:space:]]/d' /etc/hosts
echo "127.0.1.1 ${SERVER_NAME}" | sudo tee -a /etc/hosts
sudo timedatectl set-timezone ${TIMEZONE}
timedatectl
```

<Guided>The name shows in your prompt and in every log line, which helps the day you have two servers. The time zone makes log times match your watch<When flag="AUTO_REBOOT"> and gives the automatic reboot time its meaning</When>. The clock itself is kept right by an NTP client (`systemd-timesyncd`); TLS certificates and log correlation depend on it.</Guided>

<Deep>`/etc/hosts` maps the name to `127.0.1.1`, the Debian convention for a machine without a fixed name in DNS; without it, `sudo` warns "unable to resolve host" on every call, and one such warning while these lines run is expected. cloud-init, the first-boot tool of OVH's image, can rewrite both the hostname and `/etc/hosts` from OVH's metadata at boot. The drop-in in `/etc/cloud/cloud.cfg.d/` tells it to leave them alone; files there are read in lexical order and later values win, hence `99-`. If `timedatectl` says `NTP service: inactive` and the clock is not synchronized, install `systemd-timesyncd`.</Deep>

<Note>Whether OVH's image sets `manage_etc_hosts: true` varies between image versions. See what it does with `grep -rn 'hostname\|etc_hosts' /etc/cloud/cloud.cfg /etc/cloud/cloud.cfg.d/`, and confirm after the next reboot that `hostname` still prints <V name="SERVER_NAME" />.</Note>

## Your own user

Create <V name="USERNAME" />, in terminal A. `adduser` asks for the password twice: type the one saved in your password manager. Paste this command alone:

```bash on="server" as="debian" interactive
sudo adduser --gecos "" ${USERNAME}
```

Then put it in `sudo` and `sshusers`, and write **your** public key (the value from the panel, not a copy of `debian`'s file) into its `authorized_keys`:

```bash on="server" as="debian"
sudo groupadd -f sshusers
sudo usermod -aG sudo,sshusers ${USERNAME}
sudo install -d -m 700 -o ${USERNAME} -g ${USERNAME} /home/${USERNAME}/.ssh
echo '${SSH_PUBKEY}' | sudo tee /home/${USERNAME}/.ssh/authorized_keys >/dev/null
sudo chown ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh/authorized_keys
sudo chmod 600 /home/${USERNAME}/.ssh/authorized_keys
```

Check the key before going on: the file must hold a valid Ed25519 key.

<Check cmd="sudo ssh-keygen -lf /home/${USERNAME}/.ssh/authorized_keys | grep -o '(ED25519)'" expect="(ED25519)" />

<Warn>If this prints nothing or an error, **stop here**: `authorized_keys` is empty or wrong, and the switch-over would lock you out. Fill in the server's public key in the values panel (`pbcopy < ~/.ssh/id_ed25519_vps.pub`) and run the block above again.</Warn>

<Guided>`debian` is an account everyone knows exists on OVH servers, with sudo that never asks anything. Your own account has sudo **with a password**: a stolen key alone gives a shell, not root. The `sshusers` group becomes the list of who may log in over SSH, in the next step.</Guided>

<Deep>The key is written from the panel rather than copied from `/home/debian/.ssh/authorized_keys`: if OVH received no key at order time, that file is empty, and copying it gives an account nobody can log in to once passwords are refused. SSH silently ignores an `authorized_keys` that others can write, hence `chmod 600`; the wrong mode is the classic "my key does not work". `AllowGroups sshusers` is a whitelist: accounts created later by packages, or by you, cannot log in over SSH until you add them to the group (the deploy page adds <V name="DEPLOY_USER" />). On Debian 13, `adduser` may print that `--gecos` is deprecated in favour of `--comment`; both work, and `--gecos` also works on Debian 12.</Deep>

From terminal B (Mac), log in as the new user with the key only (still on port 22) and read its groups. The options forbid the password: if this passes, the key works.

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes ${USERNAME}@${SERVER_IP} 'id -nG | grep -qw sudo && id -nG | grep -qw sshusers && echo groups OK'" expect="groups OK" />

## Harden SSH

Write the drop-in: copy the command and paste it in terminal A.

<Annotated>

```ini file="/etc/ssh/sshd_config.d/00-hardening.conf" on="server" as="debian"
Port ${SSH_PORT}                       # (1)
PermitRootLogin no                     # (2)
PasswordAuthentication no              # (3)
KbdInteractiveAuthentication no        # (4)
AllowGroups sshusers                   # (5)
```

1. Moving off 22 does not make SSH stronger, but it removes almost all automated attempts, which keeps logs readable.
2. Root cannot log in, even with a key. Root is reached through `sudo`, which asks your password and logs the command.
3. Keys only. A password can be guessed; an Ed25519 key cannot.
4. Closes the other password door: keyboard-interactive is how PAM asks for a password even with `PasswordAuthentication no`.
5. Only members of `sshusers` may log in. `debian` is not one: from the restart on, it cannot open new sessions.

</Annotated>

<Guided>The name starts with `00-` on purpose. For each setting, sshd keeps **the first value it reads**, and it reads the drop-ins in alphabetical order before the main file. OVH's cloud-init may leave `50-cloud-init.conf` with `PasswordAuthentication yes`: a file named `99-…` would lose to it, `00-…` wins.</Guided>

<Deep>`Include /etc/ssh/sshd_config.d/*.conf` sits at the top of Debian's `/etc/ssh/sshd_config`, before any setting, and the glob expands in lexical order, so the whole ordering is file names. The exception is `Port`: every `Port` line adds a listening port instead of being ignored, so a stray `Port 22` elsewhere would keep 22 open. Do not edit `50-cloud-init.conf` itself; cloud-init may rewrite it, and a file of your own that wins is sturdier. The same logic explains why `sshd -T` is the only source of truth: it prints the configuration sshd would actually run with, after every include.</Deep>

Test the syntax, then read the effective values. `sshd -t` prints nothing when the syntax is fine; the second command must print exactly one `port` line with <V name="SSH_PORT" />, `no` for the three next settings, and `allowgroups sshusers`.

```bash on="server" as="debian"
sudo sshd -t
sudo sshd -T | grep -Ei '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowgroups) '
```

<Warn>Do not restart SSH yet. With the firewall not configured and the drop-in not tested from outside, a restart now can cut you off. The next step opens the port first, then restarts.</Warn>

## Firewall and switch-over

Install `ufw`, refuse everything inbound, and open the new port **and** 22 for now. `ufw enable` warns that it may disrupt SSH connections; answer `y`. 22 stays open so the session you are typing in survives, and so a rollback (delete the drop-in, restart) brings you back to a port the firewall accepts.

```bash on="server" as="debian"
sudo apt install -y ufw
sudo ufw default deny incoming && sudo ufw default allow outgoing
sudo ufw allow ${SSH_PORT}/tcp && sudo ufw allow 22/tcp
sudo ufw enable
```

Before the restart, give your laptop the `vps` shortcut, with the server key, in terminal B. If `~/.ssh/config` already has a `Host vps` block, do not add a second one: edit it with `${EDITOR} ~/.ssh/config` so it has the same lines (ssh uses the first match).

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host vps$' ~/.ssh/config || printf '\nHost vps\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ~/.ssh/id_ed25519_vps\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
```

<Warn>Now the restart. From here, sshd listens on <V name="SSH_PORT" /> only and `debian` can no longer open new sessions. Keep this terminal open until the test below says `connected`.</Warn>

```bash on="server" as="debian"
sudo systemctl restart ssh
```

In terminal B, test the new door with the shortcut. ssh asks again to confirm the host key: it is the same key, stored under a new name (`[IP]:port`), so answer `yes`.

<Check cmd="ssh -o BatchMode=yes vps echo connected" expect="connected" />

<Details summary="If the new connection fails">

Your first terminal is still logged in: nothing is lost. Look, in this order:

- `sudo sshd -T | grep '^port '`: must say <V name="SSH_PORT" />.
- `sudo ss -tlnp | grep sshd`: sshd must listen on <V name="SSH_PORT" />, for IPv4 (`0.0.0.0`) and IPv6 (`[::]`).
- `sudo ufw status`: <V name="SSH_PORT" />/tcp must be `ALLOW`.
- `systemctl is-active ssh.socket`: if it says `active`, the socket unit holds the port, not sshd; run `sudo systemctl daemon-reload && sudo systemctl restart ssh.socket`. Debian's default is `ssh.service` (Ubuntu uses the socket): confirm with `systemctl is-enabled ssh.socket ssh.service`.
- OVH's **Network Firewall** (control panel → your IP → Network Firewall), if you enabled it: it filters before the packets reach the VPS; add a rule for <V name="SSH_PORT" />.
- To roll back in ten seconds: `sudo rm /etc/ssh/sshd_config.d/00-hardening.conf && sudo systemctl restart ssh`. You are back on port 22, which is still open.
- First session gone too: log in on the **KVM console** as <V name="USERNAME" /> with your password and run the same commands.

</Details>

The new port works: close 22, in terminal A.

```bash on="server" as="debian"
sudo ufw delete allow 22/tcp
```

<Deep>ufw accepts packets of established connections before it looks at port rules, which is why deleting the 22 rule does not cut the session you are typing in. On Debian 13, ufw drives the `iptables` command, which is the nftables variant (`iptables-nft`): the rules end up in nftables, visible with `sudo nft list ruleset`. IPv6 is filtered with the same rules because `/etc/default/ufw` has `IPV6=yes` by default; each rule shows twice in `ufw status`, once with `(v6)`. Restarting `ssh.service` does not end open sessions: Debian's unit uses `KillMode=process`, which only stops the listener.</Deep>

## Retire the debian account

<Warn>Do this only when both are true: `ssh vps` logs you in, and `sudo true` in that session accepts your password. After this step the KVM console accepts only <V name="USERNAME" /> with that password, so check it is in your password manager.</Warn>

Open your own session from the Mac and check that sudo accepts your password:

```bash on="mac"
ssh vps
```

```bash on="server" as="${USERNAME}" interactive
sudo true && echo SUDO OK
```

Once `SUDO OK` came back, close terminal A (`exit`). Then, in the `ssh vps` session. `userdel` prints `mail spool (/var/mail/debian) not found`: that is normal.

<When flag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
sudo pkill -KILL -u debian
sudo userdel -r debian
sudo rm -f /etc/sudoers.d/90-cloud-init-users
```

</When>

<When notFlag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
sudo usermod -L -e 1 debian
sudo rm -f /etc/sudoers.d/90-cloud-init-users
```

<Guided>`-L` locks the password, `-e 1` sets the account's expiry date to 2 January 1970: an expired account is refused everywhere, keys included. It is not in `sshusers` anyway. Undo with `sudo usermod -U -e '' debian`.</Guided>

</When>

From your laptop, check that `debian` is refused; the answer must end with `Permission denied (publickey).`

```bash on="mac"
ssh -p ${SSH_PORT} -o BatchMode=yes debian@${SERVER_IP} true
```

<Deep>`userdel -r` also removes the home directory; it may warn that the mail spool `/var/mail/debian` was not found, which is harmless. It is used rather than `deluser --remove-home`, which needs the full `perl` package that a minimal image may lack. The sudoers file removed here is the one cloud-init wrote to give `debian` password-less sudo; without it, a leftover or recreated `debian` gets no sudo. cloud-init creates its default user once per instance, so a reboot does not bring it back. OVH's own guides and support messages assume the `debian` user exists: read their commands with <V name="USERNAME" /> instead. If you ever lose both SSH and the password, **rescue mode** in the control panel boots a separate system with your disk mounted, where you can fix files or reset the password.</Deep>

<When flag="FAIL2BAN">

## Ban brute force

With passwords refused, guessing is useless, but bots still knock. fail2ban reads the SSH log and bans an address after three failures.

```bash on="server" as="${USERNAME}"
sudo apt install -y fail2ban python3-systemd
```

Then the jail, in one file:

```ini file="/etc/fail2ban/jail.d/sshd.local" on="server" as="${USERNAME}"
[DEFAULT]
bantime.increment = true

[sshd]
enabled = true
port = ${SSH_PORT}
backend = systemd
maxretry = 3
findtime = 10m
bantime = 1h
```

```bash on="server" as="${USERNAME}"
sudo systemctl enable fail2ban && sudo systemctl restart fail2ban
```

<Check cmd="sudo fail2ban-client status sshd | head -1" expect="Status for the jail: sshd" />

<Guided>Three failures within ten minutes from one address earn a one-hour ban; `bantime.increment` makes each new ban of the same address longer. `backend = systemd` matters on Debian 12 and 13: there is no `/var/log/auth.log` by default, the SSH log lives in journald only, and with the file-based default the jail can fail to start.</Guided>

<Deep>`python3-systemd` is the library fail2ban uses to read the journal. To see it work, run the refused `debian` login from the previous step again, then `sudo fail2ban-client status sshd`: `Total failed` goes up by one. To test the filter against what is already in the journal: `sudo fail2ban-regex systemd-journal sshd`. On Debian 13, OpenSSH 10 logs from processes named `sshd-session`; if the regex test finds the lines with `journalctl -u ssh` but matches none, the filter's journal match predates that change (a point to confirm with that regex test after a refused login).</Deep>

<Details summary="If you ban yourself">

Three typos from your own address and you are out for an hour. To never ban a fixed home address, add `ignoreip = 127.0.0.1/8 ::1` followed by your address (<V name="MY_IP" />) under `[DEFAULT]` with `sudo ${EDITOR} /etc/fail2ban/jail.d/sshd.local`, and restart fail2ban. To unban, from another connection (phone hotspot) or the KVM console:

```bash on="server" as="${USERNAME}"
sudo fail2ban-client set sshd unbanip ${MY_IP}
```

</Details>

</When>

## Automatic security updates

```bash on="server" as="${USERNAME}"
sudo apt install -y unattended-upgrades apt-listchanges
printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades
```

<When flag="AUTO_REBOOT">

Allow a reboot at <V name="REBOOT_TIME" /> when an update needs one:

```text file="/etc/apt/apt.conf.d/52unattended-upgrades-local" on="server" as="${USERNAME}"
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "${REBOOT_TIME}";
```

</When>

<Check cmd="apt-config shell UU APT::Periodic::Unattended-Upgrade" expect="UU='1'" />

<Guided>Every day, apt refreshes the package lists and installs what Debian publishes for your release, security fixes first. <When flag="AUTO_REBOOT">A new kernel only takes effect after a reboot; with the option on, the server does it at <V name="REBOOT_TIME" /> instead of running the old one for weeks.</When><When notFlag="AUTO_REBOOT">A new kernel only takes effect after a reboot, which you now do by hand: `/var/run/reboot-required` exists when one is pending.</When></Guided>

<Deep>`20auto-upgrades` turns the daily job on; it is the file `sudo dpkg-reconfigure -plow unattended-upgrades` writes. What gets installed is set in `/etc/apt/apt.conf.d/50unattended-upgrades`: on Debian, the security archive and the point releases of your stable release. Third-party repositories (Caddy's, on a later page) are not included unless you add their origin. The job runs from `apt-daily-upgrade.timer`, around 06:00 plus a random delay; <When flag="AUTO_REBOOT">the reboot then waits for the next <V name="REBOOT_TIME" />. </When>Logs are in `/var/log/unattended-upgrades/`; a dry run shows what it would do: `sudo unattended-upgrade --dry-run --debug`. Services that still use an old library after an update keep running the old code until restarted; `needrestart` (`sudo apt install needrestart`) lists them after each upgrade, and `sudo needrestart -r l` lists them on demand.</Deep>

## Check IPv6

The next page publishes the IPv6 address in DNS; a dead one sends IPv6 visitors into timeouts. On the server, the global address must be <V name="SERVER_IPV6" /> and must reach the internet:

```bash on="server" as="${USERNAME}"
ip -6 addr show scope global
ping -6 -c 3 2001:4860:4860::8888
```

Note the result: if `ping -6` answers, page 4 publishes the IPv6 address (AAAA record); if not, it publishes none.

<Note>On recent Debian images OVH configures IPv6 by default. If the address is missing, follow OVH's guide "Configure IPv6 on a VPS" on docs.ovhcloud.com: it adds a netplan file, `/etc/netplan/51-cloud-init-ipv6.yaml`.</Note>

## Snapshot the clean state (optional, paid)

<Warn>A snapshot is a paid option: from about 0.36 € TTC per month per VPS. Check the current price on ovhcloud.com/fr/vps before ordering it.</Warn>

In the OVH control panel, open your VPS: on its home tab, the **Snapshot** option offers to order it; once active, take a snapshot from the same place. Without the option, you still have the included automated backup (one day of retention). Menu labels move: OVH's guide "Using snapshots on a VPS" on docs.ovhcloud.com has the current path.

<Guided>This is the server at its best: patched, locked, nothing else installed. If a later page goes wrong, restoring this state takes a few minutes, where rebuilding it takes this whole page again. Take a new snapshot before any risky change; there is only one at a time, and the new one replaces the old.</Guided>

## Done

| What | Before | After this page |
|---|---|---|
| Who logs in | `debian`, sudo without password | <V name="USERNAME" />, key only, sudo with password |
| SSH port | 22 | <V name="SSH_PORT" /> |
| Root, passwords | root off, passwords possibly on (cloud-init) | both refused by your own drop-in |
| Inbound traffic | everything | <V name="SSH_PORT" />/tcp only |
| Brute force | unlimited tries | <When flag="FAIL2BAN">3 tries, then banned</When><When notFlag="FAIL2BAN">passwords refused anyway</When> |
| Security updates | by hand | daily, automatic<When flag="AUTO_REBOOT">, reboot at <V name="REBOOT_TIME" /></When> |

From now on, you connect with `ssh vps`. Next page: **Point the domain at the server (Infomaniak DNS)**. It publishes <V name="SERVER_IP" /> and <V name="SERVER_IPV6" /> in the Infomaniak DNS zone of <V name="DOMAIN" />, so the name reaches this machine before Caddy asks for a certificate.
````

````mdx title="content/ovh-vps-static-site/secure-the-server/page-fr.mdx"
{/* Premier jet — à valider avant publication contre https://docs.ovhcloud.com/en/guides/bare-metal-cloud/virtual-private-servers/secure-your-vps, man sshd_config (OpenSSH, Debian 13) et https://wiki.debian.org/UnattendedUpgrades. */}

Le VPS répond sur le port 22 à tout internet, en `debian`, avec un sudo sans mot de passe. Cette page te fait passer sur ton propre compte, met SSH sur son propre port avec des clés uniquement, allume un pare-feu<When flag="FAIL2BAN">, bannit le brute force</When> et fait s'installer seules les mises à jour de sécurité. L'ordre compte : chaque nouvelle porte est testée depuis un second terminal avant de fermer l'ancienne.

<Run>

Trois parties. La **partie 1** tourne sur le serveur en `debian`, juste après la livraison (connexion par clé qui marche, sudo sans mot de passe tel qu'OVH le livre, ton mot de passe sudo rempli dans le panneau des valeurs) : colle tous les blocs qui précèdent la partie 2 dans un seul fichier avec `${EDITOR} secure.sh`, lance `sudo bash secure.sh`, puis `shred -u secure.sh`, parce qu'il contient ton mot de passe. La **partie 2** tourne sur ton portable : elle ajoute le raccourci `vps` avec la clé du serveur et le teste. `authorized_keys` est écrit à partir de la clé publique du serveur du panneau des valeurs ; le script s'arrête s'il est vide ou invalide. La **partie 3** tourne sur le serveur en <V name="USERNAME" /> depuis `ssh vps`, enregistrée dans `finish.sh` et lancée avec `sudo bash finish.sh` (sudo demande ton mot de passe une fois).

```bash on="server" as="debian"
#!/usr/bin/env bash
set -euo pipefail
# Sécuriser le serveur, partie 1 — ${SERVER_NAME} (${SERVER_IP}). En debian : sudo bash secure.sh
[ "$(id -u)" -eq 0 ] || { echo "Lance-le avec : sudo bash secure.sh"; exit 1; }
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait >/dev/null 2>&1 || true
IFS= read -r PASSWORD_LINE <<'EOF'
${USERNAME}:${USER_PASSWORD}
EOF
case "$PASSWORD_LINE" in "${USERNAME}:"|*USER_PASSWORD*) echo "Remplis d'abord le mot de passe sudo dans le panneau des valeurs."; exit 1;; esac
for i in $(seq 30); do apt-get -o DPkg::Lock::Timeout=300 update && break; sleep 10; done
apt-get -o DPkg::Lock::Timeout=300 -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade
hostnamectl set-hostname ${SERVER_NAME}
[ ! -d /etc/cloud/cloud.cfg.d ] || printf 'preserve_hostname: true\nmanage_etc_hosts: false\n' > /etc/cloud/cloud.cfg.d/99-hostname.cfg
sed -i '/^127\.0\.1\.1[[:space:]]/d' /etc/hosts
echo "127.0.1.1 ${SERVER_NAME}" >> /etc/hosts
timedatectl set-timezone ${TIMEZONE}
id ${USERNAME} >/dev/null 2>&1 || adduser --disabled-password --gecos "" ${USERNAME}
printf '%s\n' "$PASSWORD_LINE" | chpasswd
groupadd -f sshusers && usermod -aG sudo,sshusers ${USERNAME}
install -d -m 700 -o ${USERNAME} -g ${USERNAME} /home/${USERNAME}/.ssh
printf '%s\n' '${SSH_PUBKEY}' > /home/${USERNAME}/.ssh/authorized_keys
chown ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh/authorized_keys && chmod 600 /home/${USERNAME}/.ssh/authorized_keys
ssh-keygen -lf /home/${USERNAME}/.ssh/authorized_keys | grep -q '(ED25519)' || { echo "authorized_keys is empty or invalid: fill in the server's public key in the values panel."; exit 1; }
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowGroups sshusers
EOF
sshd -t
[ "$(sshd -T | grep -c '^port ')" -eq 1 ] || { echo "Un autre fichier définit un Port : grep -rn ^Port /etc/ssh"; exit 1; }
# Pare-feu : le nouveau port ET le 22 restent ouverts jusqu'à la partie 3
apt-get -o DPkg::Lock::Timeout=300 install -y ufw unattended-upgrades apt-listchanges
ufw default deny incoming && ufw default allow outgoing
ufw allow ${SSH_PORT}/tcp && ufw allow 22/tcp
ufw --force enable
printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > /etc/apt/apt.conf.d/20auto-upgrades
# Bascule SSH sur le nouveau port. Cette session reste ouverte.
if systemctl is-active --quiet ssh.socket; then systemctl daemon-reload; systemctl restart ssh.socket; else systemctl restart ssh; fi
ss -Htln "sport = :${SSH_PORT}" | grep . >/dev/null || { echo "sshd n'écoute pas sur ${SSH_PORT}. Garde cette session ouverte."; exit 1; }
ip -6 addr show scope global | grep -F '${SERVER_IPV6}' >/dev/null || echo "Attention : ${SERVER_IPV6} n'est pas configurée, vois l'étape IPv6."
```

<When flag="AUTO_REBOOT">

```bash on="server" as="debian"
printf 'Unattended-Upgrade::Automatic-Reboot "true";\nUnattended-Upgrade::Automatic-Reboot-Time "${REBOOT_TIME}";\n' > /etc/apt/apt.conf.d/52unattended-upgrades-local
```

</When>

<When flag="FAIL2BAN">

```bash on="server" as="debian"
apt-get -o DPkg::Lock::Timeout=300 install -y fail2ban python3-systemd
printf '[DEFAULT]\nbantime.increment = true\n\n[sshd]\nenabled = true\nport = ${SSH_PORT}\nbackend = systemd\nmaxretry = 3\nfindtime = 10m\nbantime = 1h\n' > /etc/fail2ban/jail.d/sshd.local
systemctl enable fail2ban && systemctl restart fail2ban
```

</When>

Partie 2, sur ton portable : colle-la dans un **nouveau** terminal. Réponds `yes` à la question sur la clé d'hôte : même clé, nouveau port.

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host vps$' ~/.ssh/config || printf '\nHost vps\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ~/.ssh/id_ed25519_vps\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh vps echo connected
```

Partie 3, seulement une fois `connected` revenu : quitte la session `debian` (`exit`), fais `ssh vps`, et mets tous les blocs à partir d'ici dans `finish.sh`.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Sécuriser le serveur, partie 3 — en ${USERNAME} via ssh vps : sudo bash finish.sh
[ "$(printenv SUDO_USER || true)" = "${USERNAME}" ] || { echo "Lance-le en ${USERNAME} (ssh vps) avec : sudo bash finish.sh"; exit 1; }
ufw delete allow 22/tcp || true
rm -f /etc/sudoers.d/90-cloud-init-users
```

<When flag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
if id debian >/dev/null 2>&1; then loginctl terminate-user debian || true; sleep 2; pkill -KILL -u debian || true; userdel -r debian || true; fi
! id debian >/dev/null 2>&1 || { echo "debian existe encore : supprime-le à la main avec userdel -r debian"; exit 1; }
rm -rf /home/debian
```

</When>

<When notFlag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
! id debian >/dev/null 2>&1 || usermod -L -e 1 debian
```

</When>

```bash on="server" as="${USERNAME}"
[ ! -f /var/run/reboot-required ] || { echo "Redémarrage pour charger les mises à jour. Reconnecte-toi avec : ssh vps"; systemctl reboot; }
```

<Warn>Ne ferme pas la session de la partie 1 tant que `ssh vps echo connected` ne marche pas. Si ça échoue, c'est cette session encore ouverte qui te permet de réparer : vois « Si la nouvelle connexion échoue » à l'étape pare-feu. Le snapshot de la fin se fait à la main dans l'espace client.</Warn>

</Run>

## Avant de commencer

Vérifie que la connexion livrée marche et que `debian` a sudo sans mot de passe, puis connecte-toi en `debian` dans un premier terminal et restes-y : un second terminal sur ton portable teste chaque changement depuis l'extérieur.

Deux terminaux, et le badge de chaque bloc dit lequel :

- **Terminal A**, badge *serveur · debian* : ta première session, ouverte maintenant. C'est ta bouée : ne la ferme pas avant que la page te le dise. Toutes les commandes `sudo` jusqu'à la bascule s'y tapent.
- **Terminal B**, badge *Mac* : un terminal sur ton portable, pour les tests depuis l'extérieur.

<V name="USERNAME" /> ne prend la main qu'une fois la nouvelle porte testée (une troisième session, `ssh vps`). La clé du serveur est celle créée à la page 2, `~/.ssh/id_ed25519_vps`.

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} sudo -n true && echo OK" expect="OK" />

<Warn>Cette page change la façon dont tu entres sur le serveur. Si une étape rate et que ta session ouverte est perdue, le chemin du retour est la **console KVM** de l'espace client OVH : le bouton `...` à côté de ton VPS → **KVM**. Elle se connecte avec un mot de passe, jamais avec une clé. Trouve-la maintenant, avant de commencer. Un snapshot pris maintenant (option payante, vois la dernière étape) ajoute un retour en un clic à l'état de livraison.</Warn>

## Tout mettre à jour

Mets à jour, puis redémarre si Debian a laissé le fichier témoin qui dit qu'une mise à jour l'exige (un nouveau noyau, en général), et reconnecte-toi en `debian` :

```bash on="server" as="debian"
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y vim
[ -f /var/run/reboot-required ] && sudo reboot
```

<Note>Si apt dit que le verrou dpkg est pris, attends une minute et relance : le serveur termine ses mises à jour de premier démarrage.</Note>

<Guided>Une image est construite des semaines ou des mois avant que tu l'installes. Mettre à jour d'abord, c'est faire tourner le reste de la page sur des logiciels corrigés, et donner aux mises à jour automatiques installées plus loin une base propre.</Guided>

<Deep>`apt upgrade` ne supprime jamais de paquet ; `apt full-upgrade` peut en supprimer un quand c'est le seul moyen d'aller au bout (un conflit, une dépendance renommée). Au sein d'une version stable, les deux font en général la même chose, et `full-upgrade` est ce qu'utilisent les notes de version de Debian. Si un écran de texte bleu t'interroge sur un fichier de configuration modifié, garde la version installée (la réponse par défaut) : les fichiers complémentaires de cette page vivent à part justement pour ça.</Deep>

## Nom et horloge

Donne son nom au serveur, empêche cloud-init de remettre celui d'OVH, et règle le fuseau horaire. `timedatectl` doit ensuite afficher ton fuseau et `System clock synchronized: yes`.

```bash on="server" as="debian"
sudo hostnamectl set-hostname ${SERVER_NAME}
printf 'preserve_hostname: true\nmanage_etc_hosts: false\n' | sudo tee /etc/cloud/cloud.cfg.d/99-hostname.cfg
sudo sed -i '/^127\.0\.1\.1[[:space:]]/d' /etc/hosts
echo "127.0.1.1 ${SERVER_NAME}" | sudo tee -a /etc/hosts
sudo timedatectl set-timezone ${TIMEZONE}
timedatectl
```

<Guided>Le nom s'affiche dans ton invite et sur chaque ligne de journal, ce qui aide le jour où tu as deux serveurs. Le fuseau fait coller l'heure des journaux à ta montre<When flag="AUTO_REBOOT"> et donne son sens à l'heure de redémarrage automatique</When>. L'horloge elle-même est tenue à l'heure par un client NTP (`systemd-timesyncd`) ; les certificats TLS et le recoupement des journaux en dépendent.</Guided>

<Deep>`/etc/hosts` associe le nom à `127.0.1.1`, la convention Debian pour une machine sans nom fixe dans le DNS ; sans cette ligne, `sudo` affiche « unable to resolve host » à chaque appel, et un tel avertissement pendant ces commandes est normal. cloud-init, l'outil de premier démarrage de l'image OVH, peut réécrire le nom d'hôte et `/etc/hosts` au démarrage à partir des métadonnées d'OVH. Le fichier dans `/etc/cloud/cloud.cfg.d/` lui dit de ne pas y toucher ; ces fichiers sont lus dans l'ordre lexical et les dernières valeurs gagnent, d'où `99-`. Si `timedatectl` affiche `NTP service: inactive` et une horloge non synchronisée, installe `systemd-timesyncd`.</Deep>

<Note>Selon la version de l'image, OVH active ou non `manage_etc_hosts: true`. Regarde ce qu'il en est avec `grep -rn 'hostname\|etc_hosts' /etc/cloud/cloud.cfg /etc/cloud/cloud.cfg.d/`, et vérifie après le prochain redémarrage que `hostname` affiche toujours <V name="SERVER_NAME" />.</Note>

## Ton propre utilisateur

Crée <V name="USERNAME" />, dans le terminal A. `adduser` demande le mot de passe deux fois : tape celui rangé dans ton gestionnaire de mots de passe. Colle cette commande seule :

```bash on="server" as="debian" interactive
sudo adduser --gecos "" ${USERNAME}
```

Puis mets-le dans `sudo` et `sshusers`, et écris **ta** clé publique (la valeur du panneau, pas une copie du fichier de `debian`) dans son `authorized_keys` :

```bash on="server" as="debian"
sudo groupadd -f sshusers
sudo usermod -aG sudo,sshusers ${USERNAME}
sudo install -d -m 700 -o ${USERNAME} -g ${USERNAME} /home/${USERNAME}/.ssh
echo '${SSH_PUBKEY}' | sudo tee /home/${USERNAME}/.ssh/authorized_keys >/dev/null
sudo chown ${USERNAME}:${USERNAME} /home/${USERNAME}/.ssh/authorized_keys
sudo chmod 600 /home/${USERNAME}/.ssh/authorized_keys
```

Vérifie la clé avant d'aller plus loin : le fichier doit contenir une clé Ed25519 valide.

<Check cmd="sudo ssh-keygen -lf /home/${USERNAME}/.ssh/authorized_keys | grep -o '(ED25519)'" expect="(ED25519)" />

<Warn>Si ça n'affiche rien ou une erreur, **arrête-toi là** : `authorized_keys` est vide ou faux, et la bascule t'enfermerait dehors. Remplis la clé publique du serveur dans le panneau des valeurs (`pbcopy < ~/.ssh/id_ed25519_vps.pub`) et relance le bloc au-dessus.</Warn>

<Guided>`debian` est un compte dont tout le monde sait qu'il existe sur les serveurs OVH, avec un sudo qui ne demande jamais rien. Ton compte a sudo **avec mot de passe** : une clé volée seule donne un shell, pas root. Le groupe `sshusers` devient, à l'étape suivante, la liste de qui a le droit d'entrer en SSH.</Guided>

<Deep>La clé est écrite depuis le panneau plutôt que copiée depuis `/home/debian/.ssh/authorized_keys` : si OVH n'a reçu aucune clé à la commande, ce fichier est vide, et le copier donne un compte où personne ne peut entrer une fois les mots de passe refusés. SSH ignore en silence un `authorized_keys` modifiable par d'autres, d'où le `chmod 600` ; de mauvais droits sont le classique « ma clé ne marche pas ». `AllowGroups sshusers` est une liste blanche : les comptes créés plus tard par des paquets, ou par toi, ne peuvent pas entrer en SSH tant que tu ne les ajoutes pas au groupe (la page de déploiement y ajoute <V name="DEPLOY_USER" />). Sur Debian 13, `adduser` peut signaler que `--gecos` est déprécié au profit de `--comment` ; les deux marchent, et `--gecos` marche aussi sur Debian 12.</Deep>

Depuis le terminal B (Mac), connecte-toi avec le nouvel utilisateur par la clé seulement (encore sur le port 22) et lis ses groupes. Les options interdisent le mot de passe : si ça passe, la clé marche.

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes ${USERNAME}@${SERVER_IP} 'id -nG | grep -qw sudo && id -nG | grep -qw sshusers && echo groups OK'" expect="groups OK" />

## Durcir SSH

Écris le fichier complémentaire : copie la commande et colle-la dans le terminal A.

<Annotated>

```ini file="/etc/ssh/sshd_config.d/00-hardening.conf" on="server" as="debian"
Port ${SSH_PORT}                       # (1)
PermitRootLogin no                     # (2)
PasswordAuthentication no              # (3)
KbdInteractiveAuthentication no        # (4)
AllowGroups sshusers                   # (5)
```

1. Quitter le 22 ne rend pas SSH plus solide, mais ça supprime presque toutes les tentatives automatisées, ce qui garde les journaux lisibles.
2. Root ne peut pas se connecter, même avec une clé. On atteint root par `sudo`, qui demande ton mot de passe et journalise la commande.
3. Clés uniquement. Un mot de passe se devine ; une clé Ed25519, non.
4. Ferme l'autre porte des mots de passe : keyboard-interactive, c'est ce que PAM utilise pour demander un mot de passe même avec `PasswordAuthentication no`.
5. Seuls les membres de `sshusers` entrent. `debian` n'en fait pas partie : à partir du redémarrage, il ne peut plus ouvrir de session.

</Annotated>

<Guided>Le nom commence par `00-` exprès. Pour chaque réglage, sshd garde **la première valeur qu'il lit**, et il lit les fichiers complémentaires dans l'ordre alphabétique, avant le fichier principal. Le cloud-init d'OVH peut laisser un `50-cloud-init.conf` avec `PasswordAuthentication yes` : un fichier nommé `99-…` perdrait face à lui, `00-…` gagne.</Guided>

<Deep>`Include /etc/ssh/sshd_config.d/*.conf` est en tête du `/etc/ssh/sshd_config` de Debian, avant tout réglage, et le motif se développe dans l'ordre lexical : tout l'ordre tient donc aux noms de fichiers. L'exception, c'est `Port` : chaque ligne `Port` ajoute un port d'écoute au lieu d'être ignorée, donc un `Port 22` égaré ailleurs garderait le 22 ouvert. Ne modifie pas `50-cloud-init.conf` lui-même ; cloud-init peut le réécrire, et un fichier à toi qui gagne est plus solide. C'est aussi pour ça que `sshd -T` est la seule source de vérité : il affiche la configuration avec laquelle sshd tournerait vraiment, une fois tous les fichiers inclus.</Deep>

Teste la syntaxe, puis lis les valeurs effectives. `sshd -t` n'affiche rien quand la syntaxe est bonne ; la seconde commande doit afficher une seule ligne `port`, avec <V name="SSH_PORT" />, `no` pour les trois réglages suivants, et `allowgroups sshusers`.

```bash on="server" as="debian"
sudo sshd -t
sudo sshd -T | grep -Ei '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowgroups) '
```

<Warn>Ne redémarre pas SSH tout de suite. Sans pare-feu configuré et sans test depuis l'extérieur, un redémarrage maintenant peut te couper. L'étape suivante ouvre d'abord le port, puis redémarre.</Warn>

## Pare-feu et bascule

Installe `ufw`, refuse tout en entrée, et ouvre le nouveau port **et** le 22 pour l'instant. `ufw enable` prévient qu'il peut perturber les connexions SSH ; réponds `y`. Le 22 reste ouvert pour que la session dans laquelle tu tapes survive, et pour qu'un retour arrière (supprimer le fichier complémentaire, redémarrer) te ramène sur un port que le pare-feu accepte.

```bash on="server" as="debian"
sudo apt install -y ufw
sudo ufw default deny incoming && sudo ufw default allow outgoing
sudo ufw allow ${SSH_PORT}/tcp && sudo ufw allow 22/tcp
sudo ufw enable
```

Avant le redémarrage, donne à ton portable le raccourci `vps`, avec la clé du serveur, dans le terminal B. Si `~/.ssh/config` contient déjà un bloc `Host vps`, n'en ajoute pas un second : modifie-le avec `${EDITOR} ~/.ssh/config` pour qu'il ait les mêmes lignes (ssh prend la première correspondance).

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host vps$' ~/.ssh/config || printf '\nHost vps\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ~/.ssh/id_ed25519_vps\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
```

<Warn>Maintenant, le redémarrage. À partir d'ici, sshd n'écoute que sur <V name="SSH_PORT" /> et `debian` ne peut plus ouvrir de nouvelle session. Garde ce terminal ouvert tant que le test ci-dessous n'a pas répondu `connected`.</Warn>

```bash on="server" as="debian"
sudo systemctl restart ssh
```

Dans le terminal B, teste la nouvelle porte avec le raccourci. ssh redemande de confirmer la clé d'hôte : c'est la même clé, rangée sous un nouveau nom (`[IP]:port`), donc réponds `yes`.

<Check cmd="ssh -o BatchMode=yes vps echo connected" expect="connected" />

<Details summary="Si la nouvelle connexion échoue">

Ton premier terminal est toujours connecté : rien n'est perdu. Regarde, dans cet ordre :

- `sudo sshd -T | grep '^port '` : doit donner <V name="SSH_PORT" />.
- `sudo ss -tlnp | grep sshd` : sshd doit écouter sur <V name="SSH_PORT" />, en IPv4 (`0.0.0.0`) et en IPv6 (`[::]`).
- `sudo ufw status` : <V name="SSH_PORT" />/tcp doit être en `ALLOW`.
- `systemctl is-active ssh.socket` : s'il répond `active`, c'est l'unité socket qui tient le port, pas sshd ; lance `sudo systemctl daemon-reload && sudo systemctl restart ssh.socket`. Par défaut Debian utilise `ssh.service` (Ubuntu, le socket) : vérifie avec `systemctl is-enabled ssh.socket ssh.service`.
- Le **Network Firewall** d'OVH (espace client → ton IP → Network Firewall), si tu l'as activé : il filtre avant que les paquets n'atteignent le VPS ; ajoute une règle pour <V name="SSH_PORT" />.
- Pour revenir en arrière en dix secondes : `sudo rm /etc/ssh/sshd_config.d/00-hardening.conf && sudo systemctl restart ssh`. Tu es de nouveau sur le port 22, toujours ouvert.
- Première session perdue elle aussi : connecte-toi sur la **console KVM** en <V name="USERNAME" /> avec ton mot de passe et lance les mêmes commandes.

</Details>

Le nouveau port marche : ferme le 22, dans le terminal A.

```bash on="server" as="debian"
sudo ufw delete allow 22/tcp
```

<Deep>ufw accepte les paquets des connexions établies avant de regarder les règles par port : c'est pour ça que supprimer la règle du 22 ne coupe pas la session dans laquelle tu tapes. Sur Debian 13, ufw pilote la commande `iptables`, qui est la variante nftables (`iptables-nft`) : les règles finissent dans nftables, visibles avec `sudo nft list ruleset`. L'IPv6 est filtré avec les mêmes règles parce que `/etc/default/ufw` contient `IPV6=yes` par défaut ; chaque règle apparaît deux fois dans `ufw status`, dont une avec `(v6)`. Redémarrer `ssh.service` ne ferme pas les sessions ouvertes : l'unité de Debian utilise `KillMode=process`, qui n'arrête que le processus d'écoute.</Deep>

## Retirer le compte debian

<Warn>Ne fais ceci que quand les deux sont vrais : `ssh vps` te connecte, et `sudo true` dans cette session accepte ton mot de passe. Après cette étape, la console KVM n'accepte plus que <V name="USERNAME" /> avec ce mot de passe : vérifie qu'il est bien dans ton gestionnaire de mots de passe.</Warn>

Ouvre ta propre session depuis le Mac et vérifie que sudo accepte ton mot de passe :

```bash on="mac"
ssh vps
```

```bash on="server" as="${USERNAME}" interactive
sudo true && echo SUDO OK
```

Une fois `SUDO OK` revenu, ferme le terminal A (`exit`). Puis, dans la session `ssh vps`. `userdel` affiche `mail spool (/var/mail/debian) not found` : c'est normal.

<When flag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
sudo pkill -KILL -u debian
sudo userdel -r debian
sudo rm -f /etc/sudoers.d/90-cloud-init-users
```

</When>

<When notFlag="REMOVE_DEBIAN_USER">

```bash on="server" as="${USERNAME}"
sudo usermod -L -e 1 debian
sudo rm -f /etc/sudoers.d/90-cloud-init-users
```

<Guided>`-L` verrouille le mot de passe, `-e 1` fixe l'expiration du compte au 2 janvier 1970 : un compte expiré est refusé partout, clés comprises. Il n'est de toute façon pas dans `sshusers`. Pour annuler : `sudo usermod -U -e '' debian`.</Guided>

</When>

Depuis ton portable, vérifie que `debian` est refusé ; la réponse doit se terminer par `Permission denied (publickey).`

```bash on="mac"
ssh -p ${SSH_PORT} -o BatchMode=yes debian@${SERVER_IP} true
```

<Deep>`userdel -r` supprime aussi le dossier personnel ; il peut signaler que la boîte mail `/var/mail/debian` est introuvable, sans conséquence. On l'utilise plutôt que `deluser --remove-home`, qui a besoin du paquet `perl` complet, absent d'une image minimale. Le fichier sudoers supprimé ici est celui que cloud-init a écrit pour donner à `debian` un sudo sans mot de passe ; sans lui, un `debian` resté ou recréé n'a pas sudo. cloud-init crée son utilisateur par défaut une fois par instance, donc un redémarrage ne le fait pas revenir. Les guides et le support d'OVH supposent que l'utilisateur `debian` existe : lis leurs commandes avec <V name="USERNAME" /> à la place. Si un jour tu perds à la fois SSH et le mot de passe, le **mode rescue** de l'espace client démarre un système à part avec ton disque monté, où tu peux corriger des fichiers ou réinitialiser le mot de passe.</Deep>

<When flag="FAIL2BAN">

## Bannir le brute force

Avec les mots de passe refusés, deviner ne sert à rien, mais les robots frappent quand même. fail2ban lit le journal de SSH et bannit une adresse après trois échecs.

```bash on="server" as="${USERNAME}"
sudo apt install -y fail2ban python3-systemd
```

Puis la prison, en un seul fichier :

```ini file="/etc/fail2ban/jail.d/sshd.local" on="server" as="${USERNAME}"
[DEFAULT]
bantime.increment = true

[sshd]
enabled = true
port = ${SSH_PORT}
backend = systemd
maxretry = 3
findtime = 10m
bantime = 1h
```

```bash on="server" as="${USERNAME}"
sudo systemctl enable fail2ban && sudo systemctl restart fail2ban
```

<Check cmd="sudo fail2ban-client status sshd | head -1" expect="Status for the jail: sshd" />

<Guided>Trois échecs en dix minutes depuis une même adresse valent un bannissement d'une heure ; `bantime.increment` allonge chaque nouveau bannissement de la même adresse. `backend = systemd` compte sur Debian 12 et 13 : il n'y a pas de `/var/log/auth.log` par défaut, le journal de SSH vit uniquement dans journald, et avec le réglage par défaut basé sur un fichier la jail peut refuser de démarrer.</Guided>

<Deep>`python3-systemd` est la bibliothèque dont fail2ban se sert pour lire le journal. Pour le voir marcher, refais la connexion `debian` refusée de l'étape précédente, puis `sudo fail2ban-client status sshd` : `Total failed` augmente de un. Pour tester le filtre contre ce qui est déjà dans le journal : `sudo fail2ban-regex systemd-journal sshd`. Sur Debian 13, OpenSSH 10 journalise depuis des processus nommés `sshd-session` ; si `journalctl -u ssh` montre les lignes mais que le test n'en reconnaît aucune, le filtre du journal date d'avant ce changement (point à confirmer avec ce même test après une connexion refusée).</Deep>

<Details summary="Si tu te bannis toi-même">

Trois fautes de frappe depuis ta propre adresse et tu es dehors pour une heure. Pour ne jamais bannir une adresse fixe à la maison, ajoute `ignoreip = 127.0.0.1/8 ::1` suivi de ton adresse (<V name="MY_IP" />) sous `[DEFAULT]` avec `sudo ${EDITOR} /etc/fail2ban/jail.d/sshd.local`, et redémarre fail2ban. Pour te débannir, depuis une autre connexion (partage de connexion du téléphone) ou la console KVM :

```bash on="server" as="${USERNAME}"
sudo fail2ban-client set sshd unbanip ${MY_IP}
```

</Details>

</When>

## Mises à jour de sécurité automatiques

```bash on="server" as="${USERNAME}"
sudo apt install -y unattended-upgrades apt-listchanges
printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades
```

<When flag="AUTO_REBOOT">

Autorise un redémarrage à <V name="REBOOT_TIME" /> quand une mise à jour en a besoin :

```text file="/etc/apt/apt.conf.d/52unattended-upgrades-local" on="server" as="${USERNAME}"
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "${REBOOT_TIME}";
```

</When>

<Check cmd="apt-config shell UU APT::Periodic::Unattended-Upgrade" expect="UU='1'" />

<Guided>Chaque jour, apt rafraîchit la liste des paquets et installe ce que Debian publie pour ta version, les correctifs de sécurité en premier. <When flag="AUTO_REBOOT">Un nouveau noyau ne prend effet qu'après un redémarrage ; avec l'option activée, le serveur le fait à <V name="REBOOT_TIME" /> au lieu de faire tourner l'ancien pendant des semaines.</When><When notFlag="AUTO_REBOOT">Un nouveau noyau ne prend effet qu'après un redémarrage, que tu fais désormais à la main : `/var/run/reboot-required` existe quand il y en a un en attente.</When></Guided>

<Deep>`20auto-upgrades` active la tâche quotidienne ; c'est le fichier qu'écrit `sudo dpkg-reconfigure -plow unattended-upgrades`. Ce qui est installé se règle dans `/etc/apt/apt.conf.d/50unattended-upgrades` : sur Debian, l'archive de sécurité et les versions intermédiaires de ta version stable. Les dépôts tiers (celui de Caddy, dans une page suivante) n'en font pas partie tant que tu n'ajoutes pas leur origine. La tâche part de `apt-daily-upgrade.timer`, vers 06:00 plus un délai aléatoire ; <When flag="AUTO_REBOOT">le redémarrage attend ensuite le prochain <V name="REBOOT_TIME" />. </When>Les journaux sont dans `/var/log/unattended-upgrades/` ; un essai à blanc montre ce qu'elle ferait : `sudo unattended-upgrade --dry-run --debug`. Les services qui utilisent encore une ancienne bibliothèque après une mise à jour continuent d'exécuter l'ancien code jusqu'à leur redémarrage ; `needrestart` (`sudo apt install needrestart`) les liste après chaque mise à jour, et `sudo needrestart -r l` à la demande.</Deep>

## Vérifier l'IPv6

La page suivante publie l'adresse IPv6 dans le DNS ; une adresse morte envoie les visiteurs IPv6 dans des délais d'attente. Sur le serveur, l'adresse globale doit être <V name="SERVER_IPV6" /> et doit atteindre internet :

```bash on="server" as="${USERNAME}"
ip -6 addr show scope global
ping -6 -c 3 2001:4860:4860::8888
```

Note le résultat : si `ping -6` répond, la page 4 publie l'adresse IPv6 (enregistrement AAAA) ; sinon, elle n'en publie pas.

<Note>Sur les images Debian récentes, OVH configure l'IPv6 par défaut. Si l'adresse manque, suis le guide OVH « Configurer l'IPv6 sur un VPS » sur docs.ovhcloud.com : il ajoute un fichier netplan, `/etc/netplan/51-cloud-init-ipv6.yaml`.</Note>

## Faire un snapshot de l'état propre (facultatif, payant)

<Warn>Le snapshot est une option payante : à partir d'environ 0,36 € TTC par mois et par VPS. Vérifie le prix actuel sur ovhcloud.com/fr/vps avant de la commander.</Warn>

Dans l'espace client OVH, ouvre ton VPS : sur son onglet d'accueil, l'option **Snapshot** propose de la commander ; une fois active, prends un snapshot au même endroit. Sans l'option, tu as quand même la sauvegarde automatique incluse (un jour de rétention). Les libellés des menus bougent : le guide OVH « Utiliser les snapshots sur un VPS » sur docs.ovhcloud.com donne le chemin actuel.

<Guided>C'est le serveur à son meilleur : à jour, verrouillé, rien d'autre d'installé. Si une page suivante tourne mal, restaurer cet état prend quelques minutes, là où le reconstruire demande de refaire toute cette page. Prends un nouveau snapshot avant tout changement risqué ; il n'y en a qu'un à la fois, et le nouveau remplace l'ancien.</Guided>

## Terminé

| Quoi | Avant | Après cette page |
|---|---|---|
| Qui se connecte | `debian`, sudo sans mot de passe | <V name="USERNAME" />, clé uniquement, sudo avec mot de passe |
| Port SSH | 22 | <V name="SSH_PORT" /> |
| Root, mots de passe | root coupé, mots de passe peut-être actifs (cloud-init) | refusés tous les deux par ton propre fichier |
| Trafic entrant | tout | <V name="SSH_PORT" />/tcp seulement |
| Brute force | essais illimités | <When flag="FAIL2BAN">3 essais, puis banni</When><When notFlag="FAIL2BAN">mots de passe refusés de toute façon</When> |
| Mises à jour de sécurité | à la main | quotidiennes, automatiques<When flag="AUTO_REBOOT">, redémarrage à <V name="REBOOT_TIME" /></When> |

Désormais, tu te connectes avec `ssh vps`. Page suivante : **Pointer le domaine vers le serveur (DNS Infomaniak)**. Elle publie <V name="SERVER_IP" /> et <V name="SERVER_IPV6" /> dans la zone DNS Infomaniak de <V name="DOMAIN" />, pour que le nom mène à cette machine avant que Caddy ne demande un certificat.
````

````yaml title="content/ovh-vps-static-site/secure-the-server/diagram.yaml"
# Quick: laptop, bots, firewall, sshd, your account. Guided: the drop-in that drives sshd,
# fail2ban and unattended-upgrades. Deep: the journal, cloud-init's overridden drop-in,
# the locked debian account (when kept) and the KVM console as the rescue path.
title: { en: "One way in, tested before the old one closes", fr: "Une seule entrée, testée avant de fermer l'ancienne" }
caption:
  en: "Only your key reaches sshd, on port ${SSH_PORT}, through the firewall, and it opens a session as ${USERNAME}. Bots on port 22 find a closed door; the KVM console stays as the way back in."
  fr: "Seule ta clé atteint sshd, sur le port ${SSH_PORT}, à travers le pare-feu, et ouvre une session en ${USERNAME}. Les robots sur le port 22 trouvent porte close ; la console KVM reste le chemin du retour."

groups:
  - id: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The OVH VPS, renamed ${SERVER_NAME}. Everything inside this box is what the page changes."
      fr: "Le VPS OVH, renommé ${SERVER_NAME}. Tout ce qui est dans cette boîte est ce que la page modifie."

nodes:
  - id: laptop
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "ssh vps"
    desc:
      en: "Holds the private key and the ~/.ssh/config entry 'vps' (${SERVER_IP}, port ${SSH_PORT}, user ${USERNAME})."
      fr: "Détient la clé privée et l'entrée « vps » de ~/.ssh/config (${SERVER_IP}, port ${SSH_PORT}, utilisateur ${USERNAME})."
    deep:
      sub: "~/.ssh/config · Host vps → ${SERVER_IP}:${SSH_PORT}"
  - id: bots
    kind: cloud
    label: { en: "The internet's bots", fr: "Les robots d'internet" }
    sub: "root / passwords on 22"
    desc:
      en: "Scanners that try root and common passwords on port 22 within minutes of a server going online."
      fr: "Des scanners qui essaient root et des mots de passe courants sur le port 22 quelques minutes après la mise en ligne d'un serveur."
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "${SSH_PORT}/tcp only"
    in: server
    desc:
      en: "ufw: inbound traffic refused except ${SSH_PORT}/tcp. Port 22 stays open only until the new port is proven."
      fr: "ufw : trafic entrant refusé sauf ${SSH_PORT}/tcp. Le port 22 reste ouvert seulement jusqu'à ce que le nouveau port ait fait ses preuves."
    guided:
      sub: "ufw · deny in · allow ${SSH_PORT}/tcp"
    deep:
      sub: "ufw → iptables-nft → nftables · IPv4 + IPv6 · allow ${SSH_PORT}/tcp"
  - 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: port ${SSH_PORT}, keys only, root refused, only members of sshusers let in."
      fr: "Le démon SSH après durcissement : port ${SSH_PORT}, clés uniquement, root refusé, seuls les membres de sshusers entrent."
    guided:
      sub: "port ${SSH_PORT} · keys only · AllowGroups sshusers"
    deep:
      sub: "ssh.service · Port ${SSH_PORT} · PermitRootLogin no · PasswordAuthentication no · AllowGroups sshusers"
  - id: account
    kind: user
    label: { en: "Your account", fr: "Ton compte" }
    sub: "${USERNAME} · sudo"
    in: server
    desc:
      en: "${USERNAME}, in the groups sudo and sshusers. Logs in with the key; sudo asks for the password, which is also the KVM console login."
      fr: "${USERNAME}, dans les groupes sudo et sshusers. Se connecte avec la clé ; sudo demande le mot de passe, qui sert aussi à la console KVM."
    deep:
      sub: "${USERNAME} · groups sudo, sshusers · ~/.ssh/authorized_keys (600)"
  - id: hardening
    kind: file
    label: { en: "00-hardening.conf", fr: "00-hardening.conf" }
    sub: "/etc/ssh/sshd_config.d/"
    in: server
    level: guided
    desc:
      en: "Your drop-in. sshd keeps the first value it reads for each setting, and 00- is read before any other drop-in."
      fr: "Ton fichier complémentaire. sshd garde la première valeur lue pour chaque réglage, et 00- est lu avant tous les autres."
  - id: upgrades
    kind: service
    label: { en: "unattended-upgrades", fr: "unattended-upgrades" }
    sub: "daily security updates"
    in: server
    level: guided
    desc:
      en: "Installs Debian's security and point-release updates every day, sshd included."
      fr: "Installe chaque jour les mises à jour de sécurité et de version intermédiaire de Debian, sshd compris."
    deep:
      sub: "apt-daily-upgrade.timer · 20auto-upgrades"
  - id: fail2ban
    kind: service
    label: { en: "fail2ban", fr: "fail2ban" }
    sub: "3 failures → 1h ban"
    in: server
    level: guided
    when: { flag: FAIL2BAN }
    desc:
      en: "Counts failed logins per address and bans an address after three failures in ten minutes, longer each time it comes back."
      fr: "Compte les connexions ratées par adresse et bannit une adresse après trois échecs en dix minutes, plus longtemps à chaque récidive."
    deep:
      sub: "jail sshd · backend systemd · port ${SSH_PORT} · bantime.increment"
  - id: journal
    kind: file
    label: { en: "journald", fr: "journald" }
    sub: "journalctl -u ssh"
    in: server
    level: deep
    desc:
      en: "The system journal. On Debian 12 and 13 it is the only SSH log: there is no /var/log/auth.log by default."
      fr: "Le journal système. Sur Debian 12 et 13, c'est le seul journal de SSH : il n'y a pas de /var/log/auth.log par défaut."
  - id: cloudinit
    kind: file
    label: { en: "50-cloud-init.conf", fr: "50-cloud-init.conf" }
    sub: "overridden"
    in: server
    level: deep
    desc:
      en: "Left by cloud-init, may say PasswordAuthentication yes. Read after 00-hardening.conf, so its values lose."
      fr: "Laissé par cloud-init, peut contenir PasswordAuthentication yes. Lu après 00-hardening.conf, donc ses valeurs perdent."
  - id: debian
    kind: user
    label: { en: "debian account", fr: "Compte debian" }
    sub: "locked"
    in: server
    level: deep
    when: { notFlag: REMOVE_DEBIAN_USER }
    desc:
      en: "OVH's default account, kept but locked and expired, outside sshusers, and without its password-less sudo file."
      fr: "Le compte par défaut d'OVH, conservé mais verrouillé et expiré, hors de sshusers, et sans son fichier de sudo sans mot de passe."
  - id: kvm
    kind: net
    label: { en: "KVM console", fr: "Console KVM" }
    sub: "OVH control panel"
    level: deep
    desc:
      en: "A screen and keyboard on the VPS through the browser. It bypasses SSH and the firewall; it takes ${USERNAME} and the password."
      fr: "Un écran et un clavier sur le VPS via le navigateur. Elle contourne SSH et le pare-feu ; elle prend ${USERNAME} et le mot de passe."

edges:
  - from: laptop
    to: firewall
    label: "ssh vps"
    guided: { label: "SSH · TCP ${SSH_PORT}" }
    deep: { label: "SSH-2 · TCP ${SSH_PORT} · IPv4/IPv6" }
    desc:
      en: "Your connection, on the new port, with the key. Tested from a second terminal before port 22 is closed."
      fr: "Ta connexion, sur le nouveau port, avec la clé. Testée depuis un second terminal avant de fermer le port 22."
  - from: bots
    to: firewall
    label: "22, refused"
    dashed: true
    deep: { label: "TCP 22 → drop" }
    desc:
      en: "Port 22 is closed once the new port works: the packets are dropped."
      fr: "Le port 22 est fermé dès que le nouveau port marche : les paquets sont jetés."
  - from: firewall
    to: sshd
    desc:
      en: "Only traffic to ${SSH_PORT}/tcp reaches the daemon."
      fr: "Seul le trafic vers ${SSH_PORT}/tcp atteint le démon."
  - from: sshd
    to: account
    label: "key → session"
    desc:
      en: "The key matches and the account is in sshusers: a shell as ${USERNAME}, never as root."
      fr: "La clé correspond et le compte est dans sshusers : un shell en ${USERNAME}, jamais en root."
  - from: hardening
    to: sshd
    label: "read first"
    dashed: true
    level: guided
    desc:
      en: "Loaded at start. Check with sshd -t, then sshd -T for the effective values, before any restart."
      fr: "Chargé au démarrage. Vérifie avec sshd -t, puis sshd -T pour les valeurs effectives, avant tout redémarrage."
  - from: cloudinit
    to: sshd
    label: "overridden"
    dashed: true
    level: deep
    desc:
      en: "Read later; for every setting already set by 00-hardening.conf, its value is ignored."
      fr: "Lu plus tard ; pour chaque réglage déjà fixé par 00-hardening.conf, sa valeur est ignorée."
  - from: upgrades
    to: sshd
    label: "patches"
    dashed: true
    level: guided
    desc:
      en: "Security fixes for OpenSSH and everything else arrive without you."
      fr: "Les correctifs de sécurité d'OpenSSH et du reste arrivent sans toi."
  - from: sshd
    to: journal
    label: "logs"
    dashed: true
    level: deep
    desc:
      en: "Every login attempt, accepted or refused, with the source address."
      fr: "Chaque tentative de connexion, acceptée ou refusée, avec l'adresse source."
  - from: journal
    to: fail2ban
    label: "reads"
    dashed: true
    level: deep
    when: { flag: FAIL2BAN }
    desc:
      en: "fail2ban follows the journal through python3-systemd and counts failures per address."
      fr: "fail2ban suit le journal via python3-systemd et compte les échecs par adresse."
  - from: fail2ban
    to: firewall
    label: "ban"
    dashed: true
    level: guided
    when: { flag: FAIL2BAN }
    desc:
      en: "A ban is a firewall rule dropping the address for an hour, longer for repeat offenders."
      fr: "Un bannissement est une règle de pare-feu qui jette l'adresse pendant une heure, plus pour les récidivistes."
  - from: sshd
    to: debian
    label: "refused"
    dashed: true
    level: deep
    when: { notFlag: REMOVE_DEBIAN_USER }
    desc:
      en: "Not in sshusers and expired: no key opens a session for it."
      fr: "Hors de sshusers et expiré : aucune clé ne lui ouvre de session."
  - from: laptop
    to: kvm
    label: "rescue"
    dashed: true
    level: deep
    desc:
      en: "When SSH does not answer: the control panel, then the KVM console."
      fr: "Quand SSH ne répond plus : l'espace client, puis la console KVM."
  - from: kvm
    to: account
    label: "password login"
    dashed: true
    level: deep
    desc:
      en: "A local login as ${USERNAME} with the password, which neither the firewall nor sshd can block."
      fr: "Une connexion locale en ${USERNAME} avec le mot de passe, que ni le pare-feu ni sshd ne peuvent bloquer."
````

---

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