# Source of "Commander le VPS chez OVH"

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/order-the-vps/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PUBKEY, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.
# No page variables and no choices: this page only fills SSH_PUBKEY, SERVER_IP and SERVER_IPV6 in the values panel.
title:
  en: Order the VPS at OVH
  fr: Commander le VPS chez OVH
summary:
  en: >-
    A Debian 13 VPS ordered in the OVH control panel, delivered, and reachable as the user debian
    with an SSH key made for it (~/.ssh/id_ed25519_vps), its public half in the values panel. Its IPv4 and IPv6 are noted, and the OVH account that controls it is
    protected by two-factor authentication.
  fr: >-
    Un VPS Debian 13 commandé dans l'espace client OVH, livré, et joignable sous l'utilisateur
    debian avec une clé SSH créée pour lui (~/.ssh/id_ed25519_vps), sa moitié publique dans le
    panneau des valeurs. Ses IPv4 et IPv6 sont notées, et le compte OVH qui le contrôle est protégé
    par une double authentification.
difficulty: beginner
tags: [ovh, vps, debian, ssh, hosting]
authors: [thudal]
created: 2026-09-26
minutes: 15
validated: OVHcloud VPS range Sept 2026 · Debian 13 (trixie)
status: draft             # not yet run end to end by its author
````

````mdx title="content/ovh-vps-static-site/order-the-vps/page-en.mdx"
{/* First pass — to be validated against https://www.ovhcloud.com/fr/vps/ and https://docs.ovhcloud.com/en/guides/bare-metal-cloud/virtual-private-servers/starting-with-a-vps (plus the OVHcloud guide "Securing your OVHcloud account with two-factor authentication") before publishing. */}

This page gets you a server. You protect the OVH account first, make an SSH key just for this server, then order a VPS-1 running plain Debian 13 in a French datacenter with that key preinstalled, wait for the delivery email, note the two IP addresses, and log in once as `debian`. Most of it is clicks in the OVH control panel; the terminal comes in at the end.

<Run>

Ordering happens in your browser: this script only does the laptop side. Run it **on your Mac**, as yourself, once you have made the server's key by hand (step "Make the server's SSH key": it has a passphrase, so the script cannot make it) and pasted its public half into the values panel. It stops if the key is missing, not in the agent, or different from the one in the panel. Open a new file `vps-first-contact.sh` on your laptop, paste the code block of this section into it, then run `bash vps-first-contact.sh`. First run, before ordering: it copies the public key to the clipboard for the order form and stops, because the IPv4 in the values panel is still the example. Second run, once <V name="SERVER_IP" /> is filled from the delivery email: it waits until SSH answers, then prints what you got. After 10 minutes without an answer it stops and prints the last ssh error with the likely causes.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Order the VPS (laptop side) — debian@${SERVER_IP}
KEY="$HOME/.ssh/id_ed25519_vps"
if [ ! -f "$KEY.pub" ]; then
  echo "No $KEY.pub found. Make it first, by hand (step: Make the server's SSH key): it has a passphrase." >&2
  exit 1
fi
if [ "$(cut -d' ' -f1,2 "$KEY.pub")" != "$(printf '%s\n' '${SSH_PUBKEY}' | cut -d' ' -f1,2)" ]; then
  echo "The values panel (Your server's public key) does not hold $KEY.pub. Paste the right line there, then copy this script again." >&2
  exit 1
fi
ssh-add --apple-load-keychain 2>/dev/null || true
if ! ssh-add -l 2>/dev/null | grep -qF "$(ssh-keygen -lf "$KEY.pub" | awk '{print $2}')"; then
  echo "The key is not in the agent. Run once: ssh-add --apple-use-keychain ~/.ssh/id_ed25519_vps, then run this script again." >&2
  exit 1
fi
pbcopy < "$KEY.pub"
echo "Public key copied to the clipboard. Paste it in the SSH key field of the OVH order."
if [ "${SERVER_IP}" = "203.0.113.10" ]; then
  echo "SERVER_IP is still the example value. Order the VPS, fill SERVER_IP from the delivery email, run again."
  exit 0
