# Source of "Préparer ton portable"

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/prepare-the-laptop/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PUBKEY, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.

title:
  en: Prepare your laptop
  fr: Préparer ton portable
summary:
  en: >-
    Before ordering anything: the site builds cleanly from scratch, you know what you are about
    to ship, the project folder (the only original of the site) is backed up, and ssh on your Mac
    keeps passphrases in the Keychain. The server's SSH key is made on the next page.
  fr: >-
    Avant de commander quoi que ce soit : le site se construit proprement à partir de zéro, tu
    sais ce que tu vas envoyer, le dossier du projet (le seul original du site) est sauvegardé, et
    ssh sur ton Mac garde les phrases de passe dans le trousseau. La clé SSH du serveur se crée à
    la page suivante.
difficulty: beginner
tags: [ssh, nextjs, macos, backup, time-machine]
authors: [thudal]
created: 2026-09-26
minutes: 20
validated: macOS 15 · Node 22 · Next.js 16
status: draft             # not yet run end to end by its author
````

````mdx title="content/ovh-vps-static-site/prepare-the-laptop/page-en.mdx"
{/* First pass — to be validated against nextjs.org/docs/app/guides/static-exports, support.apple.com (Back up your Mac with Time Machine; Full Disk Access), man tmutil, man ssh-add (Apple) and man ssh_config before publishing. */}

Before you order a server, get your Mac in order. This page makes sure the site builds from nothing, shows you what you are about to ship, checks that the project folder is backed up (it is the only original of the site), and sets ssh on this Mac to keep passphrases in the Keychain. No SSH key is made here: the next page makes one for the server, right before the OVH order form.

<Run>

The script runs **on your Mac**, as you, in the project folder <V name="LOCAL_DIR" />. It expects Node 22. It only reports on the backup; it never fails because of it.

Open a new file `laptop-setup.sh` in the project folder, paste every code block of this section into it in order, then run `bash laptop-setup.sh`.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Prepare your laptop — ${LOCAL_DIR}
cd ${LOCAL_DIR}

# SSH: the agent and the Keychain hold passphrases (no key here: the server key is made on the next page)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q 'UseKeychain yes' ~/.ssh/config || printf '\nHost *\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
grep -q '^ *IdentityFile ~/.ssh/id_ed25519$' ~/.ssh/config && echo "Note: ~/.ssh/config names ~/.ssh/id_ed25519 as IdentityFile; delete that line if it is in Host * (step 2)."

# Clean build from the lockfile
node -v | grep -q '^v22\.' || echo "Warning: Node is $(node -v), this page is written for v22."
rm -rf node_modules .next out
npm ci
npm run build
{ test -f out/index.html && test -f out/404.html; } || { echo "Build incomplete: no out/index.html or out/404.html."; exit 1; }
echo "build OK"

# What you ship
echo "out/ weighs $(du -sh out | cut -f1), $(find out -type f | wc -l | tr -d ' ') files"
echo "Files over 20 MB (they deploy fine; their weight for visitors is for the launch checklist):"
find out -type f -size +20M -exec du -h {} +

# Backup status (reported, never fatal)
dest=$(tmutil destinationinfo 2>&1 || true)
case "$dest" in
  *Name*) echo "$dest" | grep -E '^(Name|Kind)' || true ;;
  *) echo "WARNING: no Time Machine destination. This folder is the only original of the site." ;;
esac
latest=$(tmutil latestbackup 2>/dev/null || true)
if [ -n "$latest" ]; then echo "Latest backup: $latest"
else echo "WARNING: no latest backup found (none yet, or Terminal lacks Full Disk Access)."; fi
for d in ${LOCAL_DIR} ${LOCAL_DIR}/photos-26-og; do
  [ -e "$d" ] || continue
  tmutil isexcluded "$d" | grep -q '\[Included\]' || echo "WARNING: $d is excluded from Time Machine."
