# Source of "Backups, monitoring and the monthly routine"

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/backups-and-monitoring/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.
title:
  en: Backups, monitoring and the monthly routine
  fr: Sauvegardes, surveillance et la routine mensuelle
summary:
  en: >-
    Know what is worth saving on the server and what is not. An encrypted off-site copy made every
    night by restic, with a restore you have actually tested; an email when the site goes down or
    the backup did not run; logs that cannot fill the disk; and a ten-minute check once a month.
  fr: >-
    Savoir ce qui mérite d'être sauvegardé sur le serveur, et ce qui ne le mérite pas. Une copie
    chiffrée hors site faite chaque nuit par restic, avec une restauration réellement testée ; un
    email quand le site tombe ou que la sauvegarde n'a pas tourné ; des logs qui ne peuvent plus
    remplir le disque ; et dix minutes de vérification une fois par mois.
difficulty: intermediate
tags: [backup, restic, s3, monitoring, systemd, ovh, debian]
authors: [thudal]
created: 2026-09-26
minutes: 40
validated: Debian 13 (trixie) · restic 0.17+ · OVH Object Storage (S3)
status: draft             # not yet run end to end by its author

groups:
  - id: backup
    label: { en: Off-site backup, fr: Sauvegarde hors site }
    desc: { en: Where the encrypted copy goes and when it is made., fr: Où part la copie chiffrée et quand elle est faite. }
    when: { is: OFFSITE, equals: s3 }
  - id: monitoring
    label: { en: Monitoring, fr: Surveillance }
    desc: { en: Who tells you when something stops., fr: Qui te prévient quand quelque chose s'arrête. }
    when: { flag: HEALTHCHECK }

vars:
  - key: S3_ENDPOINT
    kind: url
    group: backup
    default: https://s3.gra.io.cloud.ovh.net
    label: { en: S3 endpoint, fr: Point d'accès S3 }
    hint:
      en: The S3 URL of your object storage region, without the bucket name. OVH Gravelines (GRA) standard storage by default.
      fr: L'URL S3 de la région de ton stockage objet, sans le nom du bucket. Par défaut, le stockage standard OVH de Gravelines (GRA).
    impact:
      en: Part of the restic repository address. A wrong region gives "bucket not found" or a signature error at restic init.
      fr: Fait partie de l'adresse du dépôt restic. Une mauvaise région donne « bucket not found » ou une erreur de signature au restic init.
    when: { is: OFFSITE, equals: s3 }
  - key: S3_BUCKET
    kind: text
    group: backup
    default: site-backups
    label: { en: Bucket name, fr: Nom du bucket }
    hint:
      en: The container you create for the backups. Lowercase letters, digits and hyphens.
      fr: Le conteneur que tu crées pour les sauvegardes. Minuscules, chiffres et tirets.
    impact:
      en: Must match the container exactly. Keep one bucket for this server only, so a forget/prune never touches anything else.
      fr: Doit correspondre exactement au conteneur. Garde un bucket pour ce seul serveur, pour qu'un forget/prune ne touche jamais rien d'autre.
    when: { is: OFFSITE, equals: s3 }
  - key: S3_ACCESS_KEY
    kind: secret
    group: backup
    default: ""
    label: { en: S3 access key, fr: Clé d'accès S3 }
    hint:
      en: The access key of the S3 user you create in the storage provider's panel.
      fr: La clé d'accès de l'utilisateur S3 que tu crées dans le panneau du fournisseur de stockage.
    impact:
      en: Stored only in /etc/restic/env on the server (root-only). With the secret key, it can read and delete the bucket's objects, which are encrypted.
      fr: Stockée seulement dans /etc/restic/env sur le serveur (root uniquement). Avec la clé secrète, elle peut lire et supprimer les objets du bucket, qui sont chiffrés.
    when: { is: OFFSITE, equals: s3 }
  - key: S3_SECRET_KEY
    kind: secret
    group: backup
    default: ""
    label: { en: S3 secret key, fr: Clé secrète S3 }
    hint:
      en: The secret half of the S3 credentials, shown once when the S3 user is created.
      fr: La moitié secrète des identifiants S3, affichée une seule fois à la création de l'utilisateur S3.
    impact:
      en: If it leaks, regenerate it in the provider's panel and update /etc/restic/env. The backups stay unreadable without the restic password.
      fr: Si elle fuite, régénère-la dans le panneau du fournisseur et mets à jour /etc/restic/env. Les sauvegardes restent illisibles sans le mot de passe restic.
    when: { is: OFFSITE, equals: s3 }
  - key: RESTIC_PASSWORD
    kind: secret
    group: backup
    default: ""
    label: { en: Restic repository password, fr: Mot de passe du dépôt restic }
    hint:
      en: Encrypts every backup. Generate it with openssl rand -base64 33 (letters, digits, + and / only, no quotes or spaces).
      fr: Chiffre chaque sauvegarde. Génère-le avec openssl rand -base64 33 (lettres, chiffres, + et / seulement, ni guillemets ni espaces).
    impact:
      en: Lose it and every backup is unreadable forever; nobody can recover it. Store it in your password manager before the first backup.
      fr: Perds-le et toutes les sauvegardes sont illisibles pour toujours ; personne ne peut le récupérer. Range-le dans ton gestionnaire de mots de passe avant la première sauvegarde.
    when: { is: OFFSITE, equals: s3 }
  - key: BACKUP_TIME
    kind: text
    group: backup
    default: "03:15"
    label: { en: Backup time (server clock), fr: Heure de sauvegarde (horloge du serveur) }
    hint:
      en: When the nightly backup starts, HH:MM, in the server's time zone, set on page 3. Up to 15 minutes of random delay is added.
      fr: L'heure de départ de la sauvegarde nocturne, HH:MM, dans le fuseau horaire du serveur, réglé à la page 3. Jusqu'à 15 minutes de délai aléatoire s'y ajoutent.
    impact:
      en: Goes into the systemd timer. Pick a quiet hour; the healthchecks.io check expects one ping a day around it.
      fr: Va dans le timer systemd. Choisis une heure calme ; le check healthchecks.io attend un ping par jour autour de cette heure.
    when: { is: OFFSITE, equals: s3 }
  - key: HC_PING_URL
    kind: url
    group: monitoring
    default: ""
    required: true
    label: { en: healthchecks.io ping URL, fr: URL de ping healthchecks.io }
    hint:
      en: The ping URL of the check you create on healthchecks.io, shown on the check's page.
      fr: L'URL de ping du check que tu crées sur healthchecks.io, affichée sur la page du check.
    impact:
      en: Called after each successful backup. With a wrong URL the ping goes nowhere, and your check reports the backup as down every day.
      fr: Appelée après chaque sauvegarde réussie. Avec une URL fausse le ping ne va nulle part, et ton check signale la sauvegarde en panne chaque jour.
    when: { flag: HEALTHCHECK }

choices:
  - key: OFFSITE
    type: select
    label: { en: Off-site copy, fr: Copie hors site }
    hint:
      en: An off-site copy protects you against losing the VPS or the OVH account, which OVH's own backups do not.
      fr: Une copie hors site te protège contre la perte du VPS ou du compte OVH, ce que les sauvegardes d'OVH ne font pas.
    default: s3
    options:
      - { value: s3, label: { en: "restic to S3 object storage (OVH, Infomaniak Swiss Backup, …)", fr: "restic vers un stockage objet S3 (OVH, Infomaniak Swiss Backup, …)" } }
      - { value: none, label: { en: No off-site copy (OVH backups only), fr: Pas de copie hors site (sauvegardes OVH seulement) } }
  - key: HEALTHCHECK
    type: boolean
    label: { en: Ping healthchecks.io after each backup, fr: Pinguer healthchecks.io après chaque sauvegarde }
    hint:
      en: Emails you when the nightly backup did not report in. Needs the off-site copy, since the ping comes from the backup job.
      fr: T'envoie un email quand la sauvegarde de la nuit ne s'est pas manifestée. Nécessite la copie hors site, puisque le ping vient du job de sauvegarde.
    default: true
````

````mdx title="content/ovh-vps-static-site/backups-and-monitoring/page-en.mdx"
{/* First pass — to be validated against https://restic.readthedocs.io , https://docs.ovhcloud.com (Object Storage S3 getting started, VPS automated backups and snapshots) and https://healthchecks.io/docs/ before publishing. */}

The site is live. This page makes sure that when something breaks, you hear about it first and you can put it back. You decide what is worth saving, find OVH's own backups, send an encrypted copy off-site every night with restic, restore from it once to prove it works, put an external monitor on the site, cap the logs, and end with a ten-minute routine to run once a month.

<Run>

On the server (`ssh vps`), open a new file `backup-setup.sh` with <V name="EDITOR" />, paste every code block of this section into it in order, then run `sudo bash backup-setup.sh` (sudo asks your password once). Pages 3 to 6 must be done<When is="OFFSITE" equals="s3">, the bucket and its S3 keys created, and every value of the "Off-site backup" group filled in the panel</When><When flag="HEALTHCHECK">, as well as `HC_PING_URL` in the "Monitoring" group</When>.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Backups and monitoring — ${DOMAIN}
install -d /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/size.conf <<'EOF'
[Journal]
SystemMaxUse=500M
EOF
systemctl restart systemd-journald
```

<When is="OFFSITE" equals="s3">

<When flag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
[ -n "${HC_PING_URL}" ] || { echo "Fill HC_PING_URL first"; exit 1; }
```

</When>

```bash on="server" as="${USERNAME}"
for i in $(seq 30); do apt-get -o DPkg::Lock::Timeout=300 update && break; sleep 10; done
apt-get -o DPkg::Lock::Timeout=300 install -y restic curl
install -d -m 700 /etc/restic
umask 077
cat > /etc/restic/env <<'EOF'
RESTIC_REPOSITORY=s3:${S3_ENDPOINT}/${S3_BUCKET}
AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
RESTIC_PASSWORD=${RESTIC_PASSWORD}
EOF
umask 022
cat > /etc/restic/excludes <<'EOF'
/home/*/.cache
/var/lib/caddy/.local/share/caddy/locks
EOF
set -a; . /etc/restic/env; set +a
restic cat config >/dev/null 2>&1 || restic init
```

<When notFlag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.service <<'EOF'
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
EOF
```

</When>

<When flag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.service <<'EOF'
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${HC_PING_URL}
EOF
```

</When>

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.timer <<'EOF'
[Unit]
Description=Run restic-backup every night

[Timer]
OnCalendar=*-*-* ${BACKUP_TIME}:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now restic-backup.timer
systemctl start restic-backup.service
restic restore latest --target /tmp/restore-test --include /etc/caddy
diff -r /etc/caddy /tmp/restore-test/etc/caddy
echo "restore OK"
rm -rf /tmp/restore-test
systemctl list-timers restic-backup.timer
echo "Done. Put RESTIC_PASSWORD in your password manager if it is not there yet."
```

<Warn>If you lose <V name="RESTIC_PASSWORD" />, every backup is unreadable forever. Put it in your password manager, with the S3 keys, before running the script.</Warn>

</When>

<When is="OFFSITE" equals="none">

Without an off-site copy the script only caps the journal. Copy `/etc` to your laptop by hand, as shown in "Keep a copy of /etc yourself".

</When>

</Run>

## Before you start

Pages 3 to 6 are done: you log in with `ssh vps`, Caddy serves the site over HTTPS, and deploys go through `vps-deploy`. Everything below runs on the server after `ssh vps`, as <V name="USERNAME" />, except the web dashboards and the blocks marked for the Mac.

<Check cmd="systemctl is-active caddy" expect="active" />

<Guided>You also need a password manager. This page creates secrets that exist nowhere else: the restic password and the S3 keys. A server that burns down takes its copy of them along; the copy in your password manager is the one that counts.</Guided>

## What to back up

The site's source lives only in the project folder on your Mac, <V name="LOCAL_DIR" />: the site is rebuilt and redeployed from there in minutes (pages 1 and 6). What the Mac cannot rebuild is the server's own state.

| Path | What is in it | If you lose it |
|---|---|---|
| `/etc` | SSH drop-in, Caddyfile, ufw rules, fail2ban jails, apt sources, sudoers, users and groups | Redo pages 3 to 5 by hand: an hour or two, with room for mistakes |
| `/var/lib/caddy` | TLS certificates and the ACME account | Caddy gets new ones on its own, within Let's Encrypt's rate limits |
| `/home` | Your dotfiles, both users' `authorized_keys`, scripts you wrote | Re-add keys through the KVM console |
| <V name="WEB_ROOT" /> | The built releases, not the source: no code, no originals | Redeploy from the Mac; this copy only matters if the Mac and its backup are both gone |
| `/var/lib/<app>` (later) | A backend's data, once you add one | Irreplaceable: the real reason backups exist |

<Guided>This page covers the server. The project folder on the Mac is backed up by Time Machine, set up on page 1; that is where the code and the original photos live. The web root is saved here anyway: it costs almost nothing, since the releases are hard-linked and restic stores identical files once, and it is the only other copy of the built site if the Mac and its Time Machine disk were lost together. It is a copy of the output, never a substitute for the source.</Guided>

<Deep>Let's Encrypt allows five certificates per week for the same set of names. Rebuilding a server a few times in a day of experiments, without `/var/lib/caddy`, is how people hit that limit and wait a week. Caddy would then fall back to ZeroSSL, which the CAA record from page 4 may not allow. A useful extra: before each backup, dump the list of packages you installed by hand, with `apt-mark showmanual > /etc/apt/manual-packages.txt`, so a rebuild starts with one `apt install`.</Deep>

## OVH backups and snapshots

In the OVH control panel, open your VPS (**Bare Metal Cloud** → **Virtual Private Servers** → the VPS). The **Home** tab lists the options: **Automated backup** and **Snapshot**.

- **Automated backup**: included with the VPS, one per day, one day kept. The `...` next to it → **Restore**.
- **Snapshot**: a paid option. You take it by hand, before a risky change: a Debian major upgrade, a Caddy config you are unsure of, a firewall change.

<Warn>Restoring a backup or a snapshot overwrites the whole disk of the VPS. Everything written since, including logs and files uploaded by the deploy, is gone. The snapshot option costs money each month: check the current price on ovhcloud.com/fr/vps.</Warn>

<Guided>

Three layers, each against a different accident:

1. **OVH automated backup**: "I broke it yesterday." The whole VPS goes back one day, in one click.
2. **OVH snapshot**: "I am about to do something risky." Taken minutes before, restored minutes after.
3. **An off-site copy you control**: "the VPS, the datacenter or the OVH account is gone." A locked account, an unpaid invoice, a datacenter fire: the first two layers live at OVH and go with it.

</Guided>

<Deep>Both OVH layers work at the disk level: they cannot give you one file back without rolling back everything else. The control panel can also mount an automated backup so you pick files from it; check the OVHcloud guide "Using automated backups on a VPS" for the current procedure. Only one snapshot exists at a time: taking a new one replaces the old.</Deep>

<Note>OVH moves its menus from time to time. If the path above has changed, search docs.ovhcloud.com for "VPS snapshot" and "VPS automated backup".</Note>

<When is="OFFSITE" equals="s3">

## Create the bucket and keys

The off-site copy goes to S3 object storage: a bucket, plus an S3 user whose keys restic uses. At OVH, in the control panel:

1. **Public Cloud** → create a project if you have none (it needs a payment method).
2. **Object Storage** → **Create an object container**: S3 API, standard class, region **GRA** (Gravelines), name <V name="S3_BUCKET" />.
3. **Object Storage** → **S3 users** → create a user, give it read/write on the container, and copy its **access key** and **secret key** into the values panel and your password manager.

<Warn>Public Cloud is billed per use. For a few dozen megabytes of configuration, expect cents per month (storage is in the order of 0.01 € per GB per month), but confirm the current price on the OVHcloud Object Storage page before creating the project.</Warn>

<Note>Menu names move, and the S3 endpoint depends on the region you choose. Confirm the endpoint for your region in the OVHcloud guide "Object Storage — Getting started with S3" and copy it into <V name="S3_ENDPOINT" />.</Note>

<Guided>Why a second provider would be even better: a bucket at OVH survives the loss of the VPS, but not the loss of the OVH account. **Infomaniak Swiss Backup** (where your domain already lives) or **Backblaze B2** also speak S3; restic does not care. Change the endpoint and the keys, and nothing else on this page moves.</Guided>

<Deep>The S3 user only needs access to this one bucket. Do not reuse a user that can reach other containers: the keys sit on the server, so whoever takes the server could delete the backups with them. restic's encryption keeps the content private, but not the objects' existence. If that risk matters to you, turn on object lock or versioning on the bucket, where the provider supports it, so deletions can be undone.</Deep>

## Set up restic

restic makes encrypted, deduplicated snapshots of directories into a "repository", here the bucket. Install it, then write its credentials in a file only root can read.

```bash on="server" as="${USERNAME}"
sudo apt install -y restic
sudo install -d -m 700 /etc/restic
sudo install -m 600 /dev/null /etc/restic/env
```

Then write the credentials into it: copy the command and paste it in the terminal. The file already exists with mode 600, and `tee` keeps that mode, so only root can read the secrets.

```ini file="/etc/restic/env" on="server" as="${USERNAME}"
RESTIC_REPOSITORY=s3:${S3_ENDPOINT}/${S3_BUCKET}
AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
RESTIC_PASSWORD=${RESTIC_PASSWORD}
```

<Warn>If you lose <V name="RESTIC_PASSWORD" />, every backup is unreadable forever: nobody, not even OVH, can decrypt them. Put it in your password manager now, before the first backup.</Warn>

Then the list of what to skip, `/etc/restic/excludes`:

```text file="/etc/restic/excludes" on="server" as="${USERNAME}"
/home/*/.cache
/var/lib/caddy/.local/share/caddy/locks
```

And the repository itself:

```bash on="server" as="${USERNAME}"
sudo bash -c 'set -a; . /etc/restic/env; restic init'
```

<Guided>`set -a` exports every variable read afterwards, so `. /etc/restic/env` hands the four values to restic without them ever appearing on the command line or in your shell history. `restic init` creates the repository structure and its encryption key, derived from the password. It prints `created restic repository … at s3:…`.</Guided>

<Details summary="If restic init fails">

- `The specified bucket does not exist`: the bucket name or the endpoint region does not match. Compare with the control panel.
- `Access Denied` or `SignatureDoesNotMatch`: wrong keys, or the S3 user has no rights on the container.
- A region error: add `AWS_DEFAULT_REGION=gra` (your region, lowercase) to `/etc/restic/env`.
- `config file already exists`: the repository is already initialized; nothing to do.

To edit `/etc/restic/env` by hand:

```bash on="server" as="${USERNAME}"
sudo ${EDITOR} /etc/restic/env
```

</Details>

Now the nightly job: a systemd service that makes the backup and prunes old snapshots, and a timer that starts it.

<Tabs group="restic-units">

<Tab label="restic-backup.service">

<When notFlag="HEALTHCHECK">

```ini file="/etc/systemd/system/restic-backup.service" on="server" as="${USERNAME}" {12-13}
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
```

</When>

<When flag="HEALTHCHECK">

With healthchecks.io, the last line pings it after a successful backup. Its URL comes from the check you create further down, in "Get told when something breaks": the block can be copied once <V name="HC_PING_URL" /> is filled, so create the check first, or come back to this block then.

```ini file="/etc/systemd/system/restic-backup.service" on="server" as="${USERNAME}" {12-14}
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${HC_PING_URL}
```

</When>

</Tab>

<Tab label="restic-backup.timer">

```ini file="/etc/systemd/system/restic-backup.timer" on="server" as="${USERNAME}"
[Unit]
Description=Run restic-backup every night

[Timer]
OnCalendar=*-*-* ${BACKUP_TIME}:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
```

</Tab>

</Tabs>

Copy each block and paste it in the terminal on the server: each one writes its file. Then enable the timer and run one backup now:

```bash on="server" as="${USERNAME}"
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
sudo systemctl start restic-backup.service
```

<Check cmd="systemctl is-active restic-backup.timer" expect="active" />

<Check cmd="sudo systemctl show -p Result restic-backup.service" expect="Result=success" />

<Guided>The service keeps 7 daily, 4 weekly and 6 monthly snapshots: half a year of history for a few megabytes, since restic stores each changed block only once. `Persistent=true` makes a backup missed while the VPS was off run at the next boot. `systemctl start` waits for the backup to finish, so the second check reads the result of the run you just made.</Guided>

<Deep>`ExecStartPost` lines only run if `ExecStart` succeeded, so a failed backup never prunes. restic exits with code 3 when some files could not be read: the snapshot exists but is incomplete, and the service is marked failed on purpose. The `-` in front of the curl line tells systemd to ignore its failure: an outage at healthchecks.io must not mark a good backup as failed. `CacheDirectory` gives restic a local cache in `/var/cache/restic`, which avoids downloading the index from S3 at every run. Tired of the `bash -c` prefix? A wrapper in `/usr/local/sbin/rst` containing `set -a; . /etc/restic/env; set +a; exec restic "$@"` (mode 700) lets you type `sudo rst snapshots`.</Deep>

## Test a restore

A backup you never restored is a hope, not a backup. Restore the Caddy configuration into a scratch folder and compare it with the live one:

```bash on="server" as="${USERNAME}"
sudo bash -c 'set -a; . /etc/restic/env; restic restore latest --target /tmp/restore-test --include /etc/caddy'
sudo diff -r /etc/caddy /tmp/restore-test/etc/caddy && echo "restore OK"
```

<Check cmd="sudo diff -r /etc/caddy /tmp/restore-test/etc/caddy && echo 'restore OK'" expect="restore OK" />

Then remove the scratch copy with `sudo rm -rf /tmp/restore-test`.

<Guided>This proves the whole chain: the keys reach the bucket, the password decrypts, the files come back identical. Do it once a year too, and after any change to `/etc/restic/env`. On a new server, the same command with `--target /` and no `--include` puts back everything; install restic and recreate `/etc/restic/env` from your password manager first.</Guided>

<Deep>`restic snapshots` lists what exists; `restic ls latest /etc/caddy` browses a snapshot without restoring; `restic mount /mnt/restic` exposes every snapshot as a read-only folder (needs FUSE, `sudo apt install fuse3`). `restic check` verifies the repository structure; `restic check --read-data-subset=5%` also downloads and verifies a sample of the data, which some providers bill as egress.</Deep>

</When>

<When is="OFFSITE" equals="none">

## Keep a copy of /etc yourself

You rely on OVH's backups alone. They cover a bad change, not the loss of the OVH account, an unpaid invoice or a provider incident. At least keep a copy of `/etc` on your laptop, refreshed after each change of configuration. From the laptop:

```bash on="mac"
ssh -t vps 'sudo sh -c "umask 077; tar czf /tmp/etc.tgz /etc" && sudo chown ${USERNAME} /tmp/etc.tgz'
scp vps:/tmp/etc.tgz ./vps-etc-$(date +%F).tgz
ssh vps rm /tmp/etc.tgz
```

<Check cmd="tar tzf vps-etc-$(date +%F).tgz | head -1" expect="etc/" />

<Warn>The archive holds password hashes, the SSH host keys and every secret of the server. Keep it on an encrypted disk (FileVault on the Mac), never in a synced folder such as iCloud Drive or Dropbox.</Warn>

<Guided>`ssh -t` gives sudo a terminal to ask your password. The archive is created with `umask 077`, so no other user on the server can read it during the few seconds it sits in `/tmp`.</Guided>

</When>

## Get told when something breaks

An external service checks the site from the internet and emails you when it stops answering. UptimeRobot is one example; Better Stack and others work the same way. Create a free account and add a monitor:

- Type **Keyword**, URL https://<V name="DOMAIN" />/, every 5 minutes.
- Keyword: a word that only your real home page contains, such as your name in the title.
- Alert contact: <V name="ADMIN_EMAIL" />.

<Guided>A keyword beats a plain status check: the placeholder page from page 5, or an empty release that still answers 200, would pass a status check. The monitor also fails when the certificate is expired, since the HTTPS connection fails; if your plan offers certificate-expiry alerts, turn them on to hear about it days earlier.</Guided>

<Note>Free tiers change often (interval, number of monitors, commercial use): check the current plan on the monitor's pricing page.</Note>

<When is="OFFSITE" equals="s3">

<When flag="HEALTHCHECK">

The uptime monitor watches the site; healthchecks.io watches the backup. It works the other way round: your server pings it after each backup, and it emails you when the ping does **not** come. On healthchecks.io, create an account and **Add Check**:

- Period **1 day**, grace time **2 hours**.
- Integrations: email to <V name="ADMIN_EMAIL" /> (on by default for the account address).
- Copy the ping URL, shown on the check's page, into <V name="HC_PING_URL" />. If you already wrote `restic-backup.service` above, copy its block again (it now carries the URL) and paste it on the server, then reload systemd and run one backup:

```bash on="server" as="${USERNAME}"
sudo systemctl daemon-reload
sudo systemctl start restic-backup.service
```

<Guided>The check turns green after this run. From then on, a night without a successful backup, whatever the cause (disk full, bucket deleted, keys revoked, VPS down), sends you an email the next morning.</Guided>

</When>

</When>

## Keep logs in check

journald keeps up to 10% of the disk by default, up to 4 GB. On a 40 GB VPS, cap it at 500 MB with a drop-in, `/etc/systemd/journald.conf.d/size.conf`:

```bash on="server" as="${USERNAME}"
sudo mkdir -p /etc/systemd/journald.conf.d
```

```ini file="/etc/systemd/journald.conf.d/size.conf" on="server" as="${USERNAME}"
[Journal]
SystemMaxUse=500M
```

Copy the command and paste it in the terminal, then restart journald and look at its size:

```bash on="server" as="${USERNAME}"
sudo systemctl restart systemd-journald
journalctl --disk-usage
```

<Check cmd="systemd-analyze cat-config systemd/journald.conf | grep '^SystemMaxUse'" expect="SystemMaxUse=500M" />

<Guided>Caddy's access logs already rotate on their own (page 5). With the journal capped too, the only thing that can still fill the disk is something you put there yourself.</Guided>

<Deep>When the cap is reached, journald deletes the oldest archived files first. 500 MB holds weeks of logs on a quiet server; `journalctl --disk-usage` shows where you are. `SystemMaxUse` counts only the persistent journal in `/var/log/journal`; `RuntimeMaxUse` caps the in-memory one.</Deep>

## The monthly routine

Ten minutes, once a month, same day each month. From your laptop, `ssh vps`, then:

```bash on="server" as="${USERNAME}"
# 1. Packages to upgrade: install them with sudo apt upgrade
sudo apt update && apt list --upgradable
# 2. After a kernel update: sudo reboot, then check the site
[ -f /var/run/reboot-required ] && echo "reboot needed"
# 3. Disk use should stay under 80%
df -h /
# 4. Banned addresses (if fail2ban was chosen on page 3): nothing to do unless one is yours
sudo fail2ban-client status sshd 2>/dev/null || echo "fail2ban not installed"
# 5. Expect "0 loaded units listed"
systemctl --failed
# 6. The month's errors: read them once, search the new ones
sudo journalctl -p err --since '30 days ago' | tail -n 30
```

<When is="OFFSITE" equals="s3">

Then the backups: the timer shows the next run, and the last snapshot should be from last night.

```bash on="server" as="${USERNAME}"
systemctl list-timers restic-backup.timer
sudo bash -c 'set -a; . /etc/restic/env; restic snapshots --latest 3'
```

</When>

Last, open the uptime monitor's dashboard and look at the month's uptime and incidents. Then, on the Mac, check that Time Machine saved the project folder recently:

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

<Guided>Once a year, add: re-test a restore<When is="OFFSITE" equals="none"> (open your latest `/etc` archive)</When>, and restore one file of <V name="LOCAL_DIR" /> from Time Machine on the Mac, to prove that side works too.</Guided>

<Deep>Every two years or so comes a new Debian stable; the next one after trixie is expected around mid-2027, and trixie keeps security support for about a year after that. Plan the upgrade: take an OVH snapshot first, read the release notes, then follow them (`sources.list` to the new codename, `apt full-upgrade`, reboot). Check Caddy's apt repository supports the new release before starting. If anything goes wrong, restore the snapshot and try again another day.</Deep>

## Done

Your server's state is saved three ways<When is="OFFSITE" equals="s3">, and a restore is proven</When><When is="OFFSITE" equals="none"> (two at OVH, one on your laptop)</When>. You get an email when the site stops answering, the logs cannot fill the disk, and a monthly routine keeps it all honest.

The next page, **Launch checklist**, goes through everything once more before you announce the site.
````

````mdx title="content/ovh-vps-static-site/backups-and-monitoring/page-fr.mdx"
{/* Premier jet — à valider avant publication contre https://restic.readthedocs.io , https://docs.ovhcloud.com (Object Storage S3, premiers pas ; sauvegardes automatiques et snapshots du VPS) et https://healthchecks.io/docs/ . */}

Le site est en ligne. Cette page fait en sorte que, le jour où quelque chose casse, tu sois le premier au courant et que tu puisses tout remettre en place. Tu décides de ce qui mérite d'être sauvegardé, tu trouves les sauvegardes d'OVH, tu envoies chaque nuit une copie chiffrée hors site avec restic, tu restaures une fois pour prouver que ça marche, tu mets une surveillance externe sur le site, tu plafonnes les logs, et tu termines par une routine de dix minutes à faire une fois par mois.

<Run>

Sur le serveur (`ssh vps`), ouvre un nouveau fichier `backup-setup.sh` avec <V name="EDITOR" />, colle-y dans l'ordre tous les blocs de code de cette section, puis lance `sudo bash backup-setup.sh` (sudo te demande ton mot de passe une fois). Les pages 3 à 6 doivent être faites<When is="OFFSITE" equals="s3">, le bucket et ses clés S3 créés, et toutes les valeurs du groupe « Sauvegarde hors site » remplies dans le panneau</When><When flag="HEALTHCHECK">, ainsi que `HC_PING_URL` dans le groupe « Surveillance »</When>.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Sauvegardes et surveillance — ${DOMAIN}
install -d /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/size.conf <<'EOF'
[Journal]
SystemMaxUse=500M
EOF
systemctl restart systemd-journald
```

<When is="OFFSITE" equals="s3">

<When flag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
[ -n "${HC_PING_URL}" ] || { echo "Fill HC_PING_URL first"; exit 1; }
```

</When>

```bash on="server" as="${USERNAME}"
for i in $(seq 30); do apt-get -o DPkg::Lock::Timeout=300 update && break; sleep 10; done
apt-get -o DPkg::Lock::Timeout=300 install -y restic curl
install -d -m 700 /etc/restic
umask 077
cat > /etc/restic/env <<'EOF'
RESTIC_REPOSITORY=s3:${S3_ENDPOINT}/${S3_BUCKET}
AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
RESTIC_PASSWORD=${RESTIC_PASSWORD}
EOF
umask 022
cat > /etc/restic/excludes <<'EOF'
/home/*/.cache
/var/lib/caddy/.local/share/caddy/locks
EOF
set -a; . /etc/restic/env; set +a
restic cat config >/dev/null 2>&1 || restic init
```

<When notFlag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.service <<'EOF'
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
EOF
```

</When>

<When flag="HEALTHCHECK">

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.service <<'EOF'
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${HC_PING_URL}
EOF
```

</When>

```bash on="server" as="${USERNAME}"
cat > /etc/systemd/system/restic-backup.timer <<'EOF'
[Unit]
Description=Run restic-backup every night

[Timer]
OnCalendar=*-*-* ${BACKUP_TIME}:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now restic-backup.timer
systemctl start restic-backup.service
restic restore latest --target /tmp/restore-test --include /etc/caddy
diff -r /etc/caddy /tmp/restore-test/etc/caddy
echo "restore OK"
rm -rf /tmp/restore-test
systemctl list-timers restic-backup.timer
echo "Terminé. Range RESTIC_PASSWORD dans ton gestionnaire de mots de passe si ce n'est pas déjà fait."
```

<Warn>Si tu perds <V name="RESTIC_PASSWORD" />, toutes les sauvegardes sont illisibles pour toujours. Range-le dans ton gestionnaire de mots de passe, avec les clés S3, avant de lancer le script.</Warn>

</When>

<When is="OFFSITE" equals="none">

Sans copie hors site, le script ne fait que plafonner le journal. Copie `/etc` sur ton portable à la main, comme expliqué dans « Garder toi-même une copie de /etc ».

</When>

</Run>

## Avant de commencer

Les pages 3 à 6 sont faites : tu te connectes avec `ssh vps`, Caddy sert le site en HTTPS, et les déploiements passent par `vps-deploy`. Tout ce qui suit se lance sur le serveur après `ssh vps`, en <V name="USERNAME" />, sauf les tableaux de bord web et les blocs marqués pour le Mac.

<Check cmd="systemctl is-active caddy" expect="active" />

<Guided>Il te faut aussi un gestionnaire de mots de passe. Cette page crée des secrets qui n'existent nulle part ailleurs : le mot de passe restic et les clés S3. Un serveur qui brûle emporte sa copie avec lui ; celle de ton gestionnaire de mots de passe est la seule qui compte.</Guided>

## Quoi sauvegarder

La source du site ne vit que dans le dossier du projet sur ton Mac, <V name="LOCAL_DIR" /> : c'est de là que le site se reconstruit et se redéploie en quelques minutes (pages 1 et 6). Ce que le Mac ne sait pas reconstruire, c'est l'état du serveur lui-même.

| Chemin | Ce qu'il contient | Si tu le perds |
|---|---|---|
| `/etc` | Le fichier SSH complémentaire, le Caddyfile, les règles ufw, les jails fail2ban, les sources apt, sudoers, utilisateurs et groupes | Refaire les pages 3 à 5 à la main : une heure ou deux, avec de la place pour les erreurs |
| `/var/lib/caddy` | Les certificats TLS et le compte ACME | Caddy en obtient de nouveaux tout seul, dans les limites de Let's Encrypt |
| `/home` | Tes fichiers de config perso, les `authorized_keys` des deux utilisateurs, tes scripts | Remettre les clés par la console KVM |
| <V name="WEB_ROOT" /> | Les releases construites, pas la source : ni code, ni originaux | Redéploie depuis le Mac ; cette copie ne sert que si le Mac et sa sauvegarde ont disparu tous les deux |
| `/var/lib/<app>` (plus tard) | Les données d'un backend, quand tu en ajouteras un | Irremplaçable : la vraie raison d'être des sauvegardes |

<Guided>Cette page s'occupe du serveur. Le dossier du projet sur le Mac est sauvegardé par Time Machine, mis en place à la page 1 ; c'est là que vivent le code et les photos originales. La racine web est quand même sauvegardée ici : ça ne coûte presque rien, puisque les releases sont liées en dur et que restic ne stocke qu'une fois les fichiers identiques, et c'est la seule autre copie du site construit si le Mac et son disque Time Machine disparaissaient ensemble. C'est une copie du résultat, jamais un substitut de la source.</Guided>

<Deep>Let's Encrypt accepte cinq certificats par semaine pour un même ensemble de noms. Reconstruire un serveur plusieurs fois dans une journée d'essais, sans `/var/lib/caddy`, c'est comme ça qu'on atteint la limite et qu'on attend une semaine. Caddy se rabattrait alors sur ZeroSSL, que l'enregistrement CAA de la page 4 n'autorise peut-être pas. Un bonus utile : avant chaque sauvegarde, écris la liste des paquets installés à la main avec `apt-mark showmanual > /etc/apt/manual-packages.txt`, pour qu'une reconstruction commence par un seul `apt install`.</Deep>

## Sauvegardes et snapshots OVH

Dans l'espace client OVH, ouvre ton VPS (**Bare Metal Cloud** → **Serveurs privés virtuels** → le VPS). L'onglet **Accueil** liste les options : **Sauvegarde automatique** et **Snapshot**.

- **Sauvegarde automatique** : incluse avec le VPS, une par jour, un jour de rétention. Le `...` à côté → **Restaurer**.
- **Snapshot** : une option payante. Tu le prends à la main, avant un changement risqué : une montée de version majeure de Debian, une config Caddy dont tu n'es pas sûr, un changement de pare-feu.

<Warn>Restaurer une sauvegarde ou un snapshot écrase tout le disque du VPS. Tout ce qui a été écrit depuis, logs et fichiers envoyés par le déploiement compris, disparaît. L'option snapshot coûte chaque mois : vérifie le prix actuel sur ovhcloud.com/fr/vps.</Warn>

<Guided>

Trois couches, chacune contre un accident différent :

1. **La sauvegarde automatique OVH** : « j'ai tout cassé hier ». Le VPS entier revient un jour en arrière, en un clic.
2. **Le snapshot OVH** : « je vais faire un truc risqué ». Pris quelques minutes avant, restauré quelques minutes après.
3. **Une copie hors site que tu contrôles** : « le VPS, le datacenter ou le compte OVH a disparu ». Un compte bloqué, une facture impayée, un incendie de datacenter : les deux premières couches vivent chez OVH et partent avec.

</Guided>

<Deep>Les deux couches OVH travaillent au niveau du disque : elles ne savent pas te rendre un seul fichier sans remettre tout le reste en arrière. L'espace client peut aussi monter une sauvegarde automatique pour y piocher des fichiers ; vérifie la procédure actuelle dans le guide OVHcloud « Utiliser les sauvegardes automatiques sur un VPS ». Un seul snapshot existe à la fois : en prendre un nouveau remplace l'ancien.</Deep>

<Note>OVH déplace ses menus de temps en temps. Si le chemin ci-dessus a changé, cherche « snapshot VPS » et « sauvegarde automatique VPS » sur docs.ovhcloud.com.</Note>

<When is="OFFSITE" equals="s3">

## Créer le bucket et les clés

La copie hors site part vers un stockage objet S3 : un bucket, plus un utilisateur S3 dont restic utilise les clés. Chez OVH, dans l'espace client :

1. **Public Cloud** → crée un projet si tu n'en as pas (il faut un moyen de paiement).
2. **Object Storage** → **Créer un conteneur d'objets** : API S3, classe standard, région **GRA** (Gravelines), nom <V name="S3_BUCKET" />.
3. **Object Storage** → **Utilisateurs S3** → crée un utilisateur, donne-lui lecture/écriture sur le conteneur, et copie sa **clé d'accès** et sa **clé secrète** dans le panneau des valeurs et dans ton gestionnaire de mots de passe.

<Warn>Public Cloud est facturé à l'usage. Pour quelques dizaines de mégaoctets de configuration, compte quelques centimes par mois (le stockage coûte de l'ordre de 0,01 € par Go et par mois), mais vérifie le prix actuel sur la page Object Storage d'OVHcloud avant de créer le projet.</Warn>

<Note>Les noms de menus bougent, et le point d'accès S3 dépend de la région choisie. Vérifie celui de ta région dans le guide OVHcloud « Object Storage — Premiers pas avec S3 » et copie-le dans <V name="S3_ENDPOINT" />.</Note>

<Guided>Pourquoi un second fournisseur serait encore mieux : un bucket chez OVH survit à la perte du VPS, pas à celle du compte OVH. **Infomaniak Swiss Backup** (là où vit déjà ton domaine) ou **Backblaze B2** parlent aussi S3 ; restic s'en moque. Change le point d'accès et les clés, et rien d'autre ne bouge sur cette page.</Guided>

<Deep>L'utilisateur S3 n'a besoin d'accéder qu'à ce bucket. Ne réutilise pas un utilisateur qui voit d'autres conteneurs : les clés sont sur le serveur, donc quiconque prend le serveur pourrait effacer les sauvegardes avec. Le chiffrement de restic protège le contenu, pas l'existence des objets. Si ce risque compte pour toi, active le verrouillage d'objets ou le versioning sur le bucket, là où le fournisseur le propose, pour qu'une suppression puisse être annulée.</Deep>

## Mettre en place restic

restic fait des snapshots chiffrés et dédupliqués de répertoires dans un « dépôt », ici le bucket. Installe-le, puis écris ses identifiants dans un fichier que seul root peut lire.

```bash on="server" as="${USERNAME}"
sudo apt install -y restic
sudo install -d -m 700 /etc/restic
sudo install -m 600 /dev/null /etc/restic/env
```

Puis écris les identifiants dedans : copie la commande et colle-la dans le terminal. Le fichier existe déjà en mode 600, et `tee` garde ce mode : seul root peut lire les secrets.

```ini file="/etc/restic/env" on="server" as="${USERNAME}"
RESTIC_REPOSITORY=s3:${S3_ENDPOINT}/${S3_BUCKET}
AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
RESTIC_PASSWORD=${RESTIC_PASSWORD}
```

<Warn>Si tu perds <V name="RESTIC_PASSWORD" />, toutes les sauvegardes sont illisibles pour toujours : personne, même pas OVH, ne peut les déchiffrer. Range-le dans ton gestionnaire de mots de passe maintenant, avant la première sauvegarde.</Warn>

Ensuite la liste de ce qu'on ignore, `/etc/restic/excludes` :

```text file="/etc/restic/excludes" on="server" as="${USERNAME}"
/home/*/.cache
/var/lib/caddy/.local/share/caddy/locks
```

Et le dépôt lui-même :

```bash on="server" as="${USERNAME}"
sudo bash -c 'set -a; . /etc/restic/env; restic init'
```

<Guided>`set -a` exporte toutes les variables lues ensuite, donc `. /etc/restic/env` passe les quatre valeurs à restic sans qu'elles apparaissent jamais sur la ligne de commande ni dans ton historique. `restic init` crée la structure du dépôt et sa clé de chiffrement, dérivée du mot de passe. Il affiche `created restic repository … at s3:…`.</Guided>

<Details summary="Si restic init échoue">

- `The specified bucket does not exist` : le nom du bucket ou la région du point d'accès ne correspond pas. Compare avec l'espace client.
- `Access Denied` ou `SignatureDoesNotMatch` : mauvaises clés, ou l'utilisateur S3 n'a pas de droits sur le conteneur.
- Une erreur de région : ajoute `AWS_DEFAULT_REGION=gra` (ta région, en minuscules) dans `/etc/restic/env`.
- `config file already exists` : le dépôt est déjà initialisé, rien à faire.

Pour modifier `/etc/restic/env` à la main :

```bash on="server" as="${USERNAME}"
sudo ${EDITOR} /etc/restic/env
```

</Details>

Maintenant le travail de nuit : un service systemd qui fait la sauvegarde et élague les vieux snapshots, et un timer qui le lance.

<Tabs group="restic-units">

<Tab label="restic-backup.service">

<When notFlag="HEALTHCHECK">

```ini file="/etc/systemd/system/restic-backup.service" on="server" as="${USERNAME}" {12-13}
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
```

</When>

<When flag="HEALTHCHECK">

Avec healthchecks.io, la dernière ligne le pingue après une sauvegarde réussie. Son URL vient du check que tu crées plus bas, dans « Être prévenu quand ça casse » : le bloc se copie une fois <V name="HC_PING_URL" /> rempli, alors crée d'abord le check, ou reviens à ce bloc ensuite.

```ini file="/etc/systemd/system/restic-backup.service" on="server" as="${USERNAME}" {12-14}
[Unit]
Description=Nightly restic backup to off-site S3
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
Environment=RESTIC_CACHE_DIR=/var/cache/restic
CacheDirectory=restic
Nice=10
ExecStart=/usr/bin/restic backup /etc /var/lib/caddy /home ${WEB_ROOT} --exclude-file=/etc/restic/excludes --tag nightly
ExecStartPost=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${HC_PING_URL}
```

</When>

</Tab>

<Tab label="restic-backup.timer">

```ini file="/etc/systemd/system/restic-backup.timer" on="server" as="${USERNAME}"
[Unit]
Description=Run restic-backup every night

[Timer]
OnCalendar=*-*-* ${BACKUP_TIME}:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
```

</Tab>

</Tabs>

Copie chaque bloc et colle-le dans le terminal du serveur : chacun écrit son fichier. Puis active le timer et lance une sauvegarde tout de suite :

```bash on="server" as="${USERNAME}"
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
sudo systemctl start restic-backup.service
```

<Check cmd="systemctl is-active restic-backup.timer" expect="active" />

<Check cmd="sudo systemctl show -p Result restic-backup.service" expect="Result=success" />

<Guided>Le service garde 7 snapshots quotidiens, 4 hebdomadaires et 6 mensuels : six mois d'historique pour quelques mégaoctets, puisque restic ne stocke chaque bloc modifié qu'une fois. `Persistent=true` rattrape au démarrage suivant une sauvegarde manquée pendant que le VPS était éteint. `systemctl start` attend la fin de la sauvegarde, donc la seconde vérification lit le résultat de celle que tu viens de lancer.</Guided>

<Deep>Les lignes `ExecStartPost` ne tournent que si `ExecStart` a réussi : une sauvegarde ratée n'élague jamais rien. restic sort avec le code 3 quand certains fichiers n'ont pas pu être lus : le snapshot existe mais il est incomplet, et le service est marqué en échec exprès. Le `-` devant la ligne curl dit à systemd d'ignorer son échec : une panne chez healthchecks.io ne doit pas faire passer une bonne sauvegarde pour ratée. `CacheDirectory` donne à restic un cache local dans `/var/cache/restic`, ce qui évite de retélécharger l'index depuis S3 à chaque fois. Marre du préfixe `bash -c` ? Un wrapper `/usr/local/sbin/rst` contenant `set -a; . /etc/restic/env; set +a; exec restic "$@"` (mode 700) te permet de taper `sudo rst snapshots`.</Deep>

## Tester une restauration

Une sauvegarde jamais restaurée est un espoir, pas une sauvegarde. Restaure la configuration de Caddy dans un dossier de test et compare-la avec celle en service :

```bash on="server" as="${USERNAME}"
sudo bash -c 'set -a; . /etc/restic/env; restic restore latest --target /tmp/restore-test --include /etc/caddy'
sudo diff -r /etc/caddy /tmp/restore-test/etc/caddy && echo "restore OK"
```

<Check cmd="sudo diff -r /etc/caddy /tmp/restore-test/etc/caddy && echo 'restore OK'" expect="restore OK" />

Ensuite, supprime la copie de test avec `sudo rm -rf /tmp/restore-test`.

<Guided>Ça prouve toute la chaîne : les clés atteignent le bucket, le mot de passe déchiffre, les fichiers reviennent identiques. Refais-le une fois par an, et après chaque modification de `/etc/restic/env`. Sur un serveur neuf, la même commande avec `--target /` et sans `--include` remet tout en place ; installe d'abord restic et recrée `/etc/restic/env` depuis ton gestionnaire de mots de passe.</Guided>

<Deep>`restic snapshots` liste ce qui existe ; `restic ls latest /etc/caddy` parcourt un snapshot sans rien restaurer ; `restic mount /mnt/restic` expose tous les snapshots comme un dossier en lecture seule (il faut FUSE, `sudo apt install fuse3`). `restic check` vérifie la structure du dépôt ; `restic check --read-data-subset=5%` télécharge et vérifie en plus un échantillon des données, ce que certains fournisseurs facturent en trafic sortant.</Deep>

</When>

<When is="OFFSITE" equals="none">

## Garder toi-même une copie de /etc

Tu comptes sur les seules sauvegardes d'OVH. Elles couvrent un mauvais changement, pas la perte du compte OVH, une facture impayée ou un incident chez l'hébergeur. Garde au moins une copie de `/etc` sur ton portable, rafraîchie après chaque changement de configuration. Depuis le portable :

```bash on="mac"
ssh -t vps 'sudo sh -c "umask 077; tar czf /tmp/etc.tgz /etc" && sudo chown ${USERNAME} /tmp/etc.tgz'
scp vps:/tmp/etc.tgz ./vps-etc-$(date +%F).tgz
ssh vps rm /tmp/etc.tgz
```

<Check cmd="tar tzf vps-etc-$(date +%F).tgz | head -1" expect="etc/" />

<Warn>L'archive contient les empreintes des mots de passe, les clés d'hôte SSH et tous les secrets du serveur. Garde-la sur un disque chiffré (FileVault sur le Mac), jamais dans un dossier synchronisé comme iCloud Drive ou Dropbox.</Warn>

<Guided>`ssh -t` donne un terminal à sudo pour qu'il demande ton mot de passe. L'archive est créée avec `umask 077`, donc aucun autre utilisateur du serveur ne peut la lire pendant les quelques secondes où elle reste dans `/tmp`.</Guided>

</When>

## Être prévenu quand ça casse

Un service externe interroge le site depuis internet et t'envoie un email quand il ne répond plus. UptimeRobot en est un exemple ; Better Stack et d'autres marchent pareil. Crée un compte gratuit et ajoute une sonde :

- Type **Keyword** (mot-clé), URL https://<V name="DOMAIN" />/, toutes les 5 minutes.
- Mot-clé : un mot que seule ta vraie page d'accueil contient, par exemple ton nom dans le titre.
- Contact d'alerte : <V name="ADMIN_EMAIL" />.

<Guided>Un mot-clé vaut mieux qu'un simple contrôle de statut : la page provisoire de la page 5, ou une release vide qui répond quand même 200, passerait un contrôle de statut. La sonde échoue aussi quand le certificat a expiré, puisque la connexion HTTPS échoue ; si ton offre propose des alertes d'expiration de certificat, active-les pour l'apprendre quelques jours plus tôt.</Guided>

<Note>Les offres gratuites changent souvent (intervalle, nombre de sondes, usage commercial) : vérifie l'offre actuelle sur la page de tarifs du service.</Note>

<When is="OFFSITE" equals="s3">

<When flag="HEALTHCHECK">

La sonde surveille le site ; healthchecks.io surveille la sauvegarde. Il marche dans l'autre sens : ton serveur le pingue après chaque sauvegarde, et il t'écrit quand le ping n'arrive **pas**. Sur healthchecks.io, crée un compte puis **Add Check** :

- Période **1 day**, délai de grâce **2 hours**.
- Intégrations : email vers <V name="ADMIN_EMAIL" /> (actif par défaut pour l'adresse du compte).
- Copie l'URL de ping, affichée sur la page du check, dans <V name="HC_PING_URL" />. Si tu as déjà écrit `restic-backup.service` plus haut, recopie son bloc (il contient maintenant l'URL) et colle-le sur le serveur, puis recharge systemd et lance une sauvegarde :

```bash on="server" as="${USERNAME}"
sudo systemctl daemon-reload
sudo systemctl start restic-backup.service
```

<Guided>Le check passe au vert après ce lancement. Dès lors, une nuit sans sauvegarde réussie, quelle qu'en soit la cause (disque plein, bucket supprimé, clés révoquées, VPS éteint), t'envoie un email le lendemain matin.</Guided>

</When>

</When>

## Garder les logs sous contrôle

Par défaut, journald garde jusqu'à 10 % du disque, plafonné à 4 Go. Sur un VPS de 40 Go, plafonne-le à 500 Mo avec un fichier complémentaire, `/etc/systemd/journald.conf.d/size.conf` :

```bash on="server" as="${USERNAME}"
sudo mkdir -p /etc/systemd/journald.conf.d
```

```ini file="/etc/systemd/journald.conf.d/size.conf" on="server" as="${USERNAME}"
[Journal]
SystemMaxUse=500M
```

Copie la commande et colle-la dans le terminal, puis redémarre journald et regarde sa taille :

```bash on="server" as="${USERNAME}"
sudo systemctl restart systemd-journald
journalctl --disk-usage
```

<Check cmd="systemd-analyze cat-config systemd/journald.conf | grep '^SystemMaxUse'" expect="SystemMaxUse=500M" />

<Guided>Les logs d'accès de Caddy tournent déjà tout seuls (page 5). Avec le journal plafonné aussi, la seule chose qui peut encore remplir le disque, c'est ce que tu y mets toi-même.</Guided>

<Deep>Quand le plafond est atteint, journald supprime d'abord les plus vieux fichiers archivés. 500 Mo, c'est des semaines de logs sur un serveur calme ; `journalctl --disk-usage` te dit où tu en es. `SystemMaxUse` ne compte que le journal persistant dans `/var/log/journal` ; `RuntimeMaxUse` plafonne celui en mémoire.</Deep>

## La routine mensuelle

Dix minutes, une fois par mois, le même jour chaque mois. Depuis ton portable, `ssh vps`, puis :

```bash on="server" as="${USERNAME}"
# 1. Paquets à mettre à jour : installe-les avec sudo apt upgrade
sudo apt update && apt list --upgradable
# 2. Après une mise à jour du noyau : sudo reboot, puis vérifie le site
[ -f /var/run/reboot-required ] && echo "reboot needed"
# 3. L'occupation du disque doit rester sous 80 %
df -h /
# 4. Adresses bannies (si fail2ban a été choisi page 3) : rien à faire, sauf si l'une est la tienne
sudo fail2ban-client status sshd 2>/dev/null || echo "fail2ban not installed"
# 5. Attendu : "0 loaded units listed"
systemctl --failed
# 6. Les erreurs du mois : lis-les une fois, cherche les nouvelles
sudo journalctl -p err --since '30 days ago' | tail -n 30
```

<When is="OFFSITE" equals="s3">

Ensuite les sauvegardes : le timer montre le prochain passage, et le dernier snapshot doit dater de la nuit dernière.

```bash on="server" as="${USERNAME}"
systemctl list-timers restic-backup.timer
sudo bash -c 'set -a; . /etc/restic/env; restic snapshots --latest 3'
```

</When>

Pour finir, ouvre le tableau de bord de la sonde et regarde la disponibilité et les incidents du mois. Puis, sur le Mac, vérifie que Time Machine a sauvegardé le dossier du projet récemment :

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

<Guided>Une fois par an, ajoute : retester une restauration<When is="OFFSITE" equals="none"> (ouvre ta dernière archive de `/etc`)</When>, et restaurer un fichier de <V name="LOCAL_DIR" /> depuis Time Machine sur le Mac, pour prouver que ce côté-là marche aussi.</Guided>

<Deep>Tous les deux ans environ sort une nouvelle Debian stable ; celle qui suivra trixie est attendue vers mi-2027, et trixie garde son support de sécurité environ un an après. Prépare la montée de version : prends d'abord un snapshot OVH, lis les notes de version, puis suis-les (`sources.list` vers le nouveau nom de code, `apt full-upgrade`, redémarrage). Vérifie avant de commencer que le dépôt apt de Caddy prend en charge la nouvelle version. Si ça tourne mal, restaure le snapshot et réessaie un autre jour.</Deep>

## Terminé

L'état de ton serveur est sauvegardé de trois façons<When is="OFFSITE" equals="s3">, et une restauration a fait ses preuves</When><When is="OFFSITE" equals="none"> (deux chez OVH, une sur ton portable)</When>. Tu reçois un email quand le site ne répond plus, les logs ne peuvent plus remplir le disque, et une routine mensuelle vérifie que tout ça tient.

La page suivante, **Checklist de lancement**, repasse sur tout une dernière fois avant que tu annonces le site.
````

````yaml title="content/ovh-vps-static-site/backups-and-monitoring/diagram.yaml"
# Quick: what is saved, restic, the bucket, OVH's own backup, the site, the uptime monitor, you.
# Guided: healthchecks.io watching the backup job. Deep: the systemd timer, the repository
# password, the journald cap. No secret variable ever appears here.
title: { en: "Saved three ways, watched from outside", fr: "Sauvegardé de trois façons, surveillé de l'extérieur" }
caption:
  en: "Every night restic reads the server's configuration and certificates, encrypts them and sends them to a bucket outside the VPS; OVH keeps its own copy of the whole disk. An external monitor checks the site every five minutes and emails you when it stops answering."
  fr: "Chaque nuit, restic lit la configuration et les certificats du serveur, les chiffre et les envoie dans un bucket hors du VPS ; OVH garde sa propre copie du disque entier. Une sonde externe interroge le site toutes les cinq minutes et t'écrit quand il ne répond plus."

groups:
  - id: vps
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The Debian server from pages 2 to 6. What lives only here is what needs saving."
      fr: "Le serveur Debian des pages 2 à 6. Ce qui ne vit qu'ici, c'est ce qu'il faut sauvegarder."

nodes:
  - id: state
    kind: file
    label: { en: "Config, certs, web root", fr: "Config, certificats, racine web" }
    sub: "/etc · /var/lib/caddy · /home · ${WEB_ROOT}"
    in: vps
    desc:
      en: "The server's own state: SSH, Caddy, ufw and fail2ban configuration, users, TLS certificates, home folders. Plus the built site in ${WEB_ROOT}: a copy of the output, not the source, which lives only on the Mac."
      fr: "L'état propre du serveur : configuration SSH, Caddy, ufw et fail2ban, utilisateurs, certificats TLS, dossiers personnels. Plus le site construit dans ${WEB_ROOT} : une copie du résultat, pas la source, qui ne vit que sur le Mac."
    deep:
      sub: "/etc · /var/lib/caddy (ACME account + certs) · /home · ${WEB_ROOT} (hard-linked releases, deduplicated) · minus /etc/restic/excludes"
  - id: restic
    kind: service
    label: { en: "restic", fr: "restic" }
    sub: "nightly · 7d / 4w / 6m"
    in: vps
    focus: true
    when: { is: OFFSITE, equals: s3 }
    desc:
      en: "Takes an encrypted, deduplicated snapshot every night, then forgets the old ones: 7 daily, 4 weekly, 6 monthly."
      fr: "Prend chaque nuit un snapshot chiffré et dédupliqué, puis oublie les anciens : 7 quotidiens, 4 hebdomadaires, 6 mensuels."
    deep:
      sub: "restic-backup.service · oneshot · backup + forget --prune"
  - id: bucket
    kind: store
    label: { en: "S3 bucket", fr: "Bucket S3" }
    sub: "${S3_BUCKET}"
    when: { is: OFFSITE, equals: s3 }
    desc:
      en: "Object storage outside the VPS. It holds only encrypted blobs: without the repository password, they are noise."
      fr: "Un stockage objet hors du VPS. Il ne contient que des blocs chiffrés : sans le mot de passe du dépôt, c'est du bruit."
    deep:
      sub: "${S3_ENDPOINT}/${S3_BUCKET}"
  - id: laptop-copy
    kind: file
    label: { en: "/etc archive", fr: "Archive de /etc" }
    sub: "on your Mac"
    level: guided
    when: { is: OFFSITE, equals: none }
    desc:
      en: "Without an off-site copy, a tarball of /etc on your laptop is the only copy that survives losing the OVH account. Keep it on an encrypted disk."
      fr: "Sans copie hors site, une archive de /etc sur ton portable est la seule copie qui survit à la perte du compte OVH. Garde-la sur un disque chiffré."
  - id: ovh-backup
    kind: store
    label: { en: "OVH backup", fr: "Sauvegarde OVH" }
    sub: "daily · 1 day kept"
    desc:
      en: "Included with the VPS: an image of the whole disk every day. Restoring it rolls the entire VPS back and overwrites everything since."
      fr: "Incluse avec le VPS : une image du disque entier chaque jour. La restaurer remet tout le VPS en arrière et écrase tout ce qui a suivi."
    guided:
      sub: "daily · 1 day kept · + snapshot before risky changes"
  - id: site
    kind: net
    label: { en: "Your site", fr: "Ton site" }
    sub: "https://${DOMAIN}"
    in: vps
    desc:
      en: "Caddy serving the current release. What your visitors see, and what the monitor checks."
      fr: "Caddy qui sert la release courante. Ce que voient tes visiteurs, et ce que la sonde vérifie."
    deep:
      sub: "Caddy · :443 · ${WEB_ROOT}/current"
  - id: monitor
    kind: cloud
    label: { en: "Uptime monitor", fr: "Sonde de disponibilité" }
    sub: "every 5 min · keyword"
    desc:
      en: "An external service (UptimeRobot or similar) that fetches the home page and looks for a word only the real page contains."
      fr: "Un service externe (UptimeRobot ou équivalent) qui charge la page d'accueil et y cherche un mot que seule la vraie page contient."
  - id: healthchecks
    kind: cloud
    label: { en: "healthchecks.io", fr: "healthchecks.io" }
    sub: "period 1 day · grace 2 h"
    level: guided
    when: { flag: HEALTHCHECK }
    desc:
      en: "A dead man's switch: it expects a ping after each backup and emails you when one does not come."
      fr: "Un interrupteur d'homme mort : il attend un ping après chaque sauvegarde et t'écrit quand il n'arrive pas."
  - id: you
    kind: user
    label: { en: "You", fr: "Toi" }
    sub: "${ADMIN_EMAIL}"
    desc:
      en: "The one who hears first. Alerts land in this inbox; the monthly routine is ten minutes of your time."
      fr: "Celui qui est prévenu le premier. Les alertes arrivent dans cette boîte ; la routine mensuelle te prend dix minutes."
  - id: mac
    kind: client
    label: { en: "Your Mac", fr: "Ton Mac" }
    sub: "${LOCAL_DIR}"
    level: guided
    desc:
      en: "The only home of the site's source: code, content and original photos. Builds and deploys start here (pages 1 and 6)."
      fr: "Le seul foyer de la source du site : code, contenu et photos originales. Les builds et déploiements partent d'ici (pages 1 et 6)."
  - id: timemachine
    kind: store
    label: { en: "Time Machine", fr: "Time Machine" }
    sub: "project folder · page 1"
    level: guided
    desc:
      en: "Backs up the project folder on the Mac. Not covered by this page; the monthly routine only checks its last backup date."
      fr: "Sauvegarde le dossier du projet sur le Mac. Hors du champ de cette page ; la routine mensuelle vérifie seulement la date de sa dernière sauvegarde."
  - id: timer
    kind: service
    label: { en: "systemd timer", fr: "Timer systemd" }
    sub: "${BACKUP_TIME} + up to 15 min"
    in: vps
    level: deep
    when: { is: OFFSITE, equals: s3 }
    desc:
      en: "restic-backup.timer starts the service every night; Persistent=true catches up a run missed while the VPS was off."
      fr: "restic-backup.timer lance le service chaque nuit ; Persistent=true rattrape un passage manqué pendant que le VPS était éteint."
  - id: password
    kind: file
    label: { en: "Repository password", fr: "Mot de passe du dépôt" }
    sub: "/etc/restic/env + password manager"
    level: deep
    when: { is: OFFSITE, equals: s3 }
    desc:
      en: "Derives the key that encrypts every snapshot. Lose it and the bucket is unreadable forever; the copy in your password manager is the one that matters."
      fr: "Dérive la clé qui chiffre chaque snapshot. Perds-le et le bucket est illisible pour toujours ; la copie de ton gestionnaire de mots de passe est celle qui compte."
  - id: journald
    kind: file
    label: { en: "journald", fr: "journald" }
    sub: "SystemMaxUse=500M"
    in: vps
    level: deep
    desc:
      en: "The system journal, capped by a drop-in so logs can never fill the 40 GB disk."
      fr: "Le journal système, plafonné par un fichier complémentaire pour que les logs ne puissent jamais remplir le disque de 40 Go."

edges:
  - from: state
    to: restic
    label: "reads"
    when: { is: OFFSITE, equals: s3 }
    desc: { en: "restic reads the three trees as root, minus the excluded caches and locks.", fr: "restic lit les trois arborescences en root, moins les caches et verrous exclus." }
  - from: restic
    to: bucket
    label: "encrypted, nightly"
    when: { is: OFFSITE, equals: s3 }
    deep: { label: "S3 API over HTTPS · only changed blocks" }
    desc: { en: "Only new or changed blocks travel, encrypted before they leave the server.", fr: "Seuls les blocs nouveaux ou modifiés voyagent, chiffrés avant de quitter le serveur." }
  - from: state
    to: laptop-copy
    label: "tar, by hand"
    dashed: true
    level: guided
    when: { is: OFFSITE, equals: none }
    desc: { en: "A tar of /etc copied to your Mac with scp, after each configuration change.", fr: "Une archive tar de /etc copiée sur ton Mac avec scp, après chaque changement de configuration." }
  - from: state
    to: ovh-backup
    label: "whole disk, daily"
    dashed: true
    desc: { en: "OVH images the whole disk, site and logs included. It lives at OVH, so it goes if the account goes.", fr: "OVH copie le disque entier, site et logs compris. La copie vit chez OVH, donc elle part si le compte part." }
  - from: monitor
    to: site
    label: "HTTPS every 5 min"
    deep: { label: "GET / · TLS · keyword match" }
    desc: { en: "A request from outside, like a visitor's. An expired certificate or a missing keyword counts as down.", fr: "Une requête de l'extérieur, comme celle d'un visiteur. Un certificat expiré ou un mot-clé absent compte comme une panne." }
  - from: monitor
    to: you
    label: "email if down"
    desc: { en: "One email when the site goes down, one when it is back.", fr: "Un email quand le site tombe, un autre quand il revient." }
  - from: restic
    to: healthchecks
    label: "ping"
    dashed: true
    level: guided
    when: { flag: HEALTHCHECK }
    desc: { en: "curl to the ping URL, only after a successful backup and prune.", fr: "Un curl vers l'URL de ping, seulement après une sauvegarde et un élagage réussis." }
  - from: healthchecks
    to: you
    label: "email if no ping"
    dashed: true
    level: guided
    when: { flag: HEALTHCHECK }
    desc: { en: "No ping within a day plus two hours of grace: you get an email, whatever the cause.", fr: "Pas de ping en un jour plus deux heures de grâce : tu reçois un email, quelle qu'en soit la cause." }
  - from: mac
    to: site
    label: "deploy"
    dashed: true
    level: guided
    desc: { en: "Page 6: build on the Mac, upload a new release. The server only ever receives the built site.", fr: "Page 6 : build sur le Mac, envoi d'une nouvelle release. Le serveur ne reçoit jamais que le site construit." }
  - from: mac
    to: timemachine
    label: "hourly backup"
    dashed: true
    level: guided
    desc: { en: "Time Machine keeps hourly, daily and weekly copies of the project folder while its disk is connected.", fr: "Time Machine garde des copies horaires, quotidiennes et hebdomadaires du dossier du projet tant que son disque est branché." }
  - from: timer
    to: restic
    label: "starts"
    dashed: true
    level: deep
    when: { is: OFFSITE, equals: s3 }
    desc: { en: "OnCalendar at ${BACKUP_TIME} with a random delay, so thousands of servers do not hit the storage at the same second.", fr: "OnCalendar à ${BACKUP_TIME} avec un délai aléatoire, pour que des milliers de serveurs ne frappent pas le stockage à la même seconde." }
  - from: password
    to: restic
    label: "encrypts"
    dashed: true
    level: deep
    when: { is: OFFSITE, equals: s3 }
    desc: { en: "The password unlocks the repository's master key; every blob is encrypted and authenticated with it.", fr: "Le mot de passe déverrouille la clé maîtresse du dépôt ; chaque bloc est chiffré et authentifié avec elle." }
  - from: site
    to: journald
    label: "logs"
    dashed: true
    level: deep
    desc: { en: "Services log to the journal; the cap keeps it at 500 MB. Caddy's access logs rotate on their own.", fr: "Les services écrivent dans le journal ; le plafond le tient à 500 Mo. Les logs d'accès de Caddy tournent tout seuls." }
````

---

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