fi
echo "Waiting for debian@${SERVER_IP} to answer on SSH with $KEY (10 minutes at most)..."
tries=0
until ERR=$(ssh -i "$KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new debian@${SERVER_IP} true 2>&1); do
  tries=$((tries+1))
  if [ "$tries" -ge 60 ]; then
    echo "No SSH answer from debian@${SERVER_IP} after 60 tries. Last error:" >&2
    echo "$ERR" >&2
    echo "Likely causes:" >&2
    echo "  1. SERVER_IP is wrong: compare it with the delivery email and the control panel." >&2
    echo "  2. No key was given at order (Permission denied): /home/debian/.ssh/authorized_keys is empty. Reinstall from the OVH control panel with the key, or carry on with the password (see Add your SSH key)." >&2
    echo "  3. The host key changed (VPS reinstalled): run ssh-keygen -R ${SERVER_IP}, then run this script again." >&2
    exit 1
  fi
  sleep 10
done
ssh -i "$KEY" -o IdentitiesOnly=yes -o BatchMode=yes debian@${SERVER_IP} '
  grep VERSION_CODENAME /etc/os-release
  echo "vCores: $(nproc)"
  free -h | grep Mem
  df -h /
  ip -6 addr show scope global
'
echo "Reachable. Next page: Secure the server in the first hour. Do it now."
```

<Warn>The script does not order or pay anything, but `StrictHostKeyChecking=accept-new` trusts the server's host key on first contact without showing it. The Deep part of "First login" shows how to check it against the KVM console.</Warn>

</Run>

## Before you start

You need four things:

- An OVHcloud account, created on [ovhcloud.com/fr](https://www.ovhcloud.com/fr/). It gives you a customer ID (NIC handle, like `ab12345-ovh`) and access to the control panel.
- A payment method on that account: card, PayPal or SEPA direct debit.
- Your Mac, set up on page 1 (Prepare your laptop). The SSH key for the server is made on this page, right before the order form.
- Nothing about the domain: it stays registered and hosted at Infomaniak. You point it at the VPS on page 4, no transfer needed.

## Protect the OVH account

Before ordering anything, turn on two-factor authentication. In the control panel, open your account (your initials, top right) → **Security** → **Two-factor authentication**, and add a method.

<Warn>This account controls the billing, the VPS, its console, its rescue mode and its reinstall button. Whoever logs into it has the server, whatever you do on page 3. Protect it first.</Warn>

Three methods are offered: an authenticator app (TOTP), a security key (FIDO/U2F), or SMS. Prefer the app or a key; SMS is the weakest of the three. When the first method is added, OVH shows backup codes: store them in your password manager, next to the account password.

<Guided>TOTP means your phone app (1Password, Bitwarden, Google Authenticator, Aegis…) shows a six-digit code that changes every 30 seconds. You scan a QR code once; after that, logging in asks for the password and the current code. Losing the phone without the backup codes means a support procedure with identity documents, which takes days.</Guided>

<Deep>Add two methods if you can: the app plus a hardware key, or the app on two devices. OVH accepts several at once, and any one of them unlocks the account. Also check the contact email of the account: password resets and every VPS notification go there, so it must be an address you read and that is itself protected.</Deep>

<Note>OVH moves its menus from time to time. If the path above has changed, search the OVHcloud docs for "Securing your OVHcloud account with two-factor authentication".</Note>

## Make the server's SSH key

The order form asks for a public SSH key. Make it now, for this server only, in its own file `~/.ssh/id_ed25519_vps`, **with a passphrase**. The command asks for the passphrase twice, so it runs on its own:

```bash on="mac" interactive
ssh-keygen -t ed25519 -C "${USERNAME}@${DOMAIN}-vps" -f ~/.ssh/id_ed25519_vps
```

<Guided>Type a passphrase when asked. It encrypts the private key on disk: a stolen laptop or a leaked backup then gives the thief a useless file. You will not type it all day: the next command stores it in the Keychain and hands the key to the SSH agent. The text after `-C` is only a label, so you recognise the key later in `authorized_keys` and in the OVH control panel.</Guided>

<Warn>If `~/.ssh/id_ed25519_vps` already exists, `ssh-keygen` asks before overwriting. Answer **n** and keep the existing key: overwriting it cuts you off from every machine that already trusts it.</Warn>

Store the passphrase in the Keychain and load the key into the agent. It asks for the passphrase one last time:

```bash on="mac" interactive
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_vps
```

Then copy the public half, and paste it in the field **Your server's public key** of the values panel. Page 3 writes it from there into your own account on the server.

```bash on="mac"
pbcopy < ~/.ssh/id_ed25519_vps.pub
```

<Guided>That one line is the public half of the key pair. It is safe to paste into a web form or a panel: it only lets a server recognise you, it cannot be used to log in by itself. The private half, `~/.ssh/id_ed25519_vps` without `.pub`, never leaves your Mac.</Guided>

<Deep>

Ed25519 keys are short (a 68-character public key), fast, and have no size or curve parameter you could get wrong, which is why OpenSSH and OVH both recommend them over RSA. Passphrase plus agent gives you both properties you want: the key is encrypted at rest, and unlocked only in memory while you are logged in.

Why a key of its own rather than an `~/.ssh/id_ed25519` you may already have: you know exactly what it opens (this server), you can replace it without touching GitHub or any other machine, and ssh never has to guess which key to offer, because every command names it (`-i` on this page, the `vps` shortcut from page 3). If you ever suspect it leaked, make a new pair and replace the line in `authorized_keys`; the old key then opens nothing.

</Deep>

The line on your Mac and the one in the panel must be the same:

<Check cmd="cat ~/.ssh/id_ed25519_vps.pub" expect="${SSH_PUBKEY}" />

## Choose the model

On [ovhcloud.com/fr/vps](https://www.ovhcloud.com/fr/vps/), pick **VPS-1** and click Order. The range in September 2026:

| Model | vCores | RAM | NVMe disk | From, per month incl. VAT |
|---|---|---|---|---|
| VPS-1 | 2 | 4 GB | 40 GB | 4.57 € (3.81 € excl. VAT) |
| VPS-2 | 4 | 8 GB | 75 GB | 8.65 € |
| VPS-3 | 6 | 12 GB | 100 GB | 12.48 € |
| VPS-4 | 8 | 24 GB | 200 GB | 23.95 € |

<Note>Prices move and promotions come and go. Check the current price on ovhcloud.com/fr/vps before ordering.</Note>

VPS-1 is plenty. The site is static: Caddy reads files from disk and sends them. A few hundred visits a day use well under 1 % of one vCore. The 40 GB disk holds the system (about 2 GB), around 20 releases of the site (~217 MB each, and mostly hard-linked to each other, see page 6), the logs and local backups.

<Guided>A **vCore** is a share of a physical CPU core, reserved for you; two is comfortable for a web server plus the occasional `apt upgrade`. **RAM** is working memory; Caddy uses a few dozen MB, Debian itself around 200 MB, so 4 GB leaves room for whatever backend you add later. **NVMe** is the kind of SSD; it makes reading files fast, which is most of what a static site does.</Guided>

<Deep>

- **Traffic**: the range is sold with unlimited traffic; what differs between models is the bandwidth cap, advertised up to 3 Gbit/s at the top of the range. With 44–55 MB WAV files on the site, bandwidth decides how fast a visitor gets one, not whether you pay more. Read the bandwidth line of the model you pick.
- **Commitment**: the order form offers no commitment (monthly) or a 12 or 24 month commitment with a discount. Monthly costs a little more and lets you leave any month; for a first server, that is worth it.
- **VAT**: prices shown "HT" exclude the 20 % French VAT; as a private person you pay the "TTC" price.
- **Changing model later**: upgrading to a bigger model is done from the control panel, keeps the data and the IP, and takes a reboot. Going back down is usually not possible, because a disk cannot shrink. Start small.

</Deep>

<Note>The upgrade/downgrade rules and the per-model bandwidth are from the OVH product page and may change: confirm them on ovhcloud.com/fr/vps and in the OVH VPS guides.</Note>

## Pick the datacenter

In the order form, choose a location in **France**: Gravelines, Roubaix, Strasbourg or Paris, whichever is available for the model.

<Guided>Your readers are mostly in France: a French datacenter means 5–20 ms round trips instead of 80–150 ms from North America or Asia, which shows on every image the page loads. Data held in France also keeps the GDPR side trivial: no transfer outside the EU to document.</Guided>

<Deep>The location is fixed for the life of the VPS: the IPv4 and IPv6 belong to that datacenter's network. Moving means ordering a new VPS elsewhere and migrating, then changing the DNS. Some locations are priced differently; the order form shows the final price per location before you confirm.</Deep>

## Pick the image: Debian 13

For the image, choose **Debian 13** under the plain "distribution only" images. Do not take an image with Plesk, cPanel, Docker or an application preinstalled.

If only Debian 12 is offered, take it: every page of this series works the same on 12 and 13.

<Guided>A panel like Plesk or cPanel installs its own web server, mail server and database, all listening on the internet, all to keep updated. This series installs exactly one service, Caddy, and you will know every open port. A plain image is what makes that possible.</Guided>

<Deep>Debian is chosen for being boring: a stable release every two years, security updates for about three years, then two more years of LTS, so roughly five years per release without surprise upgrades. Ubuntu LTS is the usual alternative and would work with minor changes (same `apt`, same `ufw`, same Caddy packages); the series is written and tested for Debian only.</Deep>

## Add your SSH key

The order form has an optional SSH key field. Paste the public half of the key you just made there (the whole line, from `ssh-ed25519` to <V name="USERNAME" />@<V name="DOMAIN" />-vps) and name it, for example `id_ed25519_vps`. If the clipboard has changed since, copy it again:

```bash on="mac"
pbcopy < ~/.ssh/id_ed25519_vps.pub
```

<Warn>Do not leave this field empty. Without a key at order time, `debian` logs in with the password from the delivery email, and `/home/debian/.ssh/authorized_keys` is **empty**: nothing on the server knows your key, and the step of page 3 that refuses passwords can lock you out.</Warn>

<Guided>With the key preinstalled, your first login needs no password at all: the server already knows your public key when it boots.</Guided>

<Deep>OVH hands the key to **cloud-init**, the first-boot tool of cloud images. On first boot, cloud-init creates the user `debian`, writes your key into `/home/debian/.ssh/authorized_keys`, gives `debian` password-less sudo in `/etc/sudoers.d/90-cloud-init-users`, and may write `/etc/ssh/sshd_config.d/50-cloud-init.conf` with `PasswordAuthentication yes`. Page 3 deals with that file.</Deep>

<Details summary="If you already ordered without a key">

Two ways on, pick one:

- **Reinstall with the key.** In the control panel, VPS page → **Reinstall**, choose Debian 13 again and paste the key into the SSH key field. It wipes the disk, which costs nothing on a server you have not used yet. The IP addresses stay the same; the host key changes (see "If ssh says REMOTE HOST IDENTIFICATION HAS CHANGED" below).
- **Carry on with the password.** Log in as `debian` with the password from the delivery email: ssh asks for it in "First login". Page 3 does not rely on the keys of `debian`: it writes the key from the values panel into your own account and checks it before passwords are refused. On this page, the first check of "First login" fails (it refuses passwords); the others ask for the password.

</Details>

## Options and commitment

**Automated backup** with one day of retention is included in the price: leave it on. The rest is optional:

- **Premium backup**, from about 1.32 € incl. VAT a month: keeps more days of backups. Not needed here; page 7 sets up backups of what matters.
- **Snapshot**, from about 0.36 € incl. VAT a month: one manual, point-in-time copy of the whole VPS that you can restore in one click. Take it: page 3 and page 7 use it before risky changes.

<Warn>An order is a contract. Before you pay, read the recap: model, location, options, **commitment period** and total incl. VAT. A 12 or 24 month commitment cannot be cancelled early for a refund, and options are billed every month until you remove them.</Warn>

<Note>Option prices and the included backup retention are from the OVH VPS page in September 2026. Check them on ovhcloud.com/fr/vps and in the order recap.</Note>

<Deep>Automated backup and snapshots are stored by OVH outside your VPS, so they survive a broken disk or a bad reinstall. They are not an off-site copy under your control, though: if the OVH account is lost or the service is cancelled, they go with it. That is why page 7 also copies the site and the configuration to your laptop.</Deep>

<Guided>On a first order, OVH can ask for an extra verification of your identity or your payment before starting the installation. Answer quickly: the delivery waits for it.</Guided>

## Delivery: IP addresses and password

Delivery usually takes a few minutes, sometimes a few hours. You get an email with the IPv4 address, the user name `debian`, and a secure link to the temporary password.

<Warn>The password link can be opened only once and expires. Open it, copy the password straight into your password manager, then close the page. Until page 3, it is what you use for the KVM console.</Warn>

Then find both addresses in the control panel: **Bare Metal Cloud** → **Virtual private servers** → your VPS → **Home**, section **IP**. Copy the IPv4 and the IPv6 (without the `/prefix`).

**Fill <V name="SERVER_IP" /> and <V name="SERVER_IPV6" /> in the values panel now.** Every command from here on uses them.

<Guided>The IPv4 is the address most visitors reach. The IPv6 is its equivalent on the newer protocol; a large share of French home and mobile connections use it first. Page 4 puts both in DNS, as an A and an AAAA record.</Guided>

<Deep>The VPS name in the panel, like `vps-a1b2c3d4.vps.ovh.net`, is also a working DNS name for the IPv4. It is handy before your own domain points to the server, but do not use it in the site's configuration: it changes if you ever move to a new VPS.</Deep>

<Note>OVH updates the delivery email from time to time. If you gave a key at order and the email has no password link, there is no password yet: set one after your first login with `sudo passwd debian` so that the KVM console works.</Note>

## First login

Log in as `debian` with the key made for this server:

```bash on="mac"
ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP}
```

`-i` names the server's key, and `IdentitiesOnly=yes` stops ssh from offering the other keys of this Mac first. Page 3 puts both into a `vps` shortcut; until then, you type them.

The first time, ssh shows the server's key fingerprint and asks whether to continue. Answer `yes`.

<Guided>This is "trust on first use". The server proves its identity with a host key; ssh has never seen this one, so it asks you. Once you say yes, the fingerprint goes into `~/.ssh/known_hosts`, and any later change triggers a loud warning. That warning is what protects you from someone impersonating the server later.</Guided>

<Deep>

To make the first contact verified rather than trusted, compare fingerprints. Open the KVM console (next step), log in as `debian` with the temporary password, and run:

```bash on="server" as="debian"
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```

The `SHA256:…` value must match the one ssh printed on your Mac, next to "ED25519 key fingerprint is". If it does not, answer `no` and find out why before going further.

</Deep>

Confirm it is Debian 13 on the model you ordered, with sudo working. The first check refuses passwords, so it also proves the key works:

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes debian@${SERVER_IP} 'grep VERSION_CODENAME /etc/os-release'" expect="VERSION_CODENAME=trixie" />

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} nproc" expect="2" />

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

On Debian 12 the codename is `bookworm`. On VPS-2 `nproc` prints `4`.

Then check that the IPv6 address is configured on the server itself:

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} 'ip -6 -brief addr show scope global'" expect="${SERVER_IPV6}" />

<Details summary="If no IPv6 address shows">

On recent images OVH configures IPv6 at first boot. If the command prints nothing, follow the OVH guide "Configure IPv6 on a VPS" (docs.ovhcloud.com): on recent images it adds a netplan file, `/etc/netplan/51-cloud-init-ipv6.yaml`. Fix it before page 4, since a DNS AAAA record pointing at an address the server does not answer on is worse than no AAAA at all.

</Details>

<Details summary="If ssh says REMOTE HOST IDENTIFICATION HAS CHANGED">

Expected only if you reinstalled the VPS: the new system has a new host key. Remove the old one from your Mac, then log in again and accept the new one.

```bash on="mac"
ssh-keygen -R ${SERVER_IP}
```

If you did not reinstall anything, stop and look at the console first.

</Details>

## Know your way back in

Three tools in the control panel save you when SSH does not. Find them now, while nothing is broken. All three are on the VPS page in the control panel:

- **KVM console**: the `...` button next to the VPS name → **KVM**. A screen and keyboard attached to the VPS, in your browser. It works even with SSH down or the firewall closed. Log in with a password, never with a key.
- **Rescue mode**: boots a small separate system and mounts your disk, so you can fix a broken file. The rescue credentials arrive by email.
- **Reinstall**: puts a fresh image on the VPS.

Open the KVM console once now and log in as `debian` with the temporary password. If it refuses, set a new password over SSH with `sudo passwd debian` and try again: you want this door tested before page 3.

<Warn>**Reinstall wipes the disk.** Everything on the VPS is gone, and the IP addresses stay the same. Use it only on a server with nothing to lose, or after a snapshot.</Warn>

<Guided>Page 3 moves SSH to another port, refuses passwords and closes the firewall. One mistake there and SSH no longer answers. The KVM console is how you get back in to fix it, which is why you need a password that works on it before starting page 3.</Guided>

<Deep>The KVM console is a VNC session to the virtual machine's screen, relayed by OVH: it bypasses the network stack of your VPS entirely, which is why a closed firewall does not affect it. Keyboard layouts can be off (the console often assumes QWERTY), so a password with only letters and digits saves time there. Rescue mode reboots the VPS, so use it only when the console is not enough.</Deep>

## Done

You have a Debian 13 VPS in a French datacenter, reachable as `debian` with the key made for it (`~/.ssh/id_ed25519_vps`), that key's public half, the IPv4 and the IPv6 in the values panel, the console tested, and an OVH account under two factors.

The server is already on the internet with SSH on port 22, and bots are already trying it. Go on to the next page, **Secure the server in the first hour**, now: it creates <V name="USERNAME" />, moves SSH to port <V name="SSH_PORT" />, refuses passwords and turns on the firewall.
````

````mdx title="content/ovh-vps-static-site/order-the-vps/page-fr.mdx"
{/* First pass — to be validated against https://www.ovhcloud.com/fr/vps/ and https://docs.ovhcloud.com/en/guides/bare-metal-cloud/virtual-private-servers/starting-with-a-vps (plus the OVHcloud guide "Securing your OVHcloud account with two-factor authentication") before publishing. */}

Cette page te donne un serveur. Tu protèges d'abord le compte OVH, tu crées une clé SSH rien que pour ce serveur, puis tu commandes un VPS-1 sous Debian 13 nue, dans un datacenter français, avec cette clé préinstallée ; tu attends l'email de livraison, tu notes les deux adresses IP et tu te connectes une première fois en `debian`. L'essentiel se passe en clics dans l'espace client OVH ; le terminal n'arrive qu'à la fin.

<Run>

La commande se passe dans ton navigateur : ce script ne fait que la partie portable. Lance-le **sur ton Mac**, avec ton compte, une fois la clé du serveur créée à la main (étape « Créer la clé SSH du serveur » : elle a une phrase de passe, le script ne peut donc pas la créer) et sa moitié publique collée dans le panneau des valeurs. Il s'arrête si la clé manque, n'est pas dans l'agent, ou diffère de celle du panneau. Ouvre un nouveau fichier `vps-first-contact.sh` sur ton portable, colles-y le bloc de code de cette section, puis lance `bash vps-first-contact.sh`. Premier lancement, avant de commander : il copie la clé publique dans le presse-papiers pour le formulaire de commande et s'arrête, parce que l'IPv4 du panneau des valeurs est encore celle de l'exemple. Second lancement, une fois <V name="SERVER_IP" /> renseignée depuis l'email de livraison : il attend que SSH réponde, puis affiche ce que tu as reçu. Après 10 minutes sans réponse, il s'arrête et affiche la dernière erreur ssh avec les causes probables.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Order the VPS (laptop side) — debian@${SERVER_IP}
KEY="$HOME/.ssh/id_ed25519_vps"
if [ ! -f "$KEY.pub" ]; then
  echo "No $KEY.pub found. Make it first, by hand (step: Make the server's SSH key): it has a passphrase." >&2
  exit 1
fi
if [ "$(cut -d' ' -f1,2 "$KEY.pub")" != "$(printf '%s\n' '${SSH_PUBKEY}' | cut -d' ' -f1,2)" ]; then
  echo "The values panel (Your server's public key) does not hold $KEY.pub. Paste the right line there, then copy this script again." >&2
  exit 1
fi
ssh-add --apple-load-keychain 2>/dev/null || true
if ! ssh-add -l 2>/dev/null | grep -qF "$(ssh-keygen -lf "$KEY.pub" | awk '{print $2}')"; then
  echo "The key is not in the agent. Run once: ssh-add --apple-use-keychain ~/.ssh/id_ed25519_vps, then run this script again." >&2
  exit 1
fi
pbcopy < "$KEY.pub"
echo "Public key copied to the clipboard. Paste it in the SSH key field of the OVH order."
if [ "${SERVER_IP}" = "203.0.113.10" ]; then
  echo "SERVER_IP is still the example value. Order the VPS, fill SERVER_IP from the delivery email, run again."
  exit 0
fi
echo "Waiting for debian@${SERVER_IP} to answer on SSH with $KEY (10 minutes at most)..."
tries=0
until ERR=$(ssh -i "$KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new debian@${SERVER_IP} true 2>&1); do
  tries=$((tries+1))
  if [ "$tries" -ge 60 ]; then
    echo "No SSH answer from debian@${SERVER_IP} after 60 tries. Last error:" >&2
    echo "$ERR" >&2
    echo "Likely causes:" >&2
    echo "  1. SERVER_IP is wrong: compare it with the delivery email and the control panel." >&2
    echo "  2. No key was given at order (Permission denied): /home/debian/.ssh/authorized_keys is empty. Reinstall from the OVH control panel with the key, or carry on with the password (see Add your SSH key)." >&2
    echo "  3. The host key changed (VPS reinstalled): run ssh-keygen -R ${SERVER_IP}, then run this script again." >&2
    exit 1
  fi
  sleep 10
done
ssh -i "$KEY" -o IdentitiesOnly=yes -o BatchMode=yes debian@${SERVER_IP} '
  grep VERSION_CODENAME /etc/os-release
  echo "vCores: $(nproc)"
  free -h | grep Mem
  df -h /
  ip -6 addr show scope global
'
echo "Reachable. Next page: Secure the server in the first hour. Do it now."
```