done
echo "Done. Next: order the VPS; the server's SSH key is made on that page."
```

</Run>

## Before you start

You need Node 22 with npm. Everything on this page runs in Terminal on your Mac, in the project folder <V name="LOCAL_DIR" />.

```bash on="mac"
node -v
npm -v
```

<Guided>If `node` is missing, the LTS installer from nodejs.org is the shortest way; it installs `npm` with it. Homebrew works too, but is not required anywhere on this page.</Guided>

<Note>Next.js 16 needs Node 20.9 or later; this series is tested with Node 22 (Homebrew's `node@22` is keg-only: follow the PATH line `brew` prints). Current LTS lines are listed at nodejs.org/en/about/previous-releases.</Note>

<Deep>Why pin a major version at all: the build runs on this Mac only, so the server never needs Node. But `npm ci` and `next build` behave slightly differently across Node majors (native modules, the bundled npm version), and "it built last month" is only reassuring if the same Node built it. If you juggle several projects, a version manager (fnm, nvm, or Volta) and an `.nvmrc` file containing `22` in the project keep this one on the right version.</Deep>

<Check cmd="node -v | cut -d. -f1" expect="v22" />

## Let macOS remember SSH passphrases

No SSH key on this page: the next page makes one for the server, with a passphrase, right before the order form. What you set now is how this Mac handles that passphrase, so that you type it once and not at every connection. Copy the command and paste it in the terminal: it adds a `Host *` block at the end of `~/.ssh/config`, and creates the file if it does not exist.

```bash on="mac"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/config <<'EOF'

Host *
  AddKeysToAgent yes
  UseKeychain yes
