# Source of "Deploy atomic releases with rsync"

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/deploy-releases/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.
title:
  en: Deploy atomic releases with rsync
  fr: Déployer des versions atomiques avec rsync
summary:
  en: >-
    One command on your laptop builds the site, uploads only what changed into a new release
    directory, switches the live site to it in a single atomic step, keeps the last few releases and
    checks the result. Rollback is one command too.
  fr: >-
    Une commande sur ton portable construit le site, envoie seulement ce qui a changé dans un nouveau
    répertoire de version, bascule le site en ligne dessus en une seule étape atomique, garde les
    dernières versions et vérifie le résultat. Le retour arrière tient aussi en une commande.
difficulty: intermediate
tags: [deploy, rsync, ssh, symlink, rollback]
authors: [thudal]
created: 2026-09-26
minutes: 30
validated: Debian 13 (trixie) · rsync 3.x · macOS 15
status: draft             # not yet run end to end by its author

vars:
  - key: KEEP_RELEASES
    kind: text
    group: site
    default: "5"
    label: { en: Releases to keep, fr: Versions à garder }
    hint:
      en: How many releases stay on the server after each deploy, the live one included. Older ones are deleted.
      fr: Combien de versions restent sur le serveur après chaque déploiement, celle en ligne comprise. Les plus anciennes sont supprimées.
    impact:
      en: "It is also how far back you can roll back. Each release shares unchanged files with the previous one, so 5 costs little disk; 1 leaves nothing to roll back to. Must be at least 1: the deploy script refuses 0, which would delete the live release."
      fr: "C'est aussi jusqu'où tu peux revenir en arrière. Chaque version partage les fichiers inchangés avec la précédente, donc 5 coûte peu de disque ; 1 ne laisse rien vers quoi revenir. Au moins 1 : le script de déploiement refuse 0, qui supprimerait la version en ligne."
````

````mdx title="content/ovh-vps-static-site/deploy-releases/page-en.mdx"
{/* First pass — to be validated against man rsync (--link-dest, --checksum, --chmod), man sshd (AUTHORIZED_KEYS FILE FORMAT, restrict) and man mv (-T) before publishing. */}

The site is served, but only a placeholder. This page gives you one command, `./scripts/deploy.sh`, that builds the site on your laptop, uploads only what changed into a new release directory on the server, switches the live site to it in one atomic step, deletes old releases beyond the last <V name="KEEP_RELEASES" />, and checks that https://<V name="DOMAIN" /> answers. A second command rolls back. The uploads go through a dedicated account, <V name="DEPLOY_USER" />, with its own key, no sudo, and the web root as the only thing it owns.

<Run>

Open a new file `deploy-setup.sh` in the project folder, paste every code block of this section into it in order, then run `bash deploy-setup.sh`. It runs **on your laptop**, from <V name="LOCAL_DIR" />, as you. It needs `ssh vps` working (page 3), Caddy serving <V name="WEB_ROOT" />/current (page 5), and rsync 3 from Homebrew. The one `ssh -t vps sudo …` call asks for your sudo password on the server, once.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Deploy releases — ${DOMAIN}
cd ${LOCAL_DIR}
rsync --version | grep 'version 3\.' >/dev/null || { echo "Install rsync 3 first: brew install rsync" >&2; exit 1; }

# 1. On the laptop: a key used only for deploys
[ -f ~/.ssh/id_ed25519_deploy ] || ssh-keygen -t ed25519 -N "" -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy

# 2. On the server: deploy user, restricted key, web root ownership
SETUP=$(mktemp)
cat > "$SETUP" <<'EOF'
set -eu
apt-get -o DPkg::Lock::Timeout=300 install -y -q rsync >/dev/null
id -u ${DEPLOY_USER} >/dev/null 2>&1 || adduser --disabled-password --gecos "" ${DEPLOY_USER}
usermod -aG sshusers ${DEPLOY_USER}
install -d -m 700 -o ${DEPLOY_USER} -g ${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh
sed 's/^/restrict /' /home/${USERNAME}/deploy.pub > /home/${DEPLOY_USER}/.ssh/authorized_keys
chown ${DEPLOY_USER}:${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh/authorized_keys
chmod 600 /home/${DEPLOY_USER}/.ssh/authorized_keys
chown -R ${DEPLOY_USER}:${DEPLOY_USER} ${WEB_ROOT}
rm /home/${USERNAME}/deploy.pub
EOF
scp -q ~/.ssh/id_ed25519_deploy.pub vps:deploy.pub
scp -q "$SETUP" vps:setup-deploy-user.sh
rm "$SETUP"
ssh -t vps 'sudo bash setup-deploy-user.sh && rm setup-deploy-user.sh'

# 3. On the laptop: the vps-deploy alias
grep -q '^Host vps-deploy$' ~/.ssh/config 2>/dev/null || printf '\nHost vps-deploy\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${DEPLOY_USER}\n  IdentityFile ~/.ssh/id_ed25519_deploy\n  IdentitiesOnly yes\n' >> ~/.ssh/config
ssh vps-deploy 'ls ${WEB_ROOT}'

# 4. The deploy and rollback scripts, in the project folder
mkdir -p scripts
cat > scripts/deploy.sh <<'EOF'
#!/usr/bin/env bash
# Build the site and publish it as a new release. Usage: ./scripts/deploy.sh
set -euo pipefail
cd "$(dirname "$0")/.."                                         # (1)

HOST=vps-deploy                                                 # (2)
ROOT=${WEB_ROOT}
KEEP=${KEEP_RELEASES}
[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }   # (3)

npm ci                                                          # (4)
npm run build
test -f out/index.html                                          # (5)

REL=$(date -u +%Y%m%dT%H%M%SZ)                                  # (6)
echo "Release $REL"

rsync -rlpz --checksum --delete --chmod=D755,F644 \
  --link-dest="$ROOT/current/" \
  out/ "$HOST:$ROOT/releases/$REL/"                             # (7)

ssh "$HOST" "cd $ROOT && ln -sfn releases/$REL current.tmp && mv -T current.tmp current"   # (8)
ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort | head -n -$KEEP | xargs -r rm -rf --" # (9)
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/     # (10)
EOF
cat > scripts/rollback.sh <<'EOF'
#!/usr/bin/env bash
# Usage: ./scripts/rollback.sh [release-name]
set -euo pipefail
HOST=vps-deploy
ROOT=${WEB_ROOT}
CUR=$(ssh "$HOST" "readlink $ROOT/current")
CUR=$(basename "$CUR")
LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")
if [ $# -gt 0 ]; then
  TARGET=$1
else
  TARGET=$(printf '%s\n' "$LIST" | awk -v c="$CUR" '$0 == c { print p; exit } { p = $0 }')
fi
[ -n "$TARGET" ] || { echo "No release older than $CUR." >&2; exit 1; }
printf '%s\n' "$LIST" | grep -qx "$TARGET" || { echo "Unknown release: $TARGET" >&2; exit 1; }
ssh "$HOST" "cd $ROOT && ln -sfn releases/$TARGET current.tmp && mv -T current.tmp current"
echo "current: $CUR -> $TARGET"
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/
EOF
chmod +x scripts/deploy.sh scripts/rollback.sh

# 5. First deploy
./scripts/deploy.sh
ssh vps-deploy 'readlink ${WEB_ROOT}/current'
```

<Warn>The deploy script deletes, on the server, every release directory beyond the newest <V name="KEEP_RELEASES" /> whose name starts with `20`. Nothing else in <V name="WEB_ROOT" /> is touched.</Warn>

</Run>

## Before you start

You need pages 1 to 5 done: the project folder on your laptop (page 1), `ssh vps` logging you in as <V name="USERNAME" />, and Caddy serving the placeholder at https://<V name="DOMAIN" />.

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

The Mac's own `rsync` is not the one you want. Install rsync 3 from Homebrew (install Homebrew from brew.sh first if `brew` is missing):

```bash on="mac"
brew install rsync
```

Then close this terminal and open a new one (Cmd+N in Terminal). The shell that is already open may keep running `/usr/bin/rsync`: it remembers where it found a command, and if Homebrew was installed in it, its `PATH` does not include `/opt/homebrew/bin` yet. A new shell reads your `PATH` afresh and finds Homebrew's rsync first. In the new terminal:

```bash on="mac"
rsync --version | head -1
```

<Check cmd="rsync --version | head -1 | grep -o 'version 3'" expect="version 3" />

<Deep>Depending on the macOS release, `/usr/bin/rsync` is either the ancient rsync 2.6.9 or `openrsync`, a separate implementation behind a wrapper. Both speak the rsync protocol, but their option coverage and edge cases differ from rsync 3, which is what Debian runs on the other end. With Homebrew's rsync 3.x on both sides, every flag used below behaves as documented in `man rsync`. Check which binary your shell picks with `which rsync`: it should be under `/opt/homebrew/bin`.</Deep>

## Create the deploy user

Your admin account has sudo; the account that uploads the site must not, and it gets a key of its own. On the laptop, create the deploy key if it does not exist yet and copy its public half to the server:

```bash on="mac"
[ -f ~/.ssh/id_ed25519_deploy ] || ssh-keygen -t ed25519 -N "" -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy
scp ~/.ssh/id_ed25519_deploy.pub vps:deploy.pub
```

Then log in to the server as usual:

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

<Deep>The deploy key has no passphrase because the scripts must run without a prompt. That is acceptable here: the key opens only <V name="DEPLOY_USER" />, which can write the web root and nothing else, and FileVault protects the disk it sits on when the Mac is off. If you prefer a passphrase, store it in the macOS keychain (`ssh-add --apple-use-keychain ~/.ssh/id_ed25519_deploy`, plus `UseKeychain yes` in the host block below) and the scripts still run unattended.</Deep>

On the server, install rsync (the receiving end), create the user, let it through SSH, give it the web root, and install the deploy key for it, prefixed with `restrict`. The last line, `exit`, brings you back to the laptop:

```bash on="server" as="${USERNAME}"
sudo apt install -y rsync
sudo adduser --disabled-password --gecos "" ${DEPLOY_USER}
sudo usermod -aG sshusers ${DEPLOY_USER}
sudo chown -R ${DEPLOY_USER}:${DEPLOY_USER} ${WEB_ROOT}
sudo install -d -m 700 -o ${DEPLOY_USER} -g ${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh
sed 's/^/restrict /' deploy.pub | sudo tee /home/${DEPLOY_USER}/.ssh/authorized_keys
sudo chown ${DEPLOY_USER}:${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh/authorized_keys
sudo chmod 600 /home/${DEPLOY_USER}/.ssh/authorized_keys
rm deploy.pub
exit
```

<Guided>`--disabled-password` creates an account with no password at all: it can only log in with a key. The `sshusers` group is the one `AllowGroups` lets in since page 3; without it, sshd refuses the user before even looking at its key. `chown -R` hands `releases/`, the placeholder and the `current` link to the deploy user, so it can add releases and move the link without sudo. `tee` echoes the key line it wrote, starting with `restrict ssh-ed25519` and ending with `deploy@laptop`.</Guided>

<Guided>`restrict` is an option in front of the key. It turns off everything a deploy never needs: no pseudo-terminal, no port forwarding, no agent or X11 forwarding. Commands still run, which is all rsync and the symlink swap need. `sshd` ignores an `authorized_keys` file that others can write, hence the `700` on the folder and `600` on the file.</Guided>

<Warn>Never add <V name="DEPLOY_USER" /> to the `sudo` group, and never give it a sudoers rule. Its key sits on your laptop without a passphrase and is used by scripts. With sudo, a leaked copy would own the server instead of the site.</Warn>

<Deep>Why a separate account and a separate key: blast radius. `id_ed25519_deploy` is authorised for <V name="DEPLOY_USER" /> only, and your admin key is not authorised for <V name="DEPLOY_USER" />. If the deploy key leaks (a copied file, a backup restored elsewhere), the attacker can rewrite <V name="WEB_ROOT" /> and nothing else: no sudo, no shell with a terminal, no tunnels into the server. The `from="…"` option would also pin the key to source addresses, which works for a fixed home IP but not for a laptop that deploys from cafés, trains and hotel networks. Two further steps if you want them: `command="rrsync …"` (Debian's rsync package ships `rrsync`) forces the key to rsync only, but then the `ssh` swap and prune in the script are refused, so you would move them server-side; and making `authorized_keys` owned by root (`root:root`, mode 644) stops a leaked key from adding keys of its own, since sshd accepts files owned by root.</Deep>

<Note>Caddy runs as the `caddy` user and only needs to read: directories `755`, files `644`. The deploy script forces exactly those with `--chmod`, whatever the permissions are on your Mac.</Note>

## Add the laptop alias

Add a second host to `~/.ssh/config` on your laptop, next to `vps`:

```bash on="mac"
cat >> ~/.ssh/config <<'EOF'

Host vps-deploy
  HostName ${SERVER_IP}
  Port ${SSH_PORT}
  User ${DEPLOY_USER}
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
EOF
```

<Guided>Same server, same port, different user and key. `IdentityFile` picks the deploy key; `IdentitiesOnly yes` limits ssh to the keys named in the config: your admin key (loaded in the agent since `ssh vps`) may be offered first and is refused, since the deploy user does not trust it. Every script on this page talks to `vps-deploy`, so none of them repeats the IP, the port or the key. The host key is already in your `known_hosts` from page 3, so there is no fingerprint prompt.</Guided>

<Check cmd="ssh vps-deploy 'ls ${WEB_ROOT}'" expect="current
releases" />

<Details summary="If it says Permission denied (publickey)">

In order of likelihood: the user is not in the `sshusers` group, the permissions on /home/<V name="DEPLOY_USER" />/.ssh are too open, the key line lost its `ssh-ed25519` prefix, or the `IdentityFile` path does not match the key you created. On the server, `sudo journalctl -u ssh -n 20` names the reason.

</Details>

## Write the deploy script

Create the `scripts` folder in the project:

```bash on="mac"
cd ${LOCAL_DIR} && mkdir -p scripts
```

Then write `scripts/deploy.sh`: copy the command and paste it in the same terminal. The path is relative, so it lands in the project folder you just moved into.

<Annotated>

```bash file="scripts/deploy.sh" on="mac"
#!/usr/bin/env bash
# Build the site and publish it as a new release. Usage: ./scripts/deploy.sh
set -euo pipefail
cd "$(dirname "$0")/.."                                         # (1)

HOST=vps-deploy                                                 # (2)
ROOT=${WEB_ROOT}
KEEP=${KEEP_RELEASES}
[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }   # (3)

npm ci                                                          # (4)
npm run build
test -f out/index.html                                          # (5)

REL=$(date -u +%Y%m%dT%H%M%SZ)                                  # (6)
echo "Release $REL"

rsync -rlpz --checksum --delete --chmod=D755,F644 \
  --link-dest="$ROOT/current/" \
  out/ "$HOST:$ROOT/releases/$REL/"                             # (7)

ssh "$HOST" "cd $ROOT && ln -sfn releases/$REL current.tmp && mv -T current.tmp current"   # (8)
ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort | head -n -$KEEP | xargs -r rm -rf --" # (9)
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/     # (10)
```

1. Runs from the project root wherever you call it from, and stops at the first failing command.
2. The alias from the previous step. Your values are already written into `ROOT` and `KEEP`.
3. With 0, `head -n -0` below would list every release, the live one included, and delete them all. The script refuses to start instead.
4. `npm ci` installs exactly what `package-lock.json` says, so every build starts from the same dependencies.
5. A failed or empty build stops here instead of replacing the live site with nothing.
6. The release name is the UTC time of the deploy, for example `20260926T141500Z`. Sorting the names as text sorts them by date, and UTC never jumps back an hour in autumn.
7. Uploads `out/` into a new directory. Files identical to the live release are hard-linked on the server instead of sent; only changed files cross the network.
8. The atomic swap: a new link is prepared next to `current`, then renamed over it in one step.
9. Deletes every release beyond the newest <V name="KEEP_RELEASES" />. Only names starting with `20` are considered, so the `placeholder` from page 5 is never touched.
10. Prints `200` when the live site answers; `-f` makes the script fail on any HTTP error.

</Annotated>

Make it executable, so it runs as `./scripts/deploy.sh`:

```bash on="mac"
chmod +x scripts/deploy.sh
```

<Deep>The rsync flags are not the usual `-a`, on purpose. `-a` includes `-t` (keep modification times) and `-g` (keep the group). Every `npm run build` rewrites every file in `out/`, even unchanged ones, so their times always differ, and rsync hard-links from `--link-dest` only when content, permissions, times and owner all match: with `-a`, nothing would ever be linked and each release would cost the full 200-odd MB. With `-rlp --checksum`, files are compared by content; an unchanged photo keeps the inode, and so the timestamp, of the release that first uploaded it. Caddy's `ETag` and `Last-Modified` are built from that timestamp, so browsers keep their cached copy of unchanged media across deploys. `--checksum` reads every file on both sides on each deploy: a second or two for this site. `--delete` does nothing in a fresh directory; it makes a re-run into an existing one exact.</Deep>

<Deep>`ln -sfn` alone promises no atomicity: it may remove `current` and then create the new link, and a request landing between the two gets a 404. `mv -T current.tmp current` is a single `rename(2)` system call, which replaces the old link in one step: every request sees either the old release or the new one. `-T` stops `mv` from moving `current.tmp` into the directory `current` points to. Both `mv -T` and `head -n -N` are GNU options; they run on the Debian side, which is why the swap and the prune go through `ssh` rather than running on the Mac. A request already reading a file when the link moves finishes from the old release, which is still on disk. One visible edge: a visitor whose page was loaded before the swap may request an old `/_next/static/` chunk; it is gone from the new release, and Next.js falls back to a full page load.</Deep>

## Deploy for the first time

Run the script from the project folder. The last line of the output should be `200`; open https://<V name="DOMAIN" /> and your site replaces the placeholder. The first upload sends the whole site (about 220 MB with the media in `public/`); the next ones send only what changed.

<Warn>From the deploy after the <V name="KEEP_RELEASES" />th on, each run permanently deletes the oldest releases on the server. Your project folder still has the source; the server keeps only what `KEEP` allows.</Warn>

```bash on="mac"
cd ${LOCAL_DIR}
./scripts/deploy.sh
```

<Check cmd="ssh vps-deploy 'readlink ${WEB_ROOT}/current | cut -c1-11'" expect="releases/20" />

<Note>`scripts/deploy.sh`, and `scripts/rollback.sh` below, live in the project folder next to `package.json`. The Mac backup from page 1 covers them like the rest of the site.</Note>

<Guided>`current` now points to `releases/` followed by the timestamp, and the placeholder release is still next to it. Once you are happy with the real site, remove it; the script never does it for you.</Guided>

```bash on="mac"
ssh vps-deploy 'rm -rf ${WEB_ROOT}/releases/placeholder'
```

<Deep>`du` counts a hard-linked file once, in the first directory where it meets it. After two deploys, `du -sh releases/*` in the web root shows the first release at full size and the next ones at the size of what changed. That is why keeping <V name="KEEP_RELEASES" /> releases costs little disk on a 40 GB VPS.</Deep>

## Roll back

Rolling back is the swap again, pointed at an older release. This script points `current` at the release immediately before the live one, or at the one you name. Copy the command and paste it in the terminal, in the project folder:

```bash file="scripts/rollback.sh" on="mac"
#!/usr/bin/env bash
# Usage: ./scripts/rollback.sh [release-name]
set -euo pipefail
HOST=vps-deploy
ROOT=${WEB_ROOT}
CUR=$(ssh "$HOST" "readlink $ROOT/current")
CUR=$(basename "$CUR")
LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")
if [ $# -gt 0 ]; then
  TARGET=$1
else
  TARGET=$(printf '%s\n' "$LIST" | awk -v c="$CUR" '$0 == c { print p; exit } { p = $0 }')
fi
[ -n "$TARGET" ] || { echo "No release older than $CUR." >&2; exit 1; }
printf '%s\n' "$LIST" | grep -qx "$TARGET" || { echo "Unknown release: $TARGET" >&2; exit 1; }
ssh "$HOST" "cd $ROOT && ln -sfn releases/$TARGET current.tmp && mv -T current.tmp current"
echo "current: $CUR -> $TARGET"
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/
```

Make it executable, deploy once more so the server holds two releases, list them, and roll back to the first:

```bash on="mac"
chmod +x scripts/rollback.sh
./scripts/deploy.sh
ssh vps-deploy 'ls -1 ${WEB_ROOT}/releases'
./scripts/rollback.sh
```

<Guided>Run the script twice and it steps back twice: each run looks for the release before the one that is live. To go forward again, name the release: `./scripts/rollback.sh 20260926T141500Z`, or deploy again. The rollback does not touch your project folder: the next `./scripts/deploy.sh` publishes whatever is in it. To stay on the old version, undo the change in the folder before the next deploy.</Guided>

## Done

A deploy is now one command, `./scripts/deploy.sh`, and a rollback another, `./scripts/rollback.sh`. Each release is a directory named by its UTC deploy time, the live one is whatever `current` points to, and the switch between them is a single rename. The server keeps the last <V name="KEEP_RELEASES" />.

The next page, **Backups, monitoring and the monthly routine**, makes sure you hear about it when the site goes down, and that the server can be rebuilt if the VPS is lost.
````

````mdx title="content/ovh-vps-static-site/deploy-releases/page-fr.mdx"
{/* Premier jet — à valider avant publication contre man rsync (--link-dest, --checksum, --chmod), man sshd (AUTHORIZED_KEYS FILE FORMAT, restrict) et man mv (-T). */}

Le site est servi, mais ce n'est qu'une page d'attente. Cette page te donne une commande, `./scripts/deploy.sh`, qui construit le site sur ton portable, envoie seulement ce qui a changé dans un nouveau répertoire de version sur le serveur, bascule le site en ligne dessus en une étape atomique, supprime les anciennes versions au-delà des <V name="KEEP_RELEASES" /> dernières, et vérifie que https://<V name="DOMAIN" /> répond. Une seconde commande revient en arrière. Les envois passent par un compte dédié, <V name="DEPLOY_USER" />, avec sa propre clé, sans sudo, et dont la racine web est la seule possession.

<Run>

Ouvre un nouveau fichier `deploy-setup.sh` dans le dossier du projet, colles-y tous les blocs de code de cette section dans l'ordre, puis lance `bash deploy-setup.sh`. Il tourne **sur ton portable**, depuis <V name="LOCAL_DIR" />, sous ton compte. Il lui faut `ssh vps` fonctionnel (page 3), Caddy qui sert <V name="WEB_ROOT" />/current (page 5), et rsync 3 de Homebrew. L'unique appel `ssh -t vps sudo …` te demande ton mot de passe sudo du serveur, une fois.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Deploy releases — ${DOMAIN}
cd ${LOCAL_DIR}
rsync --version | grep 'version 3\.' >/dev/null || { echo "Install rsync 3 first: brew install rsync" >&2; exit 1; }

# 1. On the laptop: a key used only for deploys
[ -f ~/.ssh/id_ed25519_deploy ] || ssh-keygen -t ed25519 -N "" -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy

# 2. On the server: deploy user, restricted key, web root ownership
SETUP=$(mktemp)
cat > "$SETUP" <<'EOF'
set -eu
apt-get -o DPkg::Lock::Timeout=300 install -y -q rsync >/dev/null
id -u ${DEPLOY_USER} >/dev/null 2>&1 || adduser --disabled-password --gecos "" ${DEPLOY_USER}
usermod -aG sshusers ${DEPLOY_USER}
install -d -m 700 -o ${DEPLOY_USER} -g ${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh
sed 's/^/restrict /' /home/${USERNAME}/deploy.pub > /home/${DEPLOY_USER}/.ssh/authorized_keys
chown ${DEPLOY_USER}:${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh/authorized_keys
chmod 600 /home/${DEPLOY_USER}/.ssh/authorized_keys
chown -R ${DEPLOY_USER}:${DEPLOY_USER} ${WEB_ROOT}
rm /home/${USERNAME}/deploy.pub
EOF
scp -q ~/.ssh/id_ed25519_deploy.pub vps:deploy.pub
scp -q "$SETUP" vps:setup-deploy-user.sh
rm "$SETUP"
ssh -t vps 'sudo bash setup-deploy-user.sh && rm setup-deploy-user.sh'

# 3. On the laptop: the vps-deploy alias
grep -q '^Host vps-deploy$' ~/.ssh/config 2>/dev/null || printf '\nHost vps-deploy\n  HostName ${SERVER_IP}\n  Port ${SSH_PORT}\n  User ${DEPLOY_USER}\n  IdentityFile ~/.ssh/id_ed25519_deploy\n  IdentitiesOnly yes\n' >> ~/.ssh/config
ssh vps-deploy 'ls ${WEB_ROOT}'

# 4. The deploy and rollback scripts, in the project folder
mkdir -p scripts
cat > scripts/deploy.sh <<'EOF'
#!/usr/bin/env bash
# Build the site and publish it as a new release. Usage: ./scripts/deploy.sh
set -euo pipefail
cd "$(dirname "$0")/.."                                         # (1)

HOST=vps-deploy                                                 # (2)
ROOT=${WEB_ROOT}
KEEP=${KEEP_RELEASES}
[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }   # (3)

npm ci                                                          # (4)
npm run build
test -f out/index.html                                          # (5)

REL=$(date -u +%Y%m%dT%H%M%SZ)                                  # (6)
echo "Release $REL"

rsync -rlpz --checksum --delete --chmod=D755,F644 \
  --link-dest="$ROOT/current/" \
  out/ "$HOST:$ROOT/releases/$REL/"                             # (7)

ssh "$HOST" "cd $ROOT && ln -sfn releases/$REL current.tmp && mv -T current.tmp current"   # (8)
ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort | head -n -$KEEP | xargs -r rm -rf --" # (9)
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/     # (10)
EOF
cat > scripts/rollback.sh <<'EOF'
#!/usr/bin/env bash
# Usage: ./scripts/rollback.sh [release-name]
set -euo pipefail
HOST=vps-deploy
ROOT=${WEB_ROOT}
CUR=$(ssh "$HOST" "readlink $ROOT/current")
CUR=$(basename "$CUR")
LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")
if [ $# -gt 0 ]; then
  TARGET=$1
else
  TARGET=$(printf '%s\n' "$LIST" | awk -v c="$CUR" '$0 == c { print p; exit } { p = $0 }')
fi
[ -n "$TARGET" ] || { echo "No release older than $CUR." >&2; exit 1; }
printf '%s\n' "$LIST" | grep -qx "$TARGET" || { echo "Unknown release: $TARGET" >&2; exit 1; }
ssh "$HOST" "cd $ROOT && ln -sfn releases/$TARGET current.tmp && mv -T current.tmp current"
echo "current: $CUR -> $TARGET"
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/
EOF
chmod +x scripts/deploy.sh scripts/rollback.sh

# 5. First deploy
./scripts/deploy.sh
ssh vps-deploy 'readlink ${WEB_ROOT}/current'
```

<Warn>Le script de déploiement supprime, sur le serveur, chaque répertoire de version au-delà des <V name="KEEP_RELEASES" /> plus récents dont le nom commence par `20`. Rien d'autre dans <V name="WEB_ROOT" /> n'est touché.</Warn>

</Run>

## Avant de commencer

Il te faut les pages 1 à 5 terminées : le dossier du projet sur ton portable (page 1), `ssh vps` qui te connecte en <V name="USERNAME" />, et Caddy qui sert la page d'attente sur https://<V name="DOMAIN" />.

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

Le `rsync` fourni avec le Mac n'est pas celui qu'il te faut. Installe rsync 3 avec Homebrew (installe d'abord Homebrew depuis brew.sh si la commande `brew` manque) :

```bash on="mac"
brew install rsync
```

Puis ferme ce terminal et ouvres-en un nouveau (Cmd+N dans Terminal). Le shell déjà ouvert peut continuer à lancer `/usr/bin/rsync` : il retient où il a trouvé une commande, et si Homebrew vient d'y être installé, son `PATH` ne contient pas encore `/opt/homebrew/bin`. Un nouveau shell relit ton `PATH` et trouve d'abord le rsync de Homebrew. Dans le nouveau terminal :

```bash on="mac"
rsync --version | head -1
```

<Check cmd="rsync --version | head -1 | grep -o 'version 3'" expect="version 3" />

<Deep>Selon la version de macOS, `/usr/bin/rsync` est soit l'antique rsync 2.6.9, soit `openrsync`, une autre implémentation derrière un wrapper. Les deux parlent le protocole rsync, mais leurs options et leurs cas limites diffèrent de rsync 3, celui que Debian fait tourner à l'autre bout. Avec le rsync 3.x de Homebrew des deux côtés, chaque option utilisée plus bas se comporte comme le décrit `man rsync`. Vérifie quel binaire ton shell choisit avec `which rsync` : il doit être sous `/opt/homebrew/bin`.</Deep>

## Créer l'utilisateur de déploiement

Ton compte admin a sudo ; le compte qui envoie le site ne doit pas l'avoir, et il reçoit sa propre clé. Sur le portable, crée la clé de déploiement si elle n'existe pas encore et copie sa moitié publique sur le serveur :

```bash on="mac"
[ -f ~/.ssh/id_ed25519_deploy ] || ssh-keygen -t ed25519 -N "" -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy
scp ~/.ssh/id_ed25519_deploy.pub vps:deploy.pub
```

Puis connecte-toi au serveur comme d'habitude :

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

<Deep>La clé de déploiement n'a pas de phrase de passe, parce que les scripts doivent tourner sans question. C'est acceptable ici : la clé n'ouvre que <V name="DEPLOY_USER" />, qui peut écrire la racine web et rien d'autre, et FileVault protège le disque où elle se trouve quand le Mac est éteint. Si tu préfères une phrase de passe, range-la dans le trousseau macOS (`ssh-add --apple-use-keychain ~/.ssh/id_ed25519_deploy`, plus `UseKeychain yes` dans le bloc d'hôte plus bas) et les scripts tournent toujours sans surveillance.</Deep>

Sur le serveur, installe rsync (le côté qui reçoit), crée l'utilisateur, laisse-le passer SSH, donne-lui la racine web, et installe la clé de déploiement pour lui, précédée de `restrict`. La dernière ligne, `exit`, te ramène sur le portable :

```bash on="server" as="${USERNAME}"
sudo apt install -y rsync
sudo adduser --disabled-password --gecos "" ${DEPLOY_USER}
sudo usermod -aG sshusers ${DEPLOY_USER}
sudo chown -R ${DEPLOY_USER}:${DEPLOY_USER} ${WEB_ROOT}
sudo install -d -m 700 -o ${DEPLOY_USER} -g ${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh
sed 's/^/restrict /' deploy.pub | sudo tee /home/${DEPLOY_USER}/.ssh/authorized_keys
sudo chown ${DEPLOY_USER}:${DEPLOY_USER} /home/${DEPLOY_USER}/.ssh/authorized_keys
sudo chmod 600 /home/${DEPLOY_USER}/.ssh/authorized_keys
rm deploy.pub
exit
```

<Guided>`--disabled-password` crée un compte sans aucun mot de passe : il ne peut se connecter qu'avec une clé. Le groupe `sshusers` est celui que `AllowGroups` laisse entrer depuis la page 3 ; sans lui, sshd refuse l'utilisateur avant même de regarder sa clé. `chown -R` confie `releases/`, la page d'attente et le lien `current` à l'utilisateur de déploiement, pour qu'il puisse ajouter des versions et déplacer le lien sans sudo. `tee` réaffiche la ligne de clé écrite, qui commence par `restrict ssh-ed25519` et finit par `deploy@laptop`.</Guided>

<Guided>`restrict` est une option placée devant la clé. Elle coupe tout ce dont un déploiement n'a jamais besoin : pas de pseudo-terminal, pas de redirection de port, pas de transfert d'agent ni de X11. Les commandes s'exécutent toujours, et c'est tout ce qu'il faut à rsync et à la bascule du lien. `sshd` ignore un fichier `authorized_keys` modifiable par d'autres, d'où le `700` sur le dossier et le `600` sur le fichier.</Guided>

<Warn>N'ajoute jamais <V name="DEPLOY_USER" /> au groupe `sudo`, et ne lui donne jamais de règle sudoers. Sa clé est sur ton portable, sans phrase de passe, et sert aux scripts. Avec sudo, une copie volée posséderait le serveur au lieu du site.</Warn>

<Deep>Pourquoi un compte à part et une clé à part : le rayon d'explosion. `id_ed25519_deploy` n'est autorisée que pour <V name="DEPLOY_USER" />, et ta clé admin n'est pas autorisée pour <V name="DEPLOY_USER" />. Si la clé de déploiement fuit (un fichier copié, une sauvegarde restaurée ailleurs), l'attaquant peut réécrire <V name="WEB_ROOT" /> et rien d'autre : pas de sudo, pas de shell avec terminal, pas de tunnel vers le serveur. L'option `from="…"` épinglerait en plus la clé à des adresses sources, ce qui marche pour une IP fixe à la maison mais pas pour un portable qui déploie depuis un café, un train ou un hôtel. Deux crans de plus si tu veux : `command="rrsync …"` (le paquet rsync de Debian fournit `rrsync`) limite la clé à rsync seul, mais alors la bascule et le nettoyage par `ssh` du script sont refusés, et il faut les déplacer côté serveur ; et un `authorized_keys` appartenant à root (`root:root`, mode 644) empêche une clé volée d'ajouter ses propres clés, puisque sshd accepte les fichiers appartenant à root.</Deep>

<Note>Caddy tourne sous l'utilisateur `caddy` et n'a besoin que de lire : répertoires en `755`, fichiers en `644`. Le script de déploiement impose exactement ces droits avec `--chmod`, quels que soient ceux de ton Mac.</Note>

## Ajouter l'alias sur le portable

Ajoute un second hôte dans `~/.ssh/config` sur ton portable, à côté de `vps` :

```bash on="mac"
cat >> ~/.ssh/config <<'EOF'

Host vps-deploy
  HostName ${SERVER_IP}
  Port ${SSH_PORT}
  User ${DEPLOY_USER}
  IdentityFile ~/.ssh/id_ed25519_deploy
  IdentitiesOnly yes
EOF
```

<Guided>Même serveur, même port, autre utilisateur et autre clé. `IdentityFile` choisit la clé de déploiement ; `IdentitiesOnly yes` limite ssh aux clés nommées dans la config : ta clé admin (chargée dans l'agent depuis `ssh vps`) peut être proposée d'abord et elle est refusée, puisque l'utilisateur de déploiement ne lui fait pas confiance. Tous les scripts de cette page parlent à `vps-deploy`, donc aucun ne répète l'IP, le port ni la clé. La clé d'hôte est déjà dans ton `known_hosts` depuis la page 3 : pas de question sur l'empreinte.</Guided>

<Check cmd="ssh vps-deploy 'ls ${WEB_ROOT}'" expect="current
releases" />

<Details summary="Si tu obtiens Permission denied (publickey)">

Par ordre de probabilité : l'utilisateur n'est pas dans le groupe `sshusers`, les droits sur /home/<V name="DEPLOY_USER" />/.ssh sont trop ouverts, la ligne de clé a perdu son préfixe `ssh-ed25519`, ou le chemin `IdentityFile` ne correspond pas à la clé que tu as créée. Sur le serveur, `sudo journalctl -u ssh -n 20` donne la raison.

</Details>

## Écrire le script de déploiement

Crée le dossier `scripts` dans le projet :

```bash on="mac"
cd ${LOCAL_DIR} && mkdir -p scripts
```

Puis écris `scripts/deploy.sh` : copie la commande et colle-la dans le même terminal. Le chemin est relatif, donc le fichier atterrit dans le dossier du projet où tu viens d'entrer.

<Annotated>

```bash file="scripts/deploy.sh" on="mac"
#!/usr/bin/env bash
# Build the site and publish it as a new release. Usage: ./scripts/deploy.sh
set -euo pipefail
cd "$(dirname "$0")/.."                                         # (1)

HOST=vps-deploy                                                 # (2)
ROOT=${WEB_ROOT}
KEEP=${KEEP_RELEASES}
[ "$KEEP" -ge 1 ] || { echo "KEEP_RELEASES must be at least 1" >&2; exit 1; }   # (3)

npm ci                                                          # (4)
npm run build
test -f out/index.html                                          # (5)

REL=$(date -u +%Y%m%dT%H%M%SZ)                                  # (6)
echo "Release $REL"

rsync -rlpz --checksum --delete --chmod=D755,F644 \
  --link-dest="$ROOT/current/" \
  out/ "$HOST:$ROOT/releases/$REL/"                             # (7)

ssh "$HOST" "cd $ROOT && ln -sfn releases/$REL current.tmp && mv -T current.tmp current"   # (8)
ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort | head -n -$KEEP | xargs -r rm -rf --" # (9)
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/     # (10)
```

1. Part de la racine du projet d'où que tu l'appelles, et s'arrête à la première commande qui échoue.
2. L'alias de l'étape précédente. Tes valeurs sont déjà écrites dans `ROOT` et `KEEP`.
3. Avec 0, le `head -n -0` plus bas listerait toutes les versions, celle en ligne comprise, et les supprimerait toutes. Le script refuse de démarrer à la place.
4. `npm ci` installe exactement ce que dit `package-lock.json` : chaque build part des mêmes dépendances.
5. Un build raté ou vide s'arrête ici au lieu de remplacer le site en ligne par rien.
6. Le nom de version est l'heure UTC du déploiement, par exemple `20260926T141500Z`. Trier les noms comme du texte les trie par date, et l'UTC ne recule jamais d'une heure à l'automne.
7. Envoie `out/` dans un nouveau répertoire. Les fichiers identiques à la version en ligne sont liés en dur sur le serveur au lieu d'être envoyés ; seuls les fichiers modifiés traversent le réseau.
8. La bascule atomique : un nouveau lien est préparé à côté de `current`, puis renommé par-dessus en une étape.
9. Supprime chaque version au-delà des <V name="KEEP_RELEASES" /> plus récentes. Seuls les noms qui commencent par `20` sont concernés : le `placeholder` de la page 5 n'est jamais touché.
10. Affiche `200` quand le site en ligne répond ; `-f` fait échouer le script sur toute erreur HTTP.

</Annotated>

Rends-le exécutable, pour qu'il se lance en `./scripts/deploy.sh` :

```bash on="mac"
chmod +x scripts/deploy.sh
```

<Deep>Les options de rsync ne sont pas le `-a` habituel, exprès. `-a` inclut `-t` (garder les dates de modification) et `-g` (garder le groupe). Chaque `npm run build` réécrit tous les fichiers de `out/`, même inchangés, donc leurs dates diffèrent toujours, et rsync ne lie en dur depuis `--link-dest` que si contenu, droits, dates et propriétaire correspondent tous : avec `-a`, rien ne serait jamais lié et chaque version coûterait ses 200 et quelques Mo. Avec `-rlp --checksum`, les fichiers sont comparés par contenu ; une photo inchangée garde l'inode, et donc la date, de la version qui l'a envoyée la première. Le `ETag` et le `Last-Modified` de Caddy sont calculés à partir de cette date : les navigateurs gardent leur copie en cache des médias inchangés d'un déploiement à l'autre. `--checksum` relit chaque fichier des deux côtés à chaque déploiement : une seconde ou deux pour ce site. `--delete` ne fait rien dans un répertoire neuf ; il rend exacte une relance dans un répertoire existant.</Deep>

<Deep>`ln -sfn` seul ne promet aucune atomicité : il peut supprimer `current` puis créer le nouveau lien, et une requête qui tombe entre les deux prend une 404. `mv -T current.tmp current` est un seul appel système `rename(2)`, qui remplace l'ancien lien en une étape : chaque requête voit soit l'ancienne version, soit la nouvelle. `-T` empêche `mv` de déplacer `current.tmp` dans le répertoire vers lequel pointe `current`. `mv -T` et `head -n -N` sont des options GNU ; elles tournent côté Debian, c'est pourquoi la bascule et le nettoyage passent par `ssh` au lieu de tourner sur le Mac. Une requête déjà en train de lire un fichier quand le lien bouge finit sur l'ancienne version, toujours sur le disque. Un effet visible : un visiteur dont la page a été chargée avant la bascule peut demander un ancien morceau de `/_next/static/` ; il a disparu de la nouvelle version, et Next.js se rabat sur un chargement complet de la page.</Deep>

## Déployer pour la première fois

Lance le script depuis le dossier du projet. La dernière ligne affichée doit être `200` ; ouvre https://<V name="DOMAIN" /> et ton site remplace la page d'attente. Le premier envoi transfère tout le site (environ 220 Mo avec les médias de `public/`) ; les suivants seulement ce qui a changé.

<Warn>À partir du déploiement qui suit le <V name="KEEP_RELEASES" />e, chaque exécution supprime définitivement les plus anciennes versions sur le serveur. Ton dossier de projet garde les sources ; le serveur ne garde que ce que `KEEP` permet.</Warn>

```bash on="mac"
cd ${LOCAL_DIR}
./scripts/deploy.sh
```

<Check cmd="ssh vps-deploy 'readlink ${WEB_ROOT}/current | cut -c1-11'" expect="releases/20" />

<Note>`scripts/deploy.sh`, et `scripts/rollback.sh` plus bas, vivent dans le dossier du projet à côté de `package.json`. La sauvegarde du Mac mise en place en page 1 les couvre comme le reste du site.</Note>

<Guided>`current` pointe maintenant vers `releases/` suivi de l'horodatage, et la version d'attente est toujours à côté. Une fois content du vrai site, supprime-la ; le script ne le fait jamais à ta place.</Guided>

```bash on="mac"
ssh vps-deploy 'rm -rf ${WEB_ROOT}/releases/placeholder'
```

<Deep>`du` compte un fichier lié en dur une seule fois, dans le premier répertoire où il le rencontre. Après deux déploiements, `du -sh releases/*` dans la racine web montre la première version à taille pleine et les suivantes à la taille de ce qui a changé. C'est pourquoi garder <V name="KEEP_RELEASES" /> versions coûte peu de disque sur un VPS de 40 Go.</Deep>

## Revenir en arrière

Revenir en arrière, c'est refaire la bascule vers une version plus ancienne. Ce script pointe `current` vers la version juste avant celle en ligne, ou vers celle que tu nommes. Copie la commande et colle-la dans le terminal, dans le dossier du projet :

```bash file="scripts/rollback.sh" on="mac"
#!/usr/bin/env bash
# Usage: ./scripts/rollback.sh [release-name]
set -euo pipefail
HOST=vps-deploy
ROOT=${WEB_ROOT}
CUR=$(ssh "$HOST" "readlink $ROOT/current")
CUR=$(basename "$CUR")
LIST=$(ssh "$HOST" "cd $ROOT/releases && ls -1d 20* | sort")
if [ $# -gt 0 ]; then
  TARGET=$1
else
  TARGET=$(printf '%s\n' "$LIST" | awk -v c="$CUR" '$0 == c { print p; exit } { p = $0 }')
fi
[ -n "$TARGET" ] || { echo "No release older than $CUR." >&2; exit 1; }
printf '%s\n' "$LIST" | grep -qx "$TARGET" || { echo "Unknown release: $TARGET" >&2; exit 1; }
ssh "$HOST" "cd $ROOT && ln -sfn releases/$TARGET current.tmp && mv -T current.tmp current"
echo "current: $CUR -> $TARGET"
curl -fsS -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/
```

Rends-le exécutable, déploie une fois de plus pour que le serveur ait deux versions, liste-les, et reviens à la première :

```bash on="mac"
chmod +x scripts/rollback.sh
./scripts/deploy.sh
ssh vps-deploy 'ls -1 ${WEB_ROOT}/releases'
./scripts/rollback.sh
```

<Guided>Lance le script deux fois et il recule deux fois : chaque exécution cherche la version qui précède celle en ligne. Pour revenir en avant, nomme la version : `./scripts/rollback.sh 20260926T141500Z`, ou redéploie. Le retour arrière ne touche pas à ton dossier de projet : le prochain `./scripts/deploy.sh` publie ce qu'il contient. Pour rester sur l'ancienne version, annule la modification dans le dossier avant le prochain déploiement.</Guided>

## Terminé

Un déploiement tient maintenant en une commande, `./scripts/deploy.sh`, et un retour arrière en une autre, `./scripts/rollback.sh`. Chaque version est un répertoire nommé par l'heure UTC de son déploiement, celle en ligne est celle vers laquelle pointe `current`, et le passage de l'une à l'autre est un seul renommage. Le serveur garde les <V name="KEEP_RELEASES" /> dernières.

La page suivante, **Sauvegardes, surveillance et la routine mensuelle**, fait en sorte que tu sois prévenu quand le site tombe, et que le serveur puisse être reconstruit si le VPS est perdu.
````

````yaml title="content/ovh-vps-static-site/deploy-releases/diagram.yaml"
# Quick: the build goes from the laptop into a new release, current is swapped onto it, Caddy
# serves current. Guided: the deploy user that receives the upload, the previous releases that
# share unchanged files, and the rollback. Deep: the restricted key, and the mechanisms on the
# arrows (rsync flags, rename(2), hard links).
title: { en: "Build, upload, swap one link", fr: "Construire, envoyer, basculer un lien" }
caption:
  en: "Each deploy lands in its own directory under releases/. The live site is whatever current points to, and moving that link is a single atomic rename, so visitors never see a half-uploaded site."
  fr: "Chaque déploiement arrive dans son propre répertoire sous releases/. Le site en ligne est ce vers quoi pointe current, et déplacer ce lien est un seul renommage atomique : les visiteurs ne voient jamais un site à moitié envoyé."

groups:
  - id: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The OVH server. Everything under ${WEB_ROOT} belongs to ${DEPLOY_USER}; Caddy only reads it."
      fr: "Le serveur OVH. Tout ce qui est sous ${WEB_ROOT} appartient à ${DEPLOY_USER} ; Caddy ne fait que le lire."

nodes:
  - id: laptop
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "./scripts/deploy.sh"
    desc:
      en: "Where you run one command. It builds, uploads, swaps, prunes and checks."
      fr: "Là où tu lances une commande. Elle construit, envoie, bascule, nettoie et vérifie."
    deep:
      sub: "deploy.sh · rsync 3 (Homebrew) · ~/.ssh/id_ed25519_deploy"
  - id: out
    kind: file
    label: { en: "out/", fr: "out/" }
    sub: "npm run build"
    desc:
      en: "The static export of the Next.js site: one index.html per route, 404.html, hashed assets under _next/static, and the media from public/."
      fr: "L'export statique du site Next.js : un index.html par route, 404.html, les ressources hachées sous _next/static, et les médias de public/."
    deep:
      sub: "output: export · trailingSlash · ~220 MB"
  - id: deploy-user
    kind: user
    label: { en: "Deploy user", fr: "Utilisateur de déploiement" }
    sub: "${DEPLOY_USER} · no sudo"
    in: server
    level: guided
    desc:
      en: "The account every upload goes through. It has its own key and no sudo, and owns ${WEB_ROOT}: a leaked deploy key can change the site, not the server."
      fr: "Le compte par lequel passe chaque envoi. Il a sa propre clé, pas de sudo, et possède ${WEB_ROOT} : une clé de déploiement volée peut changer le site, pas le serveur."
    deep:
      sub: "sshd :${SSH_PORT} · group sshusers · no pty, no forwarding"
  - id: keys
    kind: file
    label: { en: "authorized_keys", fr: "authorized_keys" }
    sub: "restrict ssh-ed25519 … deploy@laptop"
    in: server
    level: deep
    desc:
      en: "One line per key allowed in as ${DEPLOY_USER}: only the deploy key from your laptop, never your admin key. The restrict prefix removes terminals and forwarding; commands still run."
      fr: "Une ligne par clé autorisée à entrer en ${DEPLOY_USER} : seulement la clé de déploiement de ton portable, jamais ta clé admin. Le préfixe restrict retire terminal et redirections ; les commandes s'exécutent toujours."
    deep:
      sub: "/home/${DEPLOY_USER}/.ssh/authorized_keys · 600"
  - id: release
    kind: file
    label: { en: "New release", fr: "Nouvelle version" }
    sub: "releases/<UTC timestamp>"
    in: server
    desc:
      en: "A complete copy of the site in its own directory, named by its UTC deploy time. It is fully uploaded before anyone can see it."
      fr: "Une copie complète du site dans son propre répertoire, nommée par l'heure UTC de son déploiement. Elle est entièrement envoyée avant que quiconque puisse la voir."
    deep:
      sub: "${WEB_ROOT}/releases/20…Z · D755 F644"
  - id: previous
    kind: file
    label: { en: "Previous releases", fr: "Versions précédentes" }
    sub: "last ${KEEP_RELEASES} kept"
    in: server
    level: guided
    desc:
      en: "The releases before this one, pruned to the newest ${KEEP_RELEASES}. They are what rollback points back to."
      fr: "Les versions d'avant, réduites aux ${KEEP_RELEASES} plus récentes. C'est vers elles que pointe un retour arrière."
    deep:
      sub: "ls -1d 20* | sort | head -n -${KEEP_RELEASES} → rm -rf"
  - id: current
    kind: file
    label: { en: "current", fr: "current" }
    sub: "symlink"
    in: server
    focus: true
    desc:
      en: "A symbolic link to the live release. Deploying and rolling back both come down to moving this one link, atomically."
      fr: "Un lien symbolique vers la version en ligne. Déployer comme revenir en arrière se résument à déplacer ce seul lien, de façon atomique."
    guided:
      sub: "symlink → releases/<new>"
    deep:
      sub: "ln -sfn current.tmp · mv -T → rename(2)"
  - id: caddy
    kind: service
    label: { en: "Caddy", fr: "Caddy" }
    sub: "${WEB_ROOT}/current"
    in: server
    desc:
      en: "Serves whatever current points to, resolving the link on every request. It needs no reload after a deploy."
      fr: "Sert ce vers quoi pointe current, en résolvant le lien à chaque requête. Aucun rechargement n'est nécessaire après un déploiement."
    deep:
      sub: "file_server · user caddy (read only) · ETag from mtime + size"
  - id: visitors
    kind: user
    label: { en: "Visitors", fr: "Visiteurs" }
    sub: "https://${DOMAIN}"
    desc:
      en: "They see either the old release or the new one, never a mix. The deploy script ends by checking that they get a 200."
      fr: "Ils voient soit l'ancienne version, soit la nouvelle, jamais un mélange. Le script de déploiement finit en vérifiant qu'ils obtiennent un 200."

edges:
  - from: laptop
    to: out
    label: "npm run build"
    deep: { label: "npm ci · npm run build · test -f out/index.html" }
    desc: { en: "A clean install and a static build, stopped here if out/index.html is missing.", fr: "Une installation propre et un build statique, arrêtés ici si out/index.html manque." }
  - from: out
    to: release
    label: "rsync over SSH"
    max: quick
    desc: { en: "Only changed files cross the network; unchanged ones are linked on the server.", fr: "Seuls les fichiers modifiés traversent le réseau ; les autres sont liés sur le serveur." }
  - from: out
    to: deploy-user
    label: "rsync · SSH :${SSH_PORT}"
    level: guided
    deep: { label: "rsync -rlpz --checksum · SSH TCP ${SSH_PORT} · vps-deploy" }
    desc: { en: "rsync runs over SSH as ${DEPLOY_USER}, through the vps-deploy alias and its dedicated key.", fr: "rsync passe par SSH en tant que ${DEPLOY_USER}, via l'alias vps-deploy et sa clé dédiée." }
  - from: deploy-user
    to: release
    label: "writes"
    level: guided
    desc: { en: "The remote rsync, running as ${DEPLOY_USER}, creates the new release directory.", fr: "Le rsync distant, qui tourne en ${DEPLOY_USER}, crée le nouveau répertoire de version." }
  - from: deploy-user
    to: keys
    label: "checks key"
    dashed: true
    level: deep
    desc: { en: "sshd accepts the connection only if the key is listed here; restrict then limits what the session can do.", fr: "sshd n'accepte la connexion que si la clé est listée ici ; restrict limite ensuite ce que la session peut faire." }
  - from: previous
    to: release
    label: "hard links"
    dashed: true
    level: guided
    deep: { label: "--link-dest=current/ · same content → same inode" }
    desc: { en: "Files identical to the live release are hard-linked instead of copied, so each release costs only what changed.", fr: "Les fichiers identiques à la version en ligne sont liés en dur au lieu d'être copiés : chaque version ne coûte que ce qui a changé." }
  - from: release
    to: current
    label: "atomic swap"
    deep: { label: "mv -T current.tmp current · rename(2)" }
    desc: { en: "The link moves in one system call. A request sees the old release or the new one, never nothing.", fr: "Le lien bouge en un seul appel système. Une requête voit l'ancienne version ou la nouvelle, jamais rien." }
  - from: current
    to: previous
    label: "rollback"
    dashed: true
    level: guided
    desc: { en: "rollback.sh moves the same link back to the release before, with the same atomic rename.", fr: "rollback.sh remet le même lien sur la version d'avant, avec le même renommage atomique." }
  - from: current
    to: caddy
    label: "serves"
    desc: { en: "Caddy follows the link on each request, so the swap takes effect immediately, without a reload.", fr: "Caddy suit le lien à chaque requête : la bascule prend effet aussitôt, sans rechargement." }
  - from: caddy
    to: visitors
    label: "HTTPS"
    deep: { label: "HTTPS · HTTP/2 + HTTP/3 · TCP/UDP 443" }
    desc: { en: "The same site as before the page, now with your real content.", fr: "Le même site qu'avant cette page, maintenant avec ton vrai contenu." }
````

---

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