<Warn>Le script ne commande et ne paie rien, mais `StrictHostKeyChecking=accept-new` fait confiance à la clé d'hôte du serveur au premier contact sans te la montrer. La partie Exhaustif de « Première connexion » montre comment la comparer avec la console KVM.</Warn>

</Run>

## Avant de commencer

Il te faut quatre choses :

- Un compte OVHcloud, créé sur [ovhcloud.com/fr](https://www.ovhcloud.com/fr/). Il te donne un identifiant client (NIC handle, du genre `ab12345-ovh`) et l'accès à l'espace client.
- Un moyen de paiement enregistré sur ce compte : carte, PayPal ou prélèvement SEPA.
- Ton Mac, préparé en page 1 (Préparer ton portable). La clé SSH du serveur se crée sur cette page, juste avant le formulaire de commande.
- Rien côté domaine : il reste enregistré et hébergé chez Infomaniak. Tu le fais pointer vers le VPS en page 4, sans transfert.

## Protéger le compte OVH

Avant de commander quoi que ce soit, active la double authentification. Dans l'espace client, ouvre ton compte (tes initiales, en haut à droite) → **Sécurité** → **Double authentification**, et ajoute une méthode.

<Warn>Ce compte contrôle la facturation, le VPS, sa console, son mode rescue et son bouton de réinstallation. Qui entre dans ce compte a le serveur, quoi que tu fasses en page 3. Protège-le en premier.</Warn>

Trois méthodes sont proposées : une application d'authentification (TOTP), une clé de sécurité (FIDO/U2F) ou le SMS. Préfère l'application ou une clé ; le SMS est la plus faible des trois. À l'ajout de la première méthode, OVH affiche des codes de secours : range-les dans ton gestionnaire de mots de passe, à côté du mot de passe du compte.

<Guided>TOTP, c'est l'appli de ton téléphone (1Password, Bitwarden, Google Authenticator, Aegis…) qui affiche un code à six chiffres renouvelé toutes les 30 secondes. Tu scannes un QR code une fois ; ensuite, la connexion demande le mot de passe et le code du moment. Perdre le téléphone sans les codes de secours, c'est une procédure auprès du support avec pièces d'identité, qui prend des jours.</Guided>

<Deep>Ajoute deux méthodes si tu peux : l'appli plus une clé physique, ou l'appli sur deux appareils. OVH en accepte plusieurs, et n'importe laquelle déverrouille le compte. Vérifie aussi l'email de contact du compte : les réinitialisations de mot de passe et toutes les notifications du VPS y arrivent, il doit donc s'agir d'une adresse que tu lis et qui est elle-même protégée.</Deep>

<Note>OVH déplace ses menus de temps en temps. Si le chemin ci-dessus a changé, cherche dans la documentation OVHcloud le guide « Sécuriser son compte OVHcloud avec la double authentification ».</Note>

## Créer la clé SSH du serveur

Le formulaire de commande demande une clé SSH publique. Crée-la maintenant, pour ce serveur seulement, dans son propre fichier `~/.ssh/id_ed25519_vps`, **avec une phrase de passe**. La commande demande la phrase de passe deux fois, donc elle se lance seule :

```bash on="mac" interactive
ssh-keygen -t ed25519 -C "${USERNAME}@${DOMAIN}-vps" -f ~/.ssh/id_ed25519_vps
```

<Guided>Tape une phrase de passe quand on te la demande. Elle chiffre la clé privée sur le disque : un portable volé ou une sauvegarde qui fuit ne donne alors qu'un fichier inutilisable. Tu ne la taperas pas toute la journée : la commande suivante la range dans le trousseau et confie la clé à l'agent SSH. Le texte après `-C` n'est qu'une étiquette, pour reconnaître la clé plus tard dans `authorized_keys` et dans l'espace client OVH.</Guided>

<Warn>Si `~/.ssh/id_ed25519_vps` existe déjà, `ssh-keygen` demande avant d'écraser. Réponds **n** et garde la clé existante : l'écraser te coupe de toutes les machines qui lui font déjà confiance.</Warn>

Range la phrase de passe dans le trousseau et charge la clé dans l'agent. La phrase de passe t'est demandée une dernière fois :

```bash on="mac" interactive
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_vps
```

Puis copie la moitié publique, et colle-la dans le champ **Clé publique du serveur** du panneau des valeurs. La page 3 l'écrit de là dans ton propre compte sur le serveur.

```bash on="mac"
pbcopy < ~/.ssh/id_ed25519_vps.pub
```

<Guided>Cette ligne unique, c'est la moitié publique de la paire de clés. Tu peux la coller sans crainte dans un formulaire web ou dans le panneau : elle permet seulement à un serveur de te reconnaître, elle ne sert pas à se connecter à elle seule. La moitié privée, `~/.ssh/id_ed25519_vps` sans `.pub`, ne quitte jamais ton Mac.</Guided>

<Deep>

Les clés Ed25519 sont courtes (68 caractères pour la clé publique), rapides, et n'ont ni taille ni courbe à mal choisir : c'est pour ça qu'OpenSSH et OVH les recommandent plutôt que RSA. Phrase de passe plus agent, c'est avoir les deux propriétés voulues : la clé est chiffrée au repos, et déverrouillée seulement en mémoire tant que ta session est ouverte.

Pourquoi une clé à part plutôt qu'une `~/.ssh/id_ed25519` que tu as peut-être déjà : tu sais exactement ce qu'elle ouvre (ce serveur), tu peux la remplacer sans toucher à GitHub ni à aucune autre machine, et ssh n'a jamais à deviner quelle clé présenter, puisque chaque commande la nomme (`-i` sur cette page, le raccourci `vps` à partir de la page 3). Si tu soupçonnes un jour une fuite, fais une nouvelle paire et remplace la ligne dans `authorized_keys` : l'ancienne clé n'ouvre alors plus rien.

</Deep>

La ligne de ton Mac et celle du panneau doivent être identiques :

<Check cmd="cat ~/.ssh/id_ed25519_vps.pub" expect="${SSH_PUBKEY}" />

## Choisir le modèle

Sur [ovhcloud.com/fr/vps](https://www.ovhcloud.com/fr/vps/), prends le **VPS-1** et clique sur Commander. La gamme en septembre 2026 :

| Modèle | vCores | RAM | Disque NVMe | À partir de, par mois TTC |
|---|---|---|---|---|
| VPS-1 | 2 | 4 Go | 40 Go | 4,57 € (3,81 € HT) |
| VPS-2 | 4 | 8 Go | 75 Go | 8,65 € |
| VPS-3 | 6 | 12 Go | 100 Go | 12,48 € |
| VPS-4 | 8 | 24 Go | 200 Go | 23,95 € |

<Note>Les prix bougent et les promotions vont et viennent. Vérifie le prix du moment sur ovhcloud.com/fr/vps avant de commander.</Note>

Le VPS-1 suffit largement. Le site est statique : Caddy lit des fichiers sur le disque et les envoie. Quelques centaines de visites par jour consomment bien moins de 1 % d'un vCore. Les 40 Go de disque contiennent le système (environ 2 Go), une vingtaine de versions du site (~217 Mo chacune, et en grande partie liées entre elles par liens physiques, voir page 6), les journaux et les sauvegardes locales.

<Guided>Un **vCore**, c'est une part d'un cœur de processeur physique, réservée pour toi ; deux, c'est confortable pour un serveur web plus un `apt upgrade` de temps en temps. La **RAM**, c'est la mémoire de travail ; Caddy en prend quelques dizaines de Mo, Debian elle-même environ 200 Mo, donc 4 Go laissent de la place pour le backend que tu ajouteras plus tard. **NVMe**, c'est le type de SSD ; il rend la lecture des fichiers rapide, et lire des fichiers, c'est l'essentiel du travail d'un site statique.</Guided>

<Deep>

- **Trafic** : la gamme est vendue en trafic illimité ; ce qui change d'un modèle à l'autre, c'est le débit maximal, annoncé jusqu'à 3 Gbit/s en haut de gamme. Avec des fichiers WAV de 44 à 55 Mo sur le site, le débit décide de la vitesse à laquelle un visiteur les reçoit, pas de ce que tu paies. Lis la ligne débit du modèle choisi.
- **Engagement** : le formulaire propose sans engagement (au mois) ou un engagement de 12 ou 24 mois avec remise. Le mensuel coûte un peu plus et te laisse partir n'importe quel mois ; pour un premier serveur, ça vaut le coup.
- **TVA** : les prix « HT » excluent les 20 % de TVA ; en tant que particulier, tu paies le prix TTC.
- **Changer de modèle plus tard** : monter vers un modèle plus gros se fait depuis l'espace client, garde les données et l'IP, et demande un redémarrage. Redescendre n'est en général pas possible, parce qu'un disque ne rétrécit pas. Commence petit.

</Deep>

<Note>Les règles de montée/descente de gamme et le débit par modèle viennent de la page produit OVH et peuvent changer : confirme-les sur ovhcloud.com/fr/vps et dans les guides VPS d'OVH.</Note>

## Choisir le datacenter

Dans le formulaire, choisis une localisation en **France** : Gravelines, Roubaix, Strasbourg ou Paris, selon ce qui est disponible pour le modèle.

<Guided>Tes lecteurs sont surtout en France : un datacenter français, c'est 5 à 20 ms d'aller-retour au lieu de 80 à 150 ms depuis l'Amérique du Nord ou l'Asie, et ça se voit sur chaque image que charge la page. Des données hébergées en France gardent aussi le volet RGPD trivial : aucun transfert hors UE à documenter.</Guided>

<Deep>La localisation est figée pour toute la vie du VPS : l'IPv4 et l'IPv6 appartiennent au réseau de ce datacenter. Déménager, c'est commander un nouveau VPS ailleurs, migrer, puis changer le DNS. Certaines localisations ont un tarif différent ; le formulaire affiche le prix final par localisation avant la validation.</Deep>

## Choisir l'image : Debian 13

Pour l'image, prends **Debian 13** parmi les images « distribution seule ». Pas d'image avec Plesk, cPanel, Docker ou une application préinstallée.

Si seule Debian 12 est proposée, prends-la : chaque page de cette série fonctionne pareil en 12 et en 13.

<Guided>Un panneau comme Plesk ou cPanel installe son propre serveur web, son serveur mail et sa base de données, tous à l'écoute sur internet, tous à tenir à jour. Cette série installe un seul service, Caddy, et tu connaîtras chaque port ouvert. C'est l'image nue qui rend ça possible.</Guided>

<Deep>Debian est choisie parce qu'elle est ennuyeuse : une version stable tous les deux ans, des mises à jour de sécurité pendant environ trois ans, puis deux ans de plus en LTS, soit à peu près cinq ans par version sans mise à niveau surprise. Ubuntu LTS est l'alternative habituelle et marcherait avec des changements mineurs (même `apt`, même `ufw`, mêmes paquets Caddy) ; la série est écrite et testée pour Debian uniquement.</Deep>

## Ajouter ta clé SSH

Le formulaire propose un champ facultatif pour une clé SSH. Colles-y la moitié publique de la clé que tu viens de créer (la ligne entière, de `ssh-ed25519` à <V name="USERNAME" />@<V name="DOMAIN" />-vps) et donne-lui un nom, par exemple `id_ed25519_vps`. Si le presse-papiers a changé entre-temps, recopie-la :

```bash on="mac"
pbcopy < ~/.ssh/id_ed25519_vps.pub
```

<Warn>Ne laisse pas ce champ vide. Sans clé à la commande, `debian` se connecte avec le mot de passe de l'email de livraison, et `/home/debian/.ssh/authorized_keys` est **vide** : rien sur le serveur ne connaît ta clé, et l'étape de la page 3 qui refuse les mots de passe peut t'enfermer dehors.</Warn>

<Guided>Avec la clé préinstallée, ta première connexion ne demande aucun mot de passe : le serveur connaît déjà ta clé publique quand il démarre.</Guided>

<Deep>OVH transmet la clé à **cloud-init**, l'outil de premier démarrage des images cloud. Au premier boot, cloud-init crée l'utilisateur `debian`, écrit ta clé dans `/home/debian/.ssh/authorized_keys`, donne à `debian` un sudo sans mot de passe dans `/etc/sudoers.d/90-cloud-init-users`, et peut écrire `/etc/ssh/sshd_config.d/50-cloud-init.conf` avec `PasswordAuthentication yes`. La page 3 s'occupe de ce fichier.</Deep>

<Details summary="Si tu as déjà commandé sans clé">

Deux façons de continuer, choisis-en une :

- **Réinstaller avec la clé.** Dans l'espace client, page du VPS → **Réinstaller**, choisis de nouveau Debian 13 et colle la clé dans le champ clé SSH. Le disque est effacé, ce qui ne coûte rien sur un serveur que tu n'as pas encore utilisé. Les adresses IP restent les mêmes ; la clé d'hôte change (voir « Si ssh affiche REMOTE HOST IDENTIFICATION HAS CHANGED » plus bas).
- **Continuer avec le mot de passe.** Connecte-toi en `debian` avec le mot de passe de l'email de livraison : ssh te le demande dans « Première connexion ». La page 3 ne s'appuie pas sur les clés de `debian` : elle écrit la clé du panneau des valeurs dans ton propre compte et la vérifie avant de refuser les mots de passe. Sur cette page, la première vérification de « Première connexion » échoue (elle refuse les mots de passe) ; les autres te demandent le mot de passe.

</Details>

## Options et engagement

La **sauvegarde automatique** avec un jour de rétention est incluse dans le prix : laisse-la active. Le reste est facultatif :

- **Sauvegarde premium**, à partir d'environ 1,32 € TTC par mois : garde plus de jours de sauvegardes. Pas nécessaire ici ; la page 7 met en place la sauvegarde de ce qui compte.
- **Snapshot**, à partir d'environ 0,36 € TTC par mois : une copie manuelle, à un instant donné, du VPS entier, que tu restaures en un clic. Prends-le : les pages 3 et 7 s'en servent avant les changements risqués.

<Warn>Une commande, c'est un contrat. Avant de payer, relis le récapitulatif : modèle, localisation, options, **durée d'engagement** et total TTC. Un engagement de 12 ou 24 mois ne se résilie pas en avance avec remboursement, et les options sont facturées chaque mois tant que tu ne les retires pas.</Warn>

<Note>Les prix des options et la rétention de la sauvegarde incluse viennent de la page VPS d'OVH en septembre 2026. Vérifie-les sur ovhcloud.com/fr/vps et dans le récapitulatif de commande.</Note>

<Deep>La sauvegarde automatique et les snapshots sont stockés par OVH hors de ton VPS : ils survivent à un disque cassé ou à une mauvaise réinstallation. Ce n'est pas pour autant une copie externe sous ton contrôle : si le compte OVH est perdu ou le service résilié, ils partent avec. C'est pour ça que la page 7 copie aussi le site et la configuration sur ton portable.</Deep>

<Guided>Sur une première commande, OVH peut demander une vérification supplémentaire de ton identité ou de ton paiement avant de lancer l'installation. Réponds vite : la livraison l'attend.</Guided>

## Livraison : adresses IP et mot de passe

La livraison prend en général quelques minutes, parfois quelques heures. Tu reçois un email avec l'adresse IPv4, le nom d'utilisateur `debian` et un lien sécurisé vers le mot de passe temporaire.

<Warn>Le lien du mot de passe ne s'ouvre qu'une fois et il expire. Ouvre-le, copie le mot de passe directement dans ton gestionnaire de mots de passe, puis ferme la page. Jusqu'à la page 3, c'est lui qui t'ouvre la console KVM.</Warn>

Ensuite, trouve les deux adresses dans l'espace client : **Bare Metal Cloud** → **Serveurs privés virtuels** → ton VPS → **Accueil**, section **IP**. Copie l'IPv4 et l'IPv6 (sans le `/préfixe`).

**Renseigne <V name="SERVER_IP" /> et <V name="SERVER_IPV6" /> dans le panneau des valeurs maintenant.** Toutes les commandes qui suivent s'en servent.

<Guided>L'IPv4, c'est l'adresse que la plupart des visiteurs atteignent. L'IPv6 est son équivalent dans le protocole plus récent ; une grosse part des connexions françaises, fixes comme mobiles, l'utilisent en priorité. La page 4 met les deux dans le DNS, en enregistrement A et AAAA.</Guided>

<Deep>Le nom du VPS dans l'espace client, du genre `vps-a1b2c3d4.vps.ovh.net`, est aussi un nom DNS valide pour l'IPv4. Pratique tant que ton domaine ne pointe pas encore vers le serveur, mais ne l'utilise pas dans la configuration du site : il change si tu passes un jour sur un nouveau VPS.</Deep>

<Note>OVH fait évoluer l'email de livraison. Si tu as donné une clé à la commande et que l'email ne contient pas de lien de mot de passe, il n'y a pas encore de mot de passe : définis-en un après ta première connexion avec `sudo passwd debian`, pour que la console KVM fonctionne.</Note>

## Première connexion

Connecte-toi en `debian` avec la clé créée pour ce serveur :

```bash on="mac"
ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP}
```

`-i` désigne la clé du serveur, et `IdentitiesOnly=yes` empêche ssh de présenter d'abord les autres clés de ce Mac. La page 3 range les deux dans un raccourci `vps` ; d'ici là, tu les tapes.

La première fois, ssh affiche l'empreinte de la clé du serveur et demande s'il faut continuer. Réponds `yes`.

<Guided>C'est la « confiance au premier usage ». Le serveur prouve son identité avec une clé d'hôte ; ssh ne l'a jamais vue, donc il te pose la question. Une fois que tu as dit oui, l'empreinte va dans `~/.ssh/known_hosts`, et tout changement ultérieur déclenche un avertissement bien visible. C'est cet avertissement qui te protège de quelqu'un qui se ferait passer pour le serveur plus tard.</Guided>

<Deep>

Pour que le premier contact soit vérifié plutôt que présumé, compare les empreintes. Ouvre la console KVM (étape suivante), connecte-toi en `debian` avec le mot de passe temporaire, et lance :

```bash on="server" as="debian"
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```

La valeur `SHA256:…` doit correspondre à celle que ssh a affichée sur ton Mac, après « ED25519 key fingerprint is ». Sinon, réponds `no` et comprends pourquoi avant d'aller plus loin.

</Deep>

Vérifie que c'est bien Debian 13 sur le modèle commandé, avec sudo fonctionnel. La première vérification refuse les mots de passe : elle prouve aussi que la clé marche.

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes debian@${SERVER_IP} 'grep VERSION_CODENAME /etc/os-release'" expect="VERSION_CODENAME=trixie" />

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} nproc" expect="2" />

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

Sur Debian 12, le nom de code est `bookworm`. Sur un VPS-2, `nproc` affiche `4`.

Vérifie ensuite que l'adresse IPv6 est bien configurée sur le serveur lui-même :

<Check cmd="ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes debian@${SERVER_IP} 'ip -6 -brief addr show scope global'" expect="${SERVER_IPV6}" />

<Details summary="Si aucune adresse IPv6 n'apparaît">

Sur les images récentes, OVH configure l'IPv6 au premier démarrage. Si la commande n'affiche rien, suis le guide OVH « Configurer l'IPv6 sur un VPS » (docs.ovhcloud.com) : sur les images récentes, il ajoute un fichier netplan, `/etc/netplan/51-cloud-init-ipv6.yaml`. Règle ça avant la page 4 : un enregistrement AAAA qui pointe vers une adresse sur laquelle le serveur ne répond pas, c'est pire que pas d'AAAA du tout.

</Details>

<Details summary="Si ssh affiche REMOTE HOST IDENTIFICATION HAS CHANGED">

Normal uniquement si tu as réinstallé le VPS : le nouveau système a une nouvelle clé d'hôte. Retire l'ancienne de ton Mac, puis reconnecte-toi et accepte la nouvelle.

```bash on="mac"
ssh-keygen -R ${SERVER_IP}
```

Si tu n'as rien réinstallé, arrête-toi et regarde d'abord la console.

</Details>

## Savoir revenir à la main

Trois outils de l'espace client te sauvent quand SSH ne répond plus. Repère-les maintenant, tant que rien n'est cassé. Les trois sont sur la page du VPS dans l'espace client :

- **Console KVM** : le bouton `...` à côté du nom du VPS → **KVM**. Un écran et un clavier branchés sur le VPS, dans ton navigateur. Elle marche même avec SSH arrêté ou le pare-feu fermé. On s'y connecte avec un mot de passe, jamais avec une clé.
- **Mode rescue** : démarre un petit système à part et monte ton disque, pour réparer un fichier cassé. Les identifiants du rescue arrivent par email.
- **Réinstaller** : pose une image neuve sur le VPS.

Ouvre la console KVM une fois maintenant et connecte-toi en `debian` avec le mot de passe temporaire. S'il est refusé, définis-en un nouveau par SSH avec `sudo passwd debian` et réessaie : cette porte doit être testée avant la page 3.

<Warn>**Réinstaller efface le disque.** Tout ce qui est sur le VPS disparaît, et les adresses IP restent les mêmes. À n'utiliser que sur un serveur sans rien à perdre, ou après un snapshot.</Warn>

<Guided>La page 3 déplace SSH sur un autre port, refuse les mots de passe et ferme le pare-feu. Une erreur à ce moment-là et SSH ne répond plus. La console KVM, c'est ce qui te permet de revenir pour corriger, d'où l'intérêt d'avoir un mot de passe qui y fonctionne avant d'attaquer la page 3.</Guided>

<Deep>La console KVM est une session VNC sur l'écran de la machine virtuelle, relayée par OVH : elle contourne complètement la pile réseau de ton VPS, c'est pour ça qu'un pare-feu fermé ne la gêne pas. La disposition du clavier peut être fausse (la console suppose souvent un QWERTY), donc un mot de passe fait seulement de lettres et de chiffres fait gagner du temps. Le mode rescue redémarre le VPS : ne t'en sers que quand la console ne suffit pas.</Deep>

## Terminé

Tu as un VPS Debian 13 dans un datacenter français, joignable en `debian` avec la clé créée pour lui (`~/.ssh/id_ed25519_vps`), la moitié publique de cette clé, l'IPv4 et l'IPv6 dans le panneau des valeurs, la console testée, et un compte OVH sous double authentification.

Le serveur est déjà sur internet avec SSH sur le port 22, et des robots l'essaient déjà. Passe à la page suivante, **Sécuriser le serveur dans la première heure**, maintenant : elle crée <V name="USERNAME" />, déplace SSH sur le port <V name="SSH_PORT" />, refuse les mots de passe et active le pare-feu.
````

````yaml title="content/ovh-vps-static-site/order-the-vps/diagram.yaml"
# Quick: you, the OVH control panel, the VPS, the delivery email and your public key.
# Guided: the KVM console, the rescue path. Deep: cloud-init, authorized_keys, automated backup.
title: { en: "From an order to a login", fr: "D'une commande à une connexion" }
caption:
  en: "You order in the OVH control panel with your public key; OVH builds the VPS and emails you its address. From then on you reach it over SSH as debian, and the KVM console is the way back in if SSH ever fails."
  fr: "Tu commandes dans l'espace client OVH avec ta clé publique ; OVH construit le VPS et t'envoie son adresse par email. Dès lors tu l'atteins en SSH sous debian, et la console KVM est le chemin de secours si SSH lâche."

groups:
  - id: dc
    label: { en: "OVH datacenter", fr: "Datacenter OVH" }
    desc:
      en: "A French OVH datacenter (Gravelines, Roubaix, Strasbourg or Paris). The location is fixed for the life of the VPS."
      fr: "Un datacenter OVH en France (Gravelines, Roubaix, Strasbourg ou Paris). La localisation est figée pour toute la vie du VPS."

nodes:
  - id: you
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "~/.ssh/id_ed25519_vps"
    desc:
      en: "Your Mac. It makes the server's key on this page, holds its private half, which never leaves it, and runs the ssh client."
      fr: "Ton Mac. Il crée la clé du serveur sur cette page, en détient la moitié privée, qui ne le quitte jamais, et fait tourner le client ssh."
    deep:
      sub: "ssh -i ~/.ssh/id_ed25519_vps -o IdentitiesOnly=yes · ~/.ssh/known_hosts"
  - id: key
    kind: file
    label: { en: "Public key", fr: "Clé publique" }
    sub: "id_ed25519_vps.pub"
    desc:
      en: "The public half of the server's key, one line. Pasted into the order form, it ends up in the authorized_keys of debian on the VPS; pasted into the values panel, page 3 writes it for your own account."
      fr: "La moitié publique de la clé du serveur, une ligne. Collée dans le formulaire de commande, elle finit dans l'authorized_keys de debian sur le VPS ; collée dans le panneau des valeurs, la page 3 l'écrit pour ton propre compte."
    deep:
      sub: "ed25519 · ${USERNAME}@${DOMAIN}-vps · name: id_ed25519_vps"
  - id: panel
    kind: cloud
    label: { en: "OVH control panel", fr: "Espace client OVH" }
    sub: "2FA · order · KVM"
    desc:
      en: "Where you order, pay, find the IPs and open the console. Whoever controls this account controls the server: two-factor authentication first."
      fr: "Là où tu commandes, paies, trouves les IP et ouvres la console. Qui contrôle ce compte contrôle le serveur : double authentification d'abord."
    deep:
      sub: "billing · VPS · KVM · rescue · reinstall · 2FA (TOTP / key)"
  - id: email
    kind: file
    label: { en: "Delivery email", fr: "Email de livraison" }
    sub: "IPv4 · debian · password link"
    desc:
      en: "Sent when the VPS is ready: the IPv4, the user debian and a one-time link to the temporary password."
      fr: "Envoyé quand le VPS est prêt : l'IPv4, l'utilisateur debian et un lien à usage unique vers le mot de passe temporaire."
  - id: vps
    kind: server
    label: { en: "VPS-1 · Debian 13", fr: "VPS-1 · Debian 13" }
    sub: "${SERVER_IP}"
    in: dc
    focus: true
    desc:
      en: "Your server: 2 vCores, 4 GB RAM, 40 GB NVMe, plain Debian 13. Reachable at ${SERVER_IP} and ${SERVER_IPV6}, as debian, with your key."
      fr: "Ton serveur : 2 vCores, 4 Go de RAM, 40 Go NVMe, Debian 13 nue. Joignable en ${SERVER_IP} et ${SERVER_IPV6}, sous debian, avec ta clé."
    guided:
      sub: "${SERVER_IP} · debian"
    deep:
      sub: "${SERVER_IP} · ${SERVER_IPV6} · 2 vCores · 4 GB · 40 GB NVMe · trixie"
  - id: kvm
    kind: net
    label: { en: "KVM console", fr: "Console KVM" }
    sub: "... → KVM"
    in: dc
    level: guided
    desc:
      en: "A screen and keyboard for the VPS in your browser. Works with SSH down or the firewall closed; logs in with a password, never a key."
      fr: "Un écran et un clavier pour le VPS dans ton navigateur. Marche avec SSH arrêté ou le pare-feu fermé ; connexion par mot de passe, jamais par clé."
    deep:
      sub: "VNC relayed by OVH · bypasses the VPS network"
  - id: cloudinit
    kind: service
    label: { en: "cloud-init", fr: "cloud-init" }
    sub: "first boot"
    in: dc
    level: deep
    desc:
      en: "Runs once at first boot: creates the user debian, installs your key, grants password-less sudo, may enable password login in 50-cloud-init.conf."
      fr: "Tourne une fois au premier démarrage : crée l'utilisateur debian, installe ta clé, donne un sudo sans mot de passe, peut activer la connexion par mot de passe dans 50-cloud-init.conf."
    deep:
      sub: "user debian · 90-cloud-init-users · 50-cloud-init.conf"
  - id: authkeys
    kind: file
    label: { en: "authorized_keys", fr: "authorized_keys" }
    sub: "/home/debian/.ssh/"
    in: dc
    level: deep
    desc:
      en: "The public keys allowed to log in as debian. Yours is written here at first boot."
      fr: "Les clés publiques autorisées à se connecter sous debian. La tienne y est écrite au premier démarrage."
  - id: backup
    kind: store
    label: { en: "Automated backup", fr: "Sauvegarde automatique" }
    sub: "daily · 1 day kept"
    in: dc
    level: deep
    desc:
      en: "Included in the price: a daily copy of the VPS kept one day, stored by OVH outside the VPS. Snapshots (option) sit next to it."
      fr: "Inclus dans le prix : une copie quotidienne du VPS gardée un jour, stockée par OVH hors du VPS. Les snapshots (option) sont à côté."
    deep:
      sub: "daily · 1 day retention · snapshot option"

edges:
  - from: you
    to: panel
    label: "order · 2FA"
    desc: { en: "You log in with two factors and order the VPS in the browser.", fr: "Tu te connectes avec deux facteurs et tu commandes le VPS dans le navigateur." }
  - from: panel
    to: vps
    label: "provisions"
    deep: { label: "Debian 13 image + key → cloud-init" }
    desc: { en: "OVH installs the Debian 13 image in the chosen datacenter and boots it.", fr: "OVH installe l'image Debian 13 dans le datacenter choisi et la démarre." }
  - from: you
    to: vps
    label: "ssh -i id_ed25519_vps debian@${SERVER_IP}"
    guided: { label: "ssh debian@${SERVER_IP} · TCP 22" }
    deep: { label: "SSH-2 · TCP 22 · host key trusted on first use" }
    desc: { en: "Your first login, with the key and no password. Port 22 until page 3 moves it.", fr: "Ta première connexion, avec la clé et sans mot de passe. Port 22 jusqu'à ce que la page 3 le déplace." }
  - from: you
    to: key
    label: "pbcopy"
    dashed: true
    desc: { en: "The public key is copied to the clipboard.", fr: "La clé publique est copiée dans le presse-papiers." }
  - from: key
    to: panel
    label: "pasted at order"
    dashed: true
    desc: { en: "Pasted into the SSH key field of the order form, named e.g. id_ed25519_vps. Without it, authorized_keys of debian stays empty.", fr: "Collée dans le champ clé SSH du formulaire de commande, nommée par exemple id_ed25519_vps. Sans elle, l'authorized_keys de debian reste vide." }
  - from: panel
    to: email
    label: "sends"
    dashed: true
    desc: { en: "When the VPS is ready, OVH sends the delivery email.", fr: "Quand le VPS est prêt, OVH envoie l'email de livraison." }
  - from: email
    to: you
    label: "IPv4 · password link"
    dashed: true
    desc: { en: "You note the IPv4, open the password link once and store the password.", fr: "Tu notes l'IPv4, tu ouvres le lien du mot de passe une fois et tu le ranges." }
  - from: panel
    to: kvm
    label: "... → KVM"
    dashed: true
    level: guided
    desc: { en: "The console opens from the VPS page of the control panel.", fr: "La console s'ouvre depuis la page du VPS dans l'espace client." }
  - from: kvm
    to: vps
    label: "rescue path"
    dashed: true
    level: guided
    desc: { en: "The way back in when SSH does not answer: log in with the password, fix, retry.", fr: "Le chemin de retour quand SSH ne répond plus : connexion par mot de passe, correction, nouvel essai." }
  - from: cloudinit
    to: authkeys
    label: "writes key"
    dashed: true
    level: deep
    desc: { en: "At first boot, cloud-init writes the key you gave at order into this file.", fr: "Au premier démarrage, cloud-init écrit dans ce fichier la clé donnée à la commande." }
  - from: vps
    to: authkeys
    label: "checks key"
    dashed: true
    level: deep
    desc: { en: "sshd looks up your public key here; your laptop proves it holds the private half.", fr: "sshd cherche ta clé publique ici ; ton portable prouve qu'il détient la moitié privée." }
  - from: vps
    to: backup
    label: "nightly"
    dashed: true
    level: deep
    desc: { en: "OVH copies the whole VPS once a day and keeps the copy one day.", fr: "OVH copie le VPS entier une fois par jour et garde la copie un jour." }
````

---

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