EOF
chmod 600 ~/.ssh/config
```

<Guided>`AddKeysToAgent` loads a key into the agent on first use, and `UseKeychain` fetches its passphrase from the Keychain instead of asking you. After a reboot, the first `ssh` or `rsync` works without a prompt. The block names no key on purpose: each server gets its own key, named where it is used (`-i` on the next page, then the `vps` shortcut from page 3).</Guided>

<Details summary="If ~/.ssh/config already has a Host * block">

If it already holds `AddKeysToAgent yes` and `UseKeychain yes`, do not run the command above: one block is enough. If that block also has a line `IdentityFile ~/.ssh/id_ed25519`, delete that line: it makes ssh offer that key, whatever it was made for, to every server you connect to. Open the file:

```bash on="mac"
${EDITOR} ~/.ssh/config
```

</Details>

<Deep>

`UseKeychain` only exists in Apple's build of OpenSSH. If you share this config file with a Linux machine, add `IgnoreUnknown UseKeychain` above it. Later pages add `Host vps` and `Host vps-deploy` blocks to the same file; they set other options (`HostName`, `Port`, `User`, and their own `IdentityFile` with `IdentitiesOnly yes`), so the order of the blocks does not matter here.

</Deep>

<Check cmd="grep -q 'UseKeychain yes' ~/.ssh/config && echo keychain OK" expect="keychain OK" />

## Check the build from scratch

A build that works on your machine can depend on a stale `node_modules` or a leftover `.next` cache. Remove both, reinstall from the lockfile, and build:

```bash on="mac"
cd ${LOCAL_DIR} && rm -rf node_modules .next out
npm ci
npm run build
```

<Note>The three deleted folders are all regenerated by the commands that follow; nothing of yours is in them. The `&&` makes sure `rm` only runs if the `cd` succeeded. Do not put quotes around the path: the `~` would no longer expand.</Note>

<Guided>`npm ci` installs exactly what `package-lock.json` records, and fails if `package.json` and the lockfile disagree, where `npm install` would quietly update the lockfile. That makes the build repeatable: the same lockfile gives the same site next month. `npm run build` runs `next build`, which writes the finished site into `out/`.</Guided>

<Deep>

`next.config.ts` sets `output: "export"`: `next build` pre-renders every page to plain HTML and writes it into `out/`, with the hashed JavaScript and CSS under `out/_next/static/`. Nothing runs on the server at request time; any web server that can send files can host it. That is why the rest of the series needs Caddy and rsync, and no Node on the VPS.

`trailingSlash: true` makes every route a folder: `/about` becomes `out/about/index.html`, served as `/about/`. `out/404.html` is the page a static server shows for a missing path; the Caddy page wires it in. Pages that rely on server features (API routes, `cookies()`, on-demand revalidation) make the export fail at build time, which is the moment you want to find out. The contact form and the chess save are stubs for now; a backend for them is a later page.

</Deep>

<Check cmd="cd ${LOCAL_DIR} && test -f out/index.html && test -f out/404.html && echo build OK" expect="build OK" />

## Know what you ship

`out/` is exactly what the server will hold. Look at its size and at the heaviest files in it:

```bash on="mac"
du -sh out
find out -type f -size +20M -exec du -h {} +
```

<Guided>`next build` copies everything in `public/` into `out/` as is, so the list shows the site's big media: the WAV files of 44 to 55 MB, and some MP4s. Expect `out/` to weigh about what `public/` weighs (around 220 MB) plus a few megabytes of HTML and JavaScript.</Guided>

These files deploy fine as they are. Nothing to compress or move today:

- The first deploy (page 6) uploads everything once; a few hundred megabytes take minutes on a home connection.
- Every later deploy sends only the files that changed, so an unchanged 55 MB WAV costs nothing after the first time.
- The VPS disk (40 GB on the smallest OVH plan) holds dozens of releases of a site this size.

What those files cost your **visitors** (a 55 MB download on a phone) is a separate question, handled on the launch checklist (page 8).

<Deep>rsync compares each file with the one already in the live release and only transfers the differences; with `--link-dest`, files identical to the live release are hard-linked on the server instead of copied, so each release costs disk space only for what changed. A fresh build rewrites `out/` and may give unchanged media new modification times, which is why the deploy page decides how rsync recognises an unchanged file. The hashed files in `out/_next/static/` change name when their content changes, which is also why they can be cached for a year.</Deep>

## Back up the project folder

There is no other copy of this site. The folder <V name="LOCAL_DIR" /> holds the only original of the source and of the media; `photos-26-og/` holds the only originals of the photos (276 MB of HEIC and MOV). The server will only ever hold **built copies**, which cannot be turned back into the project.

<Warn>If this Mac dies, is stolen, or the folder is deleted, the site cannot be rebuilt from the server. Do not order anything before a backup covers <V name="LOCAL_DIR" /> and `photos-26-og/`.</Warn>

Time Machine is the simplest backup on a Mac. Check that a destination is set up and when it last ran:

```bash on="mac"
tmutil destinationinfo
tmutil latestbackup
```

<Guided>`destinationinfo` lists the backup disks (name, kind, mount point); "No destinations configured" means Time Machine is off: set it up in System Settings → General → Time Machine with an external disk or a network share. `latestbackup` prints the path of the last completed backup; its name starts with the date. If it is weeks old, the disk has not been plugged in for weeks.</Guided>

<Note>On recent macOS, `tmutil latestbackup` needs Full Disk Access for Terminal (System Settings → Privacy & Security → Full Disk Access). Without it, the command prints an error or nothing, even if backups are fine. Confirm the current behaviour in `man tmutil` and Apple's Time Machine support pages.</Note>

Then make sure the project folder is not excluded (excluding big folders is a common way to save backup space):

```bash on="mac"
tmutil isexcluded ${LOCAL_DIR}
```

<Guided>The answer starts with `[Included]` or `[Excluded]`. Run it on the `photos-26-og` folder inside the project too. To remove an exclusion: System Settings → General → Time Machine → Options, select the folder, press −.</Guided>

<Check cmd="tmutil isexcluded ${LOCAL_DIR}" expect="[Included]" />

<Deep>

One backup is better than none; the usual target is **3-2-1**: three copies of the data, on two different media, one of them somewhere else. Here, that means the Mac itself, the Time Machine disk on your desk, and a second copy off-site: an external disk you keep at another address and refresh once a month, or an encrypted cloud backup (Backblaze, Arq to any object storage, or similar). A fire or a burglary takes the Mac and the disk next to it together.

iCloud Drive, if "Desktop & Documents" sync is on, is not a backup: a deletion syncs everywhere within seconds. With "Optimize Mac Storage", some files may even exist only in iCloud, which Time Machine does not back up. The OVH automated backup (included, one day of retention) protects the server's copy of the built site, not this folder.

</Deep>

<Deep>The day you publish the code structure on GitHub, write the `.gitignore` excluding the `public/` media and `photos-26-og/` **before** the first commit: anything committed once stays in the history, even if you delete it later. A later page may cover it.</Deep>

## Done

The site builds from an empty folder, you know what `out/` weighs and which files make it heavy, the project folder is covered by a backup, and ssh on your Mac keeps passphrases in the Keychain.

<Guided>What you carry to the next page: the confidence that `npm run build` produces a complete `out/`, and a backup that covers the only original of the site.</Guided>

Next page: **Order the VPS at OVH**, where you make the server's SSH key right before the order form, then paste its public half into it.
````

````mdx title="content/ovh-vps-static-site/prepare-the-laptop/page-fr.mdx"
{/* Premier jet — à valider avant publication contre nextjs.org/docs/app/guides/static-exports, support.apple.com (Sauvegarder son Mac avec Time Machine ; Accès complet au disque), man tmutil, man ssh-add (Apple) et man ssh_config. */}

Avant de commander un serveur, mets ton Mac en ordre. Cette page vérifie que le site se construit à partir de rien, te montre ce que tu t'apprêtes à envoyer, s'assure que le dossier du projet est sauvegardé (c'est le seul original du site), et règle ssh sur ce Mac pour qu'il garde les phrases de passe dans le trousseau. Aucune clé SSH n'est créée ici : la page suivante en crée une pour le serveur, juste avant le formulaire de commande OVH.

<Run>

Le script tourne **sur ton Mac**, sous ton compte, dans le dossier du projet <V name="LOCAL_DIR" />. Il lui faut Node 22. Pour la sauvegarde, il se contente de faire un rapport ; il n'échoue jamais à cause d'elle.

Ouvre un nouveau fichier `laptop-setup.sh` dans le dossier du projet, colles-y tous les blocs de code de cette section dans l'ordre, puis lance `bash laptop-setup.sh`.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Préparer ton portable — ${LOCAL_DIR}
cd ${LOCAL_DIR}

# SSH : l'agent et le trousseau gardent les phrases de passe (pas de clé ici : celle du serveur se crée à la page suivante)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q 'UseKeychain yes' ~/.ssh/config || printf '\nHost *\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
grep -q '^ *IdentityFile ~/.ssh/id_ed25519$' ~/.ssh/config && echo "Note : ~/.ssh/config désigne ~/.ssh/id_ed25519 comme IdentityFile ; supprime cette ligne si elle est dans Host * (étape 2)."

# Build propre à partir du fichier de verrouillage
node -v | grep -q '^v22\.' || echo "Attention : Node est en $(node -v), cette page est écrite pour la v22."
rm -rf node_modules .next out
npm ci
npm run build
{ test -f out/index.html && test -f out/404.html; } || { echo "Build incomplet : pas de out/index.html ou de out/404.html."; exit 1; }
echo "build OK"

# Ce que tu envoies
echo "out/ pèse $(du -sh out | cut -f1), $(find out -type f | wc -l | tr -d ' ') fichiers"
echo "Fichiers de plus de 20 Mo (ils se déploient sans problème ; leur poids pour les visiteurs relève de la checklist de lancement) :"
find out -type f -size +20M -exec du -h {} +

# État de la sauvegarde (rapport seulement, jamais bloquant)
dest=$(tmutil destinationinfo 2>&1 || true)
case "$dest" in
  *Name*) echo "$dest" | grep -E '^(Name|Kind)' || true ;;
  *) echo "ATTENTION : aucune destination Time Machine. Ce dossier est le seul original du site." ;;
esac
latest=$(tmutil latestbackup 2>/dev/null || true)
if [ -n "$latest" ]; then echo "Dernière sauvegarde : $latest"
else echo "ATTENTION : aucune sauvegarde trouvée (pas encore faite, ou le Terminal n'a pas l'accès complet au disque)."; fi
for d in ${LOCAL_DIR} ${LOCAL_DIR}/photos-26-og; do
  [ -e "$d" ] || continue
  tmutil isexcluded "$d" | grep -q '\[Included\]' || echo "ATTENTION : $d est exclu de Time Machine."
done
echo "Terminé. Suite : commander le VPS ; la clé SSH du serveur se crée sur cette page."
```

</Run>

## Avant de commencer

Il te faut Node 22 avec npm. Tout ce qui suit se tape dans le Terminal de ton Mac, dans le dossier du projet <V name="LOCAL_DIR" />.

```bash on="mac"
node -v
npm -v
```

<Guided>Si `node` manque, l'installeur LTS de nodejs.org est le chemin le plus court ; il installe `npm` avec. Homebrew marche aussi, mais aucune commande de cette page n'en a besoin.</Guided>

<Note>Next.js 16 demande Node 20.9 ou plus ; la série est testée avec Node 22 (le `node@22` de Homebrew est « keg-only » : suis la ligne PATH qu'affiche `brew`). Les versions LTS en cours sont listées sur nodejs.org/en/about/previous-releases.</Note>

<Deep>Pourquoi fixer une version majeure : le build ne tourne que sur ce Mac, le serveur n'a jamais besoin de Node. Mais `npm ci` et `next build` se comportent un peu différemment d'une version majeure de Node à l'autre (modules natifs, version de npm embarquée), et « ça a construit le mois dernier » ne rassure que si c'est le même Node qui a construit. Si tu jongles entre plusieurs projets, un gestionnaire de versions (fnm, nvm ou Volta) et un fichier `.nvmrc` contenant `22` dans le projet gardent celui-ci sur la bonne version.</Deep>

<Check cmd="node -v | cut -d. -f1" expect="v22" />

## Laisser macOS retenir les phrases de passe SSH

Pas de clé SSH sur cette page : la page suivante en crée une pour le serveur, avec une phrase de passe, juste avant le formulaire de commande. Ce que tu règles maintenant, c'est la façon dont ce Mac gère cette phrase de passe, pour que tu la tapes une fois et pas à chaque connexion. Copie la commande et colle-la dans le terminal : elle ajoute un bloc `Host *` à la fin de `~/.ssh/config`, et crée le fichier s'il n'existe pas.

```bash on="mac"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/config <<'EOF'

Host *
  AddKeysToAgent yes
  UseKeychain yes
EOF
chmod 600 ~/.ssh/config
```

<Guided>`AddKeysToAgent` charge une clé dans l'agent au premier usage, et `UseKeychain` va chercher sa phrase de passe dans le trousseau au lieu de te la demander. Après un redémarrage, le premier `ssh` ou `rsync` passe sans question. Le bloc ne désigne volontairement aucune clé : chaque serveur a la sienne, nommée là où elle sert (`-i` à la page suivante, puis le raccourci `vps` à partir de la page 3).</Guided>

<Details summary="Si ~/.ssh/config a déjà un bloc Host *">

S'il contient déjà `AddKeysToAgent yes` et `UseKeychain yes`, ne lance pas la commande ci-dessus : un seul bloc suffit. Si ce bloc a aussi une ligne `IdentityFile ~/.ssh/id_ed25519`, supprime-la : avec elle, ssh présente cette clé, quel que soit son usage d'origine, à chaque serveur auquel tu te connectes. Ouvre le fichier :

```bash on="mac"
${EDITOR} ~/.ssh/config
```

</Details>

<Deep>

`UseKeychain` n'existe que dans l'OpenSSH d'Apple. Si tu partages ce fichier de config avec une machine Linux, ajoute `IgnoreUnknown UseKeychain` au-dessus. Les pages suivantes ajoutent des blocs `Host vps` et `Host vps-deploy` au même fichier ; ils règlent d'autres options (`HostName`, `Port`, `User`, et leur propre `IdentityFile` avec `IdentitiesOnly yes`), donc l'ordre des blocs n'a pas d'importance ici.

</Deep>

<Check cmd="grep -q 'UseKeychain yes' ~/.ssh/config && echo keychain OK" expect="keychain OK" />

## Vérifier le build à partir de zéro

Un build qui marche sur ta machine peut dépendre d'un `node_modules` périmé ou d'un cache `.next` oublié. Supprime-les, réinstalle depuis le fichier de verrouillage, et construis :

```bash on="mac"
cd ${LOCAL_DIR} && rm -rf node_modules .next out
npm ci
npm run build
```

<Note>Les trois dossiers supprimés sont tous régénérés par les commandes qui suivent ; rien de toi n'y est. Le `&&` garantit que `rm` ne tourne que si le `cd` a réussi. Ne mets pas de guillemets autour du chemin : le `~` ne serait plus développé.</Note>

<Guided>`npm ci` installe exactement ce que `package-lock.json` enregistre, et échoue si `package.json` et le verrou ne sont pas d'accord, là où `npm install` mettrait le verrou à jour en silence. Le build devient reproductible : le même verrou donne le même site le mois prochain. `npm run build` lance `next build`, qui écrit le site fini dans `out/`.</Guided>

<Deep>

`next.config.ts` règle `output: "export"` : `next build` pré-rend chaque page en HTML pur et l'écrit dans `out/`, avec le JavaScript et le CSS hachés sous `out/_next/static/`. Rien ne tourne côté serveur au moment de la requête ; n'importe quel serveur web capable d'envoyer des fichiers peut l'héberger. C'est pour ça que la suite de la série se contente de Caddy et rsync, sans Node sur le VPS.

`trailingSlash: true` fait de chaque route un dossier : `/about` devient `out/about/index.html`, servi en `/about/`. `out/404.html` est la page qu'un serveur statique montre pour un chemin absent ; la page Caddy la branche. Les pages qui reposent sur des fonctions serveur (routes API, `cookies()`, revalidation à la demande) font échouer l'export au build, c'est-à-dire au moment où tu veux le découvrir. Le formulaire de contact et la sauvegarde d'échecs sont des ébauches pour l'instant ; leur backend fera l'objet d'une page ultérieure.

</Deep>

<Check cmd="cd ${LOCAL_DIR} && test -f out/index.html && test -f out/404.html && echo build OK" expect="build OK" />

## Savoir ce que tu envoies

`out/` est exactement ce que le serveur contiendra. Regarde sa taille et ses fichiers les plus lourds :

```bash on="mac"
du -sh out
find out -type f -size +20M -exec du -h {} +
```

<Guided>`next build` copie tout `public/` tel quel dans `out/`, donc la liste montre les gros médias du site : les WAV de 44 à 55 Mo, et quelques MP4. Attends-toi à ce que `out/` pèse à peu près le poids de `public/` (autour de 220 Mo) plus quelques mégaoctets de HTML et de JavaScript.</Guided>

Ces fichiers se déploient très bien tels quels. Rien à compresser ni à déplacer aujourd'hui :

- Le premier déploiement (page 6) envoie tout une fois ; quelques centaines de mégaoctets, c'est quelques minutes sur une connexion domestique.
- Chaque déploiement suivant n'envoie que les fichiers qui ont changé : un WAV de 55 Mo inchangé ne coûte plus rien après la première fois.
- Le disque du VPS (40 Go sur la plus petite offre OVH) tient des dizaines de versions d'un site de cette taille.

Ce que ces fichiers coûtent à tes **visiteurs** (55 Mo à télécharger sur un téléphone) est une autre question, traitée dans la checklist de lancement (page 8).

<Deep>rsync compare chaque fichier avec celui de la version en ligne et ne transfère que les différences ; avec `--link-dest`, les fichiers identiques à la version en ligne sont liés en dur sur le serveur au lieu d'être copiés, donc chaque version ne coûte de place disque que pour ce qui a changé. Un build neuf réécrit `out/` et peut donner de nouvelles dates de modification à des médias inchangés : c'est pour ça que la page de déploiement choisit comment rsync reconnaît un fichier inchangé. Les fichiers hachés de `out/_next/static/` changent de nom quand leur contenu change, ce qui explique aussi qu'on puisse les mettre en cache pour un an.</Deep>

## Sauvegarder le dossier du projet

Il n'existe pas d'autre copie de ce site. Le dossier <V name="LOCAL_DIR" /> contient le seul original des sources et des médias ; `photos-26-og/` contient les seuls originaux des photos (276 Mo de HEIC et de MOV). Le serveur ne tiendra jamais que des **copies construites**, qu'on ne peut pas retransformer en projet.

<Warn>Si ce Mac meurt, se fait voler, ou si le dossier est supprimé, le site ne peut pas être reconstruit à partir du serveur. Ne commande rien tant qu'une sauvegarde ne couvre pas <V name="LOCAL_DIR" /> et `photos-26-og/`.</Warn>

Time Machine est la sauvegarde la plus simple sur un Mac. Vérifie qu'une destination est configurée et quand elle a tourné pour la dernière fois :

```bash on="mac"
tmutil destinationinfo
tmutil latestbackup
```

<Guided>`destinationinfo` liste les disques de sauvegarde (nom, type, point de montage) ; « No destinations configured » veut dire que Time Machine est éteint : configure-le dans Réglages Système → Général → Time Machine avec un disque externe ou un partage réseau. `latestbackup` affiche le chemin de la dernière sauvegarde terminée ; son nom commence par la date. Si elle date de plusieurs semaines, le disque n'a pas été branché depuis plusieurs semaines.</Guided>

<Note>Sur les macOS récents, `tmutil latestbackup` demande l'accès complet au disque pour le Terminal (Réglages Système → Confidentialité et sécurité → Accès complet au disque). Sans lui, la commande affiche une erreur ou rien du tout, même si les sauvegardes vont bien. Confirme le comportement actuel dans `man tmutil` et les pages d'assistance Apple sur Time Machine.</Note>

Vérifie ensuite que le dossier du projet n'est pas exclu (exclure les gros dossiers est une façon courante de gagner de la place de sauvegarde) :

```bash on="mac"
tmutil isexcluded ${LOCAL_DIR}
```

<Guided>La réponse commence par `[Included]` ou `[Excluded]`. Lance-la aussi sur le dossier `photos-26-og` du projet. Pour retirer une exclusion : Réglages Système → Général → Time Machine → Options, sélectionne le dossier, appuie sur −.</Guided>

<Check cmd="tmutil isexcluded ${LOCAL_DIR}" expect="[Included]" />

<Deep>

Une sauvegarde vaut mieux que rien ; la cible habituelle est la règle **3-2-1** : trois copies des données, sur deux supports différents, dont une ailleurs. Ici, ça donne le Mac lui-même, le disque Time Machine sur ton bureau, et une seconde copie hors site : un disque externe gardé à une autre adresse et rafraîchi une fois par mois, ou une sauvegarde chiffrée dans le cloud (Backblaze, Arq vers un stockage objet, ou équivalent). Un incendie ou un cambriolage emporte le Mac et le disque posé à côté en même temps.

iCloud Drive, si la synchronisation « Bureau et Documents » est active, n'est pas une sauvegarde : une suppression se propage partout en quelques secondes. Avec « Optimiser le stockage du Mac », certains fichiers peuvent même n'exister que dans iCloud, et Time Machine ne les sauvegarde pas. La sauvegarde automatique d'OVH (incluse, un jour de rétention) protège la copie construite du site sur le serveur, pas ce dossier.

</Deep>

<Deep>Le jour où tu publies la structure du code sur GitHub, écris le `.gitignore` qui exclut les médias de `public/` et `photos-26-og/` **avant** le premier commit : tout ce qui est commité une fois reste dans l'historique, même si tu le supprimes ensuite. Une page ultérieure pourra le traiter.</Deep>

## Terminé

Le site se construit à partir d'un dossier vide, tu sais ce que pèse `out/` et quels fichiers l'alourdissent, le dossier du projet est couvert par une sauvegarde, et ssh sur ton Mac garde les phrases de passe dans le trousseau.

<Guided>Ce que tu emportes à la page suivante : la certitude que `npm run build` produit un `out/` complet, et une sauvegarde qui couvre le seul original du site.</Guided>

Page suivante : **Commander le VPS chez OVH**, où tu crées la clé SSH du serveur juste avant le formulaire de commande, puis y colles sa moitié publique.
````

````yaml title="content/ovh-vps-static-site/prepare-the-laptop/diagram.yaml"
# Quick: the project folder (focus), its build, the backup, and where out/ goes next.
# Guided: the Keychain/agent settings (Host *) and the photo originals that must be backed up too.
# Deep: the off-site copy (3-2-1) and the heavy media inside out/.
title: { en: "Your Mac, the only original", fr: "Ton Mac, le seul original" }
caption:
  en: "The project folder is the single source of truth: it builds into out/, which is what the server will get, and a backup must cover it. No SSH key yet: the server's key is made on the next page."
  fr: "Le dossier du projet est la seule source de vérité : il se construit en out/, ce que recevra le serveur, et une sauvegarde doit le couvrir. Pas encore de clé SSH : celle du serveur se crée à la page suivante."

groups:
  - id: mac
    label: { en: "Your Mac", fr: "Ton Mac" }
    desc:
      en: "Everything on this page happens here. Nothing is ordered or deployed yet."
      fr: "Tout ce qui se passe sur cette page a lieu ici. Rien n'est encore commandé ni déployé."

nodes:
  - id: project
    kind: file
    label: { en: "Project folder", fr: "Dossier du projet" }
    sub: "${LOCAL_DIR}"
    in: mac
    focus: true
    desc:
      en: "The only original of the site: source, lockfile and public/ media. Everything else (out/, the server) is a copy built from it."
      fr: "Le seul original du site : sources, fichier de verrouillage et médias de public/. Tout le reste (out/, le serveur) est une copie construite à partir de lui."
    deep:
      sub: "${LOCAL_DIR} · package-lock.json · next.config.ts (output: export) · public/ ~217 MB"
  - id: out
    kind: file
    label: { en: "Build output", fr: "Sortie du build" }
    sub: "out/"
    in: mac
    desc:
      en: "What npm run build produces: plain HTML, CSS, JS and media. Exactly what the server will hold."
      fr: "Ce que produit npm run build : HTML, CSS, JS et médias bruts. Exactement ce que le serveur contiendra."
    guided:
      sub: "out/ · index.html · 404.html"
    deep:
      sub: "out/route/index.html · 404.html · _next/static/ (hashed) · WAV 44–55 MB"
  - id: agent
    kind: service
    label: { en: "ssh-agent + Keychain", fr: "ssh-agent + trousseau" }
    sub: "Host * · UseKeychain yes"
    in: mac
    level: guided
    desc:
      en: "Set up here, used from the next page on: the agent holds unlocked keys in memory, with their passphrases stored in the macOS Keychain, so ssh and rsync do not ask for them."
      fr: "Réglés ici, utilisés à partir de la page suivante : l'agent garde les clés déverrouillées en mémoire, leurs phrases de passe étant dans le trousseau macOS, et ssh et rsync ne les demandent pas."
    deep:
      sub: "~/.ssh/config · Host * · AddKeysToAgent yes · UseKeychain yes"
  - id: originals
    kind: file
    label: { en: "Photo originals", fr: "Photos originales" }
    sub: "photos-26-og/ · 276 MB"
    in: mac
    level: guided
    desc:
      en: "HEIC and MOV sources for scripts/pictures.py. Not part of the site, and their only copy: the backup must cover them."
      fr: "Les sources HEIC et MOV de scripts/pictures.py. Pas une partie du site, et leur seule copie : la sauvegarde doit les couvrir."
  - id: backup
    kind: store
    label: { en: "Time Machine", fr: "Time Machine" }
    sub: "tmutil latestbackup"
    desc:
      en: "The backup disk. Without it, losing the Mac means losing the site: the server only holds built copies."
      fr: "Le disque de sauvegarde. Sans lui, perdre le Mac, c'est perdre le site : le serveur ne tient que des copies construites."
    deep:
      sub: "external disk or network share · [Included] ${LOCAL_DIR}"
  - id: offsite
    kind: store
    label: { en: "Off-site copy", fr: "Copie hors site" }
    sub: "3-2-1"
    level: deep
    desc:
      en: "A second backup somewhere else: a disk kept at another address, or an encrypted cloud backup. Survives a fire or a burglary."
      fr: "Une seconde sauvegarde ailleurs : un disque gardé à une autre adresse, ou une sauvegarde chiffrée dans le cloud. Survit à un incendie ou un cambriolage."
  - id: later
    kind: server
    label: { en: "Later: OVH and the server", fr: "Plus tard : OVH et le serveur" }
    sub: "${WEB_ROOT}"
    desc:
      en: "Ordered on the next page. It will only ever hold built copies of the site, never the project."
      fr: "Commandé à la page suivante. Il ne contiendra jamais que des copies construites du site, jamais le projet."
    deep:
      sub: "${WEB_ROOT}/releases · built copies only"

edges:
  - from: project
    to: out
    label: "npm run build"
    deep: { label: "npm ci && next build (static export)" }
    desc:
      en: "A clean install from the lockfile, then a static export into out/."
      fr: "Une installation propre depuis le fichier de verrouillage, puis un export statique dans out/."
  - from: project
    to: backup
    label: "backed up"
    guided: { label: "Time Machine · hourly" }
    desc:
      en: "Time Machine copies the folder to the backup disk. Check it is not excluded."
      fr: "Time Machine copie le dossier sur le disque de sauvegarde. Vérifie qu'il n'est pas exclu."
  - from: out
    to: later
    label: "deploy (page 6)"
    dashed: true
    desc:
      en: "Later, rsync sends out/ to the server: everything once, then only what changed."
      fr: "Plus tard, rsync envoie out/ au serveur : tout une fois, puis seulement ce qui a changé."
  - from: originals
    to: backup
    label: "backed up"
    dashed: true
    level: guided
    desc:
      en: "The only originals of the photos: they must be in the backup too."
      fr: "Les seuls originaux des photos : ils doivent être dans la sauvegarde aussi."
  - from: backup
    to: offsite
    label: "second copy"
    dashed: true
    level: deep
    desc:
      en: "3-2-1: three copies, two media, one off-site."
      fr: "3-2-1 : trois copies, deux supports, une hors site."
````

---

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