# Source of "Point the domain at the server (Infomaniak DNS)"

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

title:
  en: Point the domain at the server (Infomaniak DNS)
  fr: Pointer le domaine vers le serveur (DNS Infomaniak)
summary:
  en: >-
    Your domain and its www answer with the VPS in IPv4 and IPv6, only Let's Encrypt (and
    optionally ZeroSSL) may issue certificates for it, and nobody can send mail in its name — or
    your Infomaniak mailbox keeps working, with DMARC on top. Clicks in the Manager, checks with dig.
  fr: >-
    Ton domaine et son www répondent avec le VPS en IPv4 et IPv6, seul Let's Encrypt (et ZeroSSL
    si tu veux) peut émettre des certificats pour lui, et personne ne peut envoyer d'email en son
    nom — ou ta boîte Infomaniak continue de marcher, avec DMARC en plus. Des clics dans le Manager,
    des vérifications avec dig.
difficulty: beginner
tags: [dns, infomaniak, caa, dmarc, spf, dnssec, ipv6]
authors: [thudal]
created: 2026-09-26
minutes: 20
validated: Infomaniak Manager, Sept 2026
status: draft             # not yet run end to end by its author

choices:
  - key: MAIL
    type: select
    label: { en: Email on this domain, fr: Email sur ce domaine }
    hint:
      en: Whether addresses like you@your-domain exist. If they do, the mail records stay as Infomaniak created them.
      fr: Si des adresses comme toi@ton-domaine existent. Si oui, les enregistrements email restent tels qu'Infomaniak les a créés.
    default: none
    options:
      - { value: none, label: { en: No email on this domain, fr: Pas d'email sur ce domaine } }
      - { value: infomaniak, label: { en: Email hosted at Infomaniak, fr: Email hébergé chez Infomaniak } }
  - key: ZEROSSL
    type: boolean
    label: { en: "Also allow ZeroSSL (Caddy's fallback issuer)", fr: "Autoriser aussi ZeroSSL (l'émetteur de secours de Caddy)" }
    hint:
      en: Caddy tries Let's Encrypt first and falls back to ZeroSSL. Allowing both keeps the fallback working.
      fr: Caddy essaie d'abord Let's Encrypt et se replie sur ZeroSSL. Autoriser les deux garde le repli fonctionnel.
    default: true
  - key: DNSSEC
    type: boolean
    label: { en: Sign the zone (DNSSEC), fr: Signer la zone (DNSSEC) }
    hint:
      en: Resolvers can then prove the answers really come from your zone. Infomaniak manages the keys.
      fr: Les résolveurs peuvent alors prouver que les réponses viennent bien de ta zone. Infomaniak gère les clés.
    default: true
````

````mdx title="content/ovh-vps-static-site/point-the-domain/page-en.mdx"
{/* First pass — to be validated against https://www.infomaniak.com/fr/support/faq/2051, https://www.infomaniak.com/en/support/faq/2088, https://www.infomaniak.com/en/support/faq/1394, RFC 7505 (null MX) and RFC 8659 (CAA) before publishing. */}

The VPS has an address; now the domain has to lead to it. This page edits the DNS zone of **<V name="DOMAIN" />** in the Infomaniak Manager: the bare name (<V name="DOMAIN" />, nothing in front) and `www` point at the server, one record at a time, a CAA record says which certificate authorities may issue for the domain, and the mail records either lock the domain against spoofing or protect the mailbox you already have. Everything is checked from the laptop with `dig`. The certificate itself comes on the next page, and it can only be issued once this DNS is live.

<Run>

The DNS edits happen in your browser, in the Manager; this script changes nothing. Run it **on your laptop**, as yourself, once the records are in. Open a new file `dns-check.sh` on your laptop, paste every code block of this section into it in order, then run `bash dns-check.sh`. It asks two public resolvers (1.1.1.1 and 9.9.9.9) for every record of this page, prints OK, NOTE (an optional record missing) or FAIL per line, and exits non-zero if anything does not match. It needs only `dig`, which macOS ships.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Point the domain — verify ${DOMAIN} against public resolvers (read-only)
export LC_ALL=C
fail=0
resolvers="1.1.1.1 9.9.9.9"

# ask RESOLVER TYPE NAME -> answers, quotes stripped, sorted, on one line
ask() { dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' | sort | paste -sd ' ' - || true; }

# same RESOLVER TYPE NAME EXPECTED -> the whole answer must equal EXPECTED
same() {
  got=$(ask "$1" "$2" "$3")
  if [ "$got" = "$4" ]; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'FAIL  %-8s %-5s %s\n      want: %s\n      got:  %s\n' "$1" "$2" "$3" "$4" "$got"; fail=1; fi
}

# has RESOLVER TYPE NAME REGEX -> at least one answer line must match REGEX
has() {
  out=$(dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' || true)
  if printf '%s\n' "$out" | grep -Eq "$4"; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'FAIL  %-8s %-5s %s\n      want a line matching: %s\n' "$1" "$2" "$3" "$4"; fail=1; fi
}

# opt RESOLVER TYPE NAME REGEX WHY -> like has, but an optional record: only a NOTE when missing
opt() {
  out=$(dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' || true)
  if printf '%s\n' "$out" | grep -Eq "$4"; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'NOTE  %-8s %-5s %s (%s)\n' "$1" "$2" "$3" "$5"; fi
}

for r in $resolvers; do
  same $r NS    ${DOMAIN}     'ns11.infomaniak.ch. ns12.infomaniak.ch.'
  same $r A     ${DOMAIN}     '${SERVER_IP}'
  same $r CNAME www.${DOMAIN} '${DOMAIN}.'
  v6=$(ask $r AAAA ${DOMAIN})
  case "$v6" in
    '${SERVER_IPV6}') printf 'OK    %-8s %-5s %s\n' $r AAAA ${DOMAIN} ;;
    '') printf 'NOTE  %-8s %-5s %s (no AAAA: fine if the server has no working IPv6)\n' $r AAAA ${DOMAIN} ;;
    *)  printf 'FAIL  %-8s %-5s %s\n      want: %s (or none)\n      got:  %s\n' $r AAAA ${DOMAIN} '${SERVER_IPV6}' "$v6"; fail=1 ;;
  esac
done
```

<When flag="ZEROSSL">

```bash on="mac"
for r in $resolvers; do
  has $r CAA ${DOMAIN} '^0 issue letsencrypt\.org$'
  has $r CAA ${DOMAIN} '^0 issue sectigo\.com$'
  opt $r CAA ${DOMAIN} '^0 iodef mailto:' 'no iodef: optional'
done
```

</When>

<When notFlag="ZEROSSL">

```bash on="mac"
for r in $resolvers; do
  has $r CAA ${DOMAIN} '^0 issue letsencrypt\.org$'
  if ask $r CAA ${DOMAIN} | grep -q sectigo; then printf 'FAIL  %-8s %-5s %s\n      sectigo.com is allowed, but the ZeroSSL choice is off\n' $r CAA ${DOMAIN}; fail=1; fi
  opt $r CAA ${DOMAIN} '^0 iodef mailto:' 'no iodef: optional'
done
```

</When>

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

```bash on="mac"
for r in $resolvers; do
  has $r TXT ${DOMAIN}                  '^v=spf1 -all$'
  has $r TXT _dmarc.${DOMAIN}           '^v=DMARC1; p=reject'
  opt $r TXT probe._domainkey.${DOMAIN} '^v=DKIM1; p=$' 'no DKIM revocation: optional'
  mx=$(ask $r MX ${DOMAIN})
  case "$mx" in
    '0 .') printf 'OK    %-8s %-5s %s\n' $r MX ${DOMAIN} ;;
    '')    printf 'NOTE  %-8s %-5s %s (no MX at all: fine, the null MX is optional)\n' $r MX ${DOMAIN} ;;
    *)     printf 'FAIL  %-8s %-5s %s\n      got: %s (mail servers still announced)\n' $r MX ${DOMAIN} "$mx"; fail=1 ;;
  esac
done
```

</When>

<When is="MAIL" equals="infomaniak">

```bash on="mac"
for r in $resolvers; do
  has $r MX  ${DOMAIN}        'infomaniak\.ch\.$'
  has $r TXT ${DOMAIN}        '^v=spf1 .*include:spf\.infomaniak\.ch'
  has $r TXT _dmarc.${DOMAIN} '^v=DMARC1; p=(none|quarantine|reject)'
done
```

</When>

<When flag="DNSSEC">

```bash on="mac"
for r in $resolvers; do
  hdr=$(dig +dnssec +noall +comments A ${DOMAIN} "@$r" || true)
  if printf '%s\n' "$hdr" | grep -Eq '^;; flags:[a-z ]* ad[ ;]'; then printf 'OK    %-8s %-5s %s\n' $r DNSSEC ${DOMAIN}
  else printf 'NOTE  %-8s %-5s %s (not validated yet: the DS can take up to 48 h to reach the registry)\n' $r DNSSEC ${DOMAIN}; fi
done
```

</When>

```bash on="mac"
if [ "$fail" -eq 0 ]; then echo "All records match. Next page: Serve the site with Caddy and HTTPS."
else echo "Some records do not match. Changed recently? Wait for the TTL to expire and run again."; exit 1; fi
```

</Run>

## Before you start

You need a login to the Infomaniak Manager (manager.infomaniak.com), the server's IPv4 and IPv6 from the page "Order the VPS at OVH" filled in the values panel, the result of the `ping -6` test from the page "Secure the server in the first hour" (it decides the AAAA record), and a terminal on the laptop. `dig` is already installed on macOS.

<Guided>A DNS zone is only used if the domain's nameservers are the ones hosting it. Infomaniak shows you a zone even when the domain is delegated elsewhere, and editing it then changes nothing on the internet. So the first thing to confirm is that the world asks Infomaniak about <V name="DOMAIN" />.</Guided>

<Check cmd="dig +short NS ${DOMAIN} | sort" expect="ns11.infomaniak.ch.
ns12.infomaniak.ch." />

<Note>These are Infomaniak's usual nameservers; confirm the current names in the Manager, under the domain's "Serveurs DNS" section, if yours differ. If the output shows another provider (Cloudflare, OVH…), edit the zone there instead, or switch the nameservers back to Infomaniak first.</Note>

<Deep>`+short` drops everything but the answers. Without `@server`, dig asks the resolver your Mac is configured with (usually the router or your ISP), which is what a visitor's browser does too. The NS set is also published by the registry of the TLD; a mismatch between the registry and the zone's own NS records is a classic source of "it works for me but not for them".</Deep>

## Take stock of the current zone

Open manager.infomaniak.com → **Domaines** → click <V name="DOMAIN" /> → **Zone DNS** in the left menu. Note every record on the bare name (empty **Source**, or `@`) or on `www`, and every MX and TXT record.

On a domain fresh from the registrar, the list often shows only two **NS** lines (`ns11.infomaniak.ch` and `ns12.infomaniak.ch`): nothing to keep. The NS lines have no edit button: that is normal, Infomaniak manages them.

You can list the same thing from the laptop, asking Infomaniak's server directly:

```bash on="mac"
for t in A AAAA MX TXT CAA; do dig +noall +answer $t ${DOMAIN} @ns11.infomaniak.ch; done
dig +noall +answer www.${DOMAIN} @ns11.infomaniak.ch
```

<Guided>A domain that was never used for a site often carries records Infomaniak created on its own: an A (and sometimes an AAAA) pointing at a parking or "site en construction" page, a `www` pointing at the same place, and MX and SPF records for its mail service. Copy the zone's advanced view ("vue avancée") into a text file on the laptop before touching anything. The zone also keeps a version history ("historique"): that is your undo button if an edit goes wrong.</Guided>

<Guided>The rule for the rest of the page: **replace, do not add**. Two A records on the same name are not a mistake the DNS refuses; they are round robin. Resolvers hand out both, and about half of your visitors would land on the old parking page.</Guided>

## Lower the TTL (only if records already exist)

If the zone holds only the NS lines, skip this step: you create every record below directly with a TTL of **5 min**.

Otherwise, for each record on the bare name or on `www` that you are about to change, edit it and set its TTL to **5 min** (300 seconds). Then wait for the old TTL to run out (often one hour) before the real change.

<Guided>The TTL (time to live) is how long a resolver may keep an answer before asking again. With the default hour, a mistake stays in caches for an hour after you fix it. At 5 minutes, you can correct and retry quickly. Once everything is stable, you raise it back (last step).</Guided>

<Note>If the TTL list in the form does not offer 5 min, take the smallest value it offers. If you have nothing to keep and no patience, you can skip the wait: the only cost is that the old answer may linger for up to the old TTL.</Note>

<Deep>Resolvers also cache the absence of a record. If you (or a tool) asked for `www.`<V name="DOMAIN" /> before it existed, the "no such name" answer is cached for the smaller of the SOA record's own TTL and its "minimum" field (RFC 2308), commonly an hour or more. See it with `dig +short SOA` followed by the domain: the last number is that minimum. Asking the authoritative server (`@ns11.infomaniak.ch`) always shows the current truth.</Deep>

## Create the records

Here is everything this page creates. Each line of the table is one record, and gets its own sub-step below.

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

| # | Type to pick | Source | Value | Only if |
|---|---|---|---|---|
| 1 | A | *(empty)* | <V name="SERVER_IP" /> | |
| 2 | AAAA | *(empty)* | <V name="SERVER_IPV6" /> | `ping -6` answered on page 3 |
| 3 | CNAME | `www` | <V name="DOMAIN" /> | |
| 4 | CAA | *(empty)* | Flag `0`, Tag `issue`, `letsencrypt.org` | |
| 5 | CAA | *(empty)* | Flag `0`, Tag `issue`, `sectigo.com` | ZeroSSL allowed in the panel |
| 6 | TXT | *(empty)* | `v=spf1 -all` | |
| 7 | DMARC | *(set by the form)* | `v=DMARC1; p=reject; pct=100` | |

</When>

<When is="MAIL" equals="infomaniak">

| # | Type to pick | Source | Value | Only if |
|---|---|---|---|---|
| 1 | A | *(empty)* | <V name="SERVER_IP" /> | |
| 2 | AAAA | *(empty)* | <V name="SERVER_IPV6" /> | `ping -6` answered on page 3 |
| 3 | CNAME | `www` | <V name="DOMAIN" /> | |
| 4 | CAA | *(empty)* | Flag `0`, Tag `issue`, `letsencrypt.org` | |
| 5 | CAA | *(empty)* | Flag `0`, Tag `issue`, `sectigo.com` | ZeroSSL allowed in the panel |
| 6 | TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:`<V name="ADMIN_EMAIL" /> | |

</When>

Every record is created the same way, in **Zone DNS**:

1. Click **Ajouter un enregistrement**.
2. Pick the type in the list.
3. Click **Suivant**.
4. Fill the fields given in the sub-step, with the TTL at **5 min**.
5. Click **Enregistrer**, then check that the new line shows in the zone list before the next record.

The **Source** field is what goes in front of the domain. **Left empty, it means the domain itself**, <V name="DOMAIN" />, nothing in front: that is what you want for every record here except `www`<When is="MAIL" equals="infomaniak"> and `_dmarc`</When>.

<Warn>If a site is live on this domain today, it stops answering the moment resolvers pick up the new address: the VPS does not serve anything until the next page. Do this page and "Serve the site with Caddy and HTTPS" back to back.</Warn>

### The bare name to the server (A)

**Ajouter un enregistrement** → **A** → **Suivant** → **Source**: leave empty → address: <V name="SERVER_IP" /> → TTL **5 min** → **Enregistrer**.

If an A record already exists on the bare name, edit it (do not add a second one) so it points at <V name="SERVER_IP" />.

Infomaniak's own server answers at once, without waiting for any propagation:

<Check cmd="dig +short A ${DOMAIN} @ns11.infomaniak.ch" expect="${SERVER_IP}" />

<Deep>The bare name cannot be a CNAME (it carries the SOA and NS records, and a CNAME cannot coexist with anything), which is why it gets A and AAAA directly. Some providers offer ALIAS or "flattened CNAME" records to work around that; with a fixed server IP there is no need.</Deep>

### IPv6 (AAAA), only if ping -6 answered

On the page "Secure the server in the first hour", `ping -6` from the server either answered or did not.

- It answered: **Ajouter un enregistrement** → **AAAA** → **Suivant** → **Source**: leave empty → address: <V name="SERVER_IPV6" /> → TTL **5 min** → **Enregistrer**.
- It did not: create nothing, and delete any AAAA already on the bare name.

<Warn>A wrong AAAA is worse than none: IPv6 visitors can hit timeouts, and Let's Encrypt, which tries IPv6 first, can fail to validate the certificate.</Warn>

```bash on="mac"
dig +short AAAA ${DOMAIN} @ns11.infomaniak.ch
```

The answer is <V name="SERVER_IPV6" /> if you created the record, nothing otherwise.

### www (CNAME)

People type `www.` in front of a domain by reflex; here `www` is only a redirect to <V name="DOMAIN" />, which Caddy sets up on the next page.

Delete any A or AAAA record on `www` first, then: **Ajouter un enregistrement** → **CNAME** → **Suivant** → **Source**: `www` → target: <V name="DOMAIN" /> → TTL **5 min** → **Enregistrer**.

In the zone list, the new line shows the service "Domain Connect": that is normal, nothing to change.

<Guided>The CNAME says "www is another name for <V name="DOMAIN" />": if the server's IP ever changes, you edit the bare name only. Caddy will redirect `www` to the bare name, but it still needs a certificate for `www` to do that over HTTPS, so both names must resolve to the VPS. A CNAME cannot share its name with any other record, which is why the old `www` records go first; the form refuses the CNAME otherwise.</Guided>

<Deep>In the advanced view the record reads `www 300 IN CNAME` followed by the domain and a trailing dot. That dot marks a fully qualified name. Without it, a BIND-style zone appends the origin and the target becomes the domain written twice (`example.com.example.com.`), a classic typo. The Manager's simple form adds the dot for you.</Deep>

### CAA for Let's Encrypt

**Ajouter un enregistrement** → **CAA** → **Suivant** → **Source**: leave empty → **Flag**: `0` → **Tag**: `issue` → **Valeur**: `letsencrypt.org` → TTL **5 min** → **Enregistrer**.

<Guided>Any public certificate authority can issue a certificate for your domain if someone passes its domain check. A CAA record narrows that to the ones you name: every CA must look it up at issuance and refuse if it is not listed. Caddy asks Let's Encrypt first and falls back to ZeroSSL if Let's Encrypt is unavailable<When notFlag="ZEROSSL">; without `sectigo.com` in CAA, that fallback is refused, which is the trade-off you chose</When>.</Guided>

<Deep>CAA is checked by the CA at issuance (RFC 8659), not by browsers, so it does nothing to certificates already issued. The lookup climbs the tree: for `www.`<V name="DOMAIN" /> the CA follows the CNAME, then the parent names, and the record on the bare name applies. `iodef` asks CAs to report refused requests to an address; few act on it, and it costs nothing (see the optional extras). Stricter forms exist: `issuewild ";"` forbids wildcard certificates, and the `accounturi` parameter pins issuance to one ACME account.</Deep>

<When flag="ZEROSSL">

### CAA for ZeroSSL

A second CAA record, separate from the first: one value per record. `sectigo.com` is the name ZeroSSL certificates are issued under.

**Ajouter un enregistrement** → **CAA** → **Suivant** → **Source**: leave empty → **Flag**: `0` → **Tag**: `issue` → **Valeur**: `sectigo.com` → TTL **5 min** → **Enregistrer**.

</When>

```bash on="mac"
dig +short CAA ${DOMAIN} @ns11.infomaniak.ch
```

<When flag="ZEROSSL">One line per CAA record: `0 issue "letsencrypt.org"` and `0 issue "sectigo.com"`.</When><When notFlag="ZEROSSL">One line: `0 issue "letsencrypt.org"`.</When>

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

### No mail: SPF (TXT)

Nothing sends or receives mail for this domain, so publish that fact. Receiving servers then reject anything claiming to come from <V name="DOMAIN" />.

<Warn>If a mailbox exists at Infomaniak for this domain (check **Service Mail** in the Manager), deleting its MX records stops its mail at once. In that case set the choice to "Email hosted at Infomaniak" instead.</Warn>

Otherwise, delete the leftover Infomaniak MX records and any TXT starting with `v=spf1`: a domain must have only one SPF record.

The Manager has **no "SPF" type**: SPF is a TXT record. In the type list, **SSHFP** sits right next to it and has nothing to do with it (it publishes SSH host fingerprints).

**Ajouter un enregistrement** → **TXT** → **Suivant** → **Source**: leave empty → value: `v=spf1 -all` → TTL **5 min** → **Enregistrer**.

<Guided>`v=spf1 -all` says "no server is allowed to send as this domain".</Guided>

### No mail: DMARC

The Manager has a dedicated **DMARC** type: use it rather than typing the TXT by hand.

**Ajouter un enregistrement** → **DMARC** → **Suivant** → keep the policy at reject → TTL **5 min** → **Enregistrer**.

It writes `v=DMARC1; p=reject; pct=100` on `_dmarc.`<V name="DOMAIN" />. In the zone list, the line shows as "TXT (DMARC)" with the service "Messagerie": that is normal.

<Guided>The DMARC record tells receivers to reject what fails the SPF and DKIM checks, for 100 % of the messages (`pct=100`).</Guided>

<Deep>A stricter hand-written version also asks for an exact domain match and for daily aggregate reports: type **TXT**, **Source** `_dmarc`, value `v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:` followed by <V name="ADMIN_EMAIL" />, in place of the DMARC-type record (not next to it: one DMARC record only). If <V name="ADMIN_EMAIL" /> is at another domain, receivers only send reports there if that domain publishes an authorization record (`<V name="DOMAIN" />._report._dmarc` at the other domain, RFC 7489); drop the `rua=` part or accept that reports will not arrive.</Deep>

</When>

<When is="MAIL" equals="infomaniak">

### Mail at Infomaniak: DMARC only

Your mailbox relies on the MX, SPF and DKIM records the Manager created: **keep them exactly as they are**. Add only a DMARC record, in monitoring mode first.

**Ajouter un enregistrement** → **TXT** → **Suivant** → **Source**: `_dmarc` → value: `v=DMARC1; p=none; rua=mailto:` followed by <V name="ADMIN_EMAIL" />, with no space → TTL **5 min** → **Enregistrer**.

<Note>The Manager also has a dedicated DMARC type, but it writes `p=reject` straight away, which is what this step avoids. Use it only if its form lets you pick `none`.</Note>

<Guided>With `p=none`, receivers change nothing but send you daily reports listing every server that sent mail as <V name="DOMAIN" /> and whether it passed SPF and DKIM. After two weeks of clean reports (only Infomaniak, all passing), change the record to `p=quarantine`, and a few weeks later to `p=reject`. Going straight to `reject` risks losing mail from a sender you forgot, such as a contact form or a newsletter tool.</Guided>

<Note>The SPF record should contain `include:spf.infomaniak.ch`. Confirm the current value in Infomaniak's mail FAQ before editing anything; the Manager can also regenerate the mail records for you.</Note>

</When>

### Optional extras

The page works without these<When is="MAIL" equals="none"> three</When> records, and the check script only notes their absence. Add them if you want every door closed.

- **CAA iodef**: **CAA** → **Source** empty → **Flag** `0` → **Tag** `iodef` → **Valeur** `mailto:` followed by <V name="ADMIN_EMAIL" />, with no space. If the Tag list does not offer `iodef`, skip it.

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

- **Null MX**: **MX** → **Source** empty → priority `0` → target `.` (a single dot).
- **DKIM revocation**: **TXT** → **Source** `*._domainkey` → value `v=DKIM1; p=`.

<Guided>The null MX (a single dot as target, RFC 7505) says "this domain accepts no mail". The empty DKIM key revokes any signature a spammer might claim.</Guided>

<Note>If the Manager refuses `.` as an MX target, skip the null MX: SPF and DMARC do most of the work.</Note>

</When>

<Deep>SPF checks the envelope sender against the IP that delivered the message; DKIM checks a signature over the headers; DMARC ties both to the visible `From:` domain and says what to do on failure. `adkim=s; aspf=s` require an exact domain match rather than a subdomain. The wildcard `*._domainkey` answers for any selector a forger might put in a signature, so every DKIM check fails cleanly instead of being "not found".</Deep>

## Check from the laptop

Infomaniak's own server first: it shows the zone as soon as you click **Enregistrer**, with no propagation delay.

```bash on="mac"
dig +short A ${DOMAIN} @ns11.infomaniak.ch
dig +short CNAME www.${DOMAIN} @ns11.infomaniak.ch
dig +short TXT ${DOMAIN} @ns11.infomaniak.ch
dig +short TXT _dmarc.${DOMAIN} @ns11.infomaniak.ch
```

Then ask two public resolvers what the world now sees. The first run may show old answers until the old TTL runs out.

<Check cmd="dig +short A ${DOMAIN} @1.1.1.1" expect="${SERVER_IP}" />

<Check cmd="dig +short CNAME www.${DOMAIN} @1.1.1.1" expect="${DOMAIN}." />

<Check cmd="dig +short CAA ${DOMAIN} @1.1.1.1 | grep letsencrypt | tr -d '\042'" expect="0 issue letsencrypt.org" />

<Check cmd="dig +short TXT _dmarc.${DOMAIN} @1.1.1.1 | tr -d '\042' | cut -d';' -f1" expect="v=DMARC1" />

If you created the AAAA, it answers too:

```bash on="mac"
dig +short AAAA ${DOMAIN} @1.1.1.1
```

Repeat with `@9.9.9.9` in place of `@1.1.1.1`: two resolvers run by different operators agreeing is a good sign the change is out.

<Guided>`@1.1.1.1` (Cloudflare) and `@9.9.9.9` (Quad9) bypass your Mac's own resolver, so what you see is what a visitor on another network sees. `tr -d '\042'` removes the quotes around text values (042 is the octal code of `"`). The script at the top of the page (Run level) checks every record of this page this way against both resolvers.</Guided>

<Note>dig prints IPv6 addresses in their short form (`2001:db8::10`, not `2001:0db8:0000:…`). If the AAAA check differs only by the notation, it passed; write the short form in the values panel.</Note>

<Details summary="If dig still shows the old address">

In order of likelihood:

- The old TTL has not run out yet. Ask Infomaniak directly with `@ns11.infomaniak.ch`: if that shows the new value, the zone is right and waiting fixes the rest.
- Your Mac cached the old answer (without `@server`). Flush it with `sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder`.
- Two records exist on the same name: the old one was added to, not replaced. Look again at the zone.
- `@ns11.infomaniak.ch` shows the old value: the edit was not saved (**Enregistrer**), or you edited the zone of another domain.
- dig prints lines starting with `\#` for CAA: the macOS dig is too old to decode the type. `brew install bind` gives a current one.

</Details>

<When flag="DNSSEC">

## Turn on DNSSEC

In the Manager, open <V name="DOMAIN" /> → **DNSSEC** → activate. Infomaniak generates the keys, signs the zone and sends the DS record to the registry of the TLD.

<Note>The menu name and place can move between Manager versions; Infomaniak's FAQ on DNSSEC gives the current path. Activation needs the domain to use Infomaniak's nameservers, which the first check confirmed.</Note>

<Warn>If you ever move the DNS hosting away from Infomaniak, disable DNSSEC here first and wait a day. Otherwise the registry keeps pointing at keys the new provider does not have, and every validating resolver answers SERVFAIL: the domain goes dark, site and mail.</Warn>

The DS record can take a few hours, sometimes up to 48, to reach the registry. Then a validating resolver marks its answers as authenticated (the `ad` flag):

<Check cmd="dig +dnssec +noall +comments A ${DOMAIN} @1.1.1.1 | grep -o 'flags: qr rd ra ad'" expect="flags: qr rd ra ad" />

<Guided>Without DNSSEC, a resolver trusts whatever answer arrives first; an attacker on the path can forge one. With it, each answer carries a signature that chains up to the registry and then to the root, which every validating resolver already trusts. For a picture of that chain, and a clear diagnosis when something breaks, paste the domain into dnsviz.net.</Guided>

<Deep>The zone is signed with a zone-signing key (ZSK), itself signed by a key-signing key (KSK); the registry publishes a hash of the KSK as the DS record in the parent zone. Infomaniak rolls the ZSK for you. The `ad` flag only means the resolver validated; `delv` from BIND performs the validation locally, but it is not on macOS by default (`brew install bind` adds it).</Deep>

</When>

## Reverse DNS at OVH (optional)

Make the server's IP answer with <V name="DOMAIN" /> when looked up backwards. In the OVH control panel: **Network** → **Public IP addresses** → `...` next to <V name="SERVER_IP" /> → **Modify the reverse** → <V name="DOMAIN" />. Do the same for <V name="SERVER_IPV6" /> if it is listed.

<Guided>A reverse (PTR) record maps an IP back to a name. Receiving mail servers insist on one that matches before accepting mail from a server, so you need it if the VPS ever sends mail (alerts, a contact form later). Otherwise it only makes logs and `traceroute` output readable. OVH checks that <V name="DOMAIN" /> already resolves to that IP before accepting the reverse, so do it after the checks above pass.</Guided>

<Note>OVH renames its control panel sections from time to time; the IP list may sit under "Bare Metal Cloud" → "Network" → "IP". The OVH guide "Configure a reverse DNS" gives the current path.</Note>

<Deep>The reverse lives in a separate tree (`in-addr.arpa` for IPv4, `ip6.arpa` for IPv6) delegated to whoever owns the IP block: OVH, not Infomaniak. That is why it is set there. A forward-confirmed reverse, where the name's A record points back to the same IP, is what mail receivers check.</Deep>

## Raise the TTL back

Once the checks pass and the site works on the next page, set the TTL of the records you created or changed to **1 h** (3600 seconds).

<Guided>A short TTL means more queries and slightly slower first visits, and it leaves you exposed if Infomaniak's servers ever have an outage: with a one-hour TTL, resolvers keep serving your address from cache for that hour. Lower it again a day before any planned change, such as a new server.</Guided>

## Done

<V name="DOMAIN" /> and `www` lead to the VPS (in IPv6 too if `ping -6` answered), and only the certificate authorities you listed may issue certificates for them.<When is="MAIL" equals="none"> Mail claiming to come from the domain is rejected.</When><When is="MAIL" equals="infomaniak"> Your Infomaniak mail keeps flowing, under a DMARC policy you tighten over the coming weeks.</When><When flag="DNSSEC"> The zone is signed.</When>

Next page: **Serve the site with Caddy and HTTPS**. Caddy requests the certificate for both names on its first start, which works only because this DNS is live.
````

````mdx title="content/ovh-vps-static-site/point-the-domain/page-fr.mdx"
{/* Premier jet — à valider contre https://www.infomaniak.com/fr/support/faq/2051, https://www.infomaniak.com/en/support/faq/2088, https://www.infomaniak.com/en/support/faq/1394, la RFC 7505 (MX nul) et la RFC 8659 (CAA) avant publication. */}

Le VPS a une adresse ; il faut maintenant que le domaine y mène. Cette page modifie la zone DNS de **<V name="DOMAIN" />** dans le Manager Infomaniak : le nom nu (<V name="DOMAIN" />, sans rien devant) et `www` pointent vers le serveur, un enregistrement à la fois, un enregistrement CAA dit quelles autorités de certification ont le droit d'émettre pour le domaine, et les enregistrements email soit verrouillent le domaine contre l'usurpation, soit protègent la boîte que tu as déjà. Tout se vérifie depuis le portable avec `dig`. Le certificat lui-même arrive à la page suivante, et il ne peut être émis qu'une fois ce DNS en place.

<Run>

Les modifications DNS se font dans ton navigateur, dans le Manager ; ce script ne change rien. Lance-le **sur ton portable**, avec ton compte, une fois les enregistrements saisis. Ouvre un nouveau fichier `dns-check.sh` sur ton portable, colles-y tous les blocs de code de cette section dans l'ordre, puis lance `bash dns-check.sh`. Il interroge deux résolveurs publics (1.1.1.1 et 9.9.9.9) pour chaque enregistrement de la page, affiche OK, NOTE (un enregistrement facultatif absent) ou FAIL par ligne, et sort en erreur si quelque chose ne correspond pas. Il ne demande que `dig`, livré avec macOS.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Point the domain — verify ${DOMAIN} against public resolvers (read-only)
export LC_ALL=C
fail=0
resolvers="1.1.1.1 9.9.9.9"

# ask RESOLVER TYPE NAME -> answers, quotes stripped, sorted, on one line
ask() { dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' | sort | paste -sd ' ' - || true; }

# same RESOLVER TYPE NAME EXPECTED -> the whole answer must equal EXPECTED
same() {
  got=$(ask "$1" "$2" "$3")
  if [ "$got" = "$4" ]; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'FAIL  %-8s %-5s %s\n      want: %s\n      got:  %s\n' "$1" "$2" "$3" "$4" "$got"; fail=1; fi
}

# has RESOLVER TYPE NAME REGEX -> at least one answer line must match REGEX
has() {
  out=$(dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' || true)
  if printf '%s\n' "$out" | grep -Eq "$4"; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'FAIL  %-8s %-5s %s\n      want a line matching: %s\n' "$1" "$2" "$3" "$4"; fail=1; fi
}

# opt RESOLVER TYPE NAME REGEX WHY -> like has, but an optional record: only a NOTE when missing
opt() {
  out=$(dig +short +time=3 +tries=2 "$2" "$3" "@$1" | tr -d '\042' || true)
  if printf '%s\n' "$out" | grep -Eq "$4"; then printf 'OK    %-8s %-5s %s\n' "$1" "$2" "$3"
  else printf 'NOTE  %-8s %-5s %s (%s)\n' "$1" "$2" "$3" "$5"; fi
}

for r in $resolvers; do
  same $r NS    ${DOMAIN}     'ns11.infomaniak.ch. ns12.infomaniak.ch.'
  same $r A     ${DOMAIN}     '${SERVER_IP}'
  same $r CNAME www.${DOMAIN} '${DOMAIN}.'
  v6=$(ask $r AAAA ${DOMAIN})
  case "$v6" in
    '${SERVER_IPV6}') printf 'OK    %-8s %-5s %s\n' $r AAAA ${DOMAIN} ;;
    '') printf 'NOTE  %-8s %-5s %s (no AAAA: fine if the server has no working IPv6)\n' $r AAAA ${DOMAIN} ;;
    *)  printf 'FAIL  %-8s %-5s %s\n      want: %s (or none)\n      got:  %s\n' $r AAAA ${DOMAIN} '${SERVER_IPV6}' "$v6"; fail=1 ;;
  esac
done
```

<When flag="ZEROSSL">

```bash on="mac"
for r in $resolvers; do
  has $r CAA ${DOMAIN} '^0 issue letsencrypt\.org$'
  has $r CAA ${DOMAIN} '^0 issue sectigo\.com$'
  opt $r CAA ${DOMAIN} '^0 iodef mailto:' 'no iodef: optional'
done
```

</When>

<When notFlag="ZEROSSL">

```bash on="mac"
for r in $resolvers; do
  has $r CAA ${DOMAIN} '^0 issue letsencrypt\.org$'
  if ask $r CAA ${DOMAIN} | grep -q sectigo; then printf 'FAIL  %-8s %-5s %s\n      sectigo.com is allowed, but the ZeroSSL choice is off\n' $r CAA ${DOMAIN}; fail=1; fi
  opt $r CAA ${DOMAIN} '^0 iodef mailto:' 'no iodef: optional'
done
```

</When>

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

```bash on="mac"
for r in $resolvers; do
  has $r TXT ${DOMAIN}                  '^v=spf1 -all$'
  has $r TXT _dmarc.${DOMAIN}           '^v=DMARC1; p=reject'
  opt $r TXT probe._domainkey.${DOMAIN} '^v=DKIM1; p=$' 'no DKIM revocation: optional'
  mx=$(ask $r MX ${DOMAIN})
  case "$mx" in
    '0 .') printf 'OK    %-8s %-5s %s\n' $r MX ${DOMAIN} ;;
    '')    printf 'NOTE  %-8s %-5s %s (no MX at all: fine, the null MX is optional)\n' $r MX ${DOMAIN} ;;
    *)     printf 'FAIL  %-8s %-5s %s\n      got: %s (mail servers still announced)\n' $r MX ${DOMAIN} "$mx"; fail=1 ;;
  esac
done
```

</When>

<When is="MAIL" equals="infomaniak">

```bash on="mac"
for r in $resolvers; do
  has $r MX  ${DOMAIN}        'infomaniak\.ch\.$'
  has $r TXT ${DOMAIN}        '^v=spf1 .*include:spf\.infomaniak\.ch'
  has $r TXT _dmarc.${DOMAIN} '^v=DMARC1; p=(none|quarantine|reject)'
done
```

</When>

<When flag="DNSSEC">

```bash on="mac"
for r in $resolvers; do
  hdr=$(dig +dnssec +noall +comments A ${DOMAIN} "@$r" || true)
  if printf '%s\n' "$hdr" | grep -Eq '^;; flags:[a-z ]* ad[ ;]'; then printf 'OK    %-8s %-5s %s\n' $r DNSSEC ${DOMAIN}
  else printf 'NOTE  %-8s %-5s %s (not validated yet: the DS can take up to 48 h to reach the registry)\n' $r DNSSEC ${DOMAIN}; fi
done
```

</When>

```bash on="mac"
if [ "$fail" -eq 0 ]; then echo "All records match. Next page: Serve the site with Caddy and HTTPS."
else echo "Some records do not match. Changed recently? Wait for the TTL to expire and run again."; exit 1; fi
```

</Run>

## Avant de commencer

Il te faut un accès au Manager Infomaniak (manager.infomaniak.com), l'IPv4 et l'IPv6 du serveur (page « Commander le VPS chez OVH ») saisies dans le panneau des valeurs, le résultat du test `ping -6` de la page « Sécuriser le serveur dans la première heure » (il décide de l'enregistrement AAAA), et un terminal sur le portable. `dig` est déjà installé sur macOS.

<Guided>Une zone DNS ne sert que si les serveurs de noms du domaine sont ceux qui l'hébergent. Infomaniak t'affiche une zone même quand le domaine est délégué ailleurs, et la modifier ne change alors rien sur internet. Première chose à confirmer, donc : c'est bien à Infomaniak que le monde pose la question pour <V name="DOMAIN" />.</Guided>

<Check cmd="dig +short NS ${DOMAIN} | sort" expect="ns11.infomaniak.ch.
ns12.infomaniak.ch." />

<Note>Ce sont les serveurs de noms habituels d'Infomaniak ; si les tiens diffèrent, vérifie les noms actuels dans le Manager, rubrique « Serveurs DNS » du domaine. Si la sortie montre un autre fournisseur (Cloudflare, OVH…), modifie la zone chez lui, ou remets d'abord les serveurs de noms d'Infomaniak.</Note>

<Deep>`+short` ne garde que les réponses. Sans `@serveur`, dig interroge le résolveur configuré sur ton Mac (souvent la box ou ton FAI), exactement comme le navigateur d'un visiteur. Le jeu de NS est aussi publié par le registre de l'extension ; un désaccord entre le registre et les NS de la zone elle-même est une source classique de « ça marche chez moi mais pas chez eux ».</Deep>

## Faire l'état des lieux de la zone

Ouvre manager.infomaniak.com → **Domaines** → clique sur <V name="DOMAIN" /> → **Zone DNS** dans le menu de gauche. Note chaque enregistrement sur le nom nu (**Source** vide, ou `@`) ou sur `www`, et chaque enregistrement MX et TXT.

Sur un domaine tout juste acheté, la liste ne montre souvent que deux lignes **NS** (`ns11.infomaniak.ch` et `ns12.infomaniak.ch`) : rien à garder. Les lignes NS n'ont pas de bouton de modification : c'est normal, Infomaniak les gère.

Tu peux lister la même chose depuis le portable, en interrogeant directement le serveur d'Infomaniak :

```bash on="mac"
for t in A AAAA MX TXT CAA; do dig +noall +answer $t ${DOMAIN} @ns11.infomaniak.ch; done
dig +noall +answer www.${DOMAIN} @ns11.infomaniak.ch
```

<Guided>Un domaine qui n'a jamais servi pour un site porte souvent des enregistrements créés d'office par Infomaniak : un A (parfois un AAAA) vers une page de parking ou « site en construction », un `www` vers le même endroit, et des MX et SPF pour son service de mail. Copie la vue avancée de la zone dans un fichier texte sur le portable avant de toucher à quoi que ce soit. La zone garde aussi un historique des versions : c'est ton bouton « annuler » si une modification tourne mal.</Guided>

<Guided>La règle pour la suite de la page : **remplacer, pas ajouter**. Deux enregistrements A sur le même nom, ce n'est pas une erreur que le DNS refuse ; c'est du round robin. Les résolveurs distribuent les deux, et à peu près la moitié de tes visiteurs tomberaient sur l'ancienne page de parking.</Guided>

## Baisser le TTL (seulement si des enregistrements existent déjà)

Si la zone ne contient que les lignes NS, saute cette étape : tu crées directement chaque enregistrement ci-dessous avec un TTL de **5 min**.

Sinon, pour chaque enregistrement du nom nu ou de `www` que tu vas changer, modifie-le et mets son TTL à **5 min** (300 secondes). Attends ensuite que l'ancien TTL s'écoule (souvent une heure) avant le vrai changement.

<Guided>Le TTL (time to live, durée de vie) est le temps pendant lequel un résolveur peut garder une réponse avant de redemander. Avec l'heure par défaut, une erreur reste dans les caches une heure après sa correction. À 5 minutes, tu corriges et tu réessaies vite. Une fois tout stable, tu le remontes (dernière étape).</Guided>

<Note>Si la liste des TTL du formulaire ne propose pas 5 min, prends la plus petite valeur proposée. Si tu n'as rien à préserver et pas de patience, tu peux sauter l'attente : le seul coût, c'est que l'ancienne réponse peut traîner jusqu'à la fin de l'ancien TTL.</Note>

<Deep>Les résolveurs mettent aussi en cache l'absence d'un enregistrement. Si toi (ou un outil) as demandé `www.`<V name="DOMAIN" /> avant qu'il existe, la réponse « ce nom n'existe pas » reste en cache pendant le plus petit du TTL de l'enregistrement SOA et de son champ « minimum » (RFC 2308), souvent une heure ou plus. Tu le vois avec `dig +short SOA` suivi du domaine : le dernier nombre est ce minimum. Interroger le serveur faisant autorité (`@ns11.infomaniak.ch`) montre toujours la vérité du moment.</Deep>

## Créer les enregistrements

Voici tout ce que cette page crée. Chaque ligne du tableau est un enregistrement, et a sa propre sous-étape plus bas.

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

| # | Type à choisir | Source | Valeur | Seulement si |
|---|---|---|---|---|
| 1 | A | *(vide)* | <V name="SERVER_IP" /> | |
| 2 | AAAA | *(vide)* | <V name="SERVER_IPV6" /> | `ping -6` a répondu à la page 3 |
| 3 | CNAME | `www` | <V name="DOMAIN" /> | |
| 4 | CAA | *(vide)* | Flag `0`, Tag `issue`, `letsencrypt.org` | |
| 5 | CAA | *(vide)* | Flag `0`, Tag `issue`, `sectigo.com` | ZeroSSL autorisé dans le panneau |
| 6 | TXT | *(vide)* | `v=spf1 -all` | |
| 7 | DMARC | *(fixée par le formulaire)* | `v=DMARC1; p=reject; pct=100` | |

</When>

<When is="MAIL" equals="infomaniak">

| # | Type à choisir | Source | Valeur | Seulement si |
|---|---|---|---|---|
| 1 | A | *(vide)* | <V name="SERVER_IP" /> | |
| 2 | AAAA | *(vide)* | <V name="SERVER_IPV6" /> | `ping -6` a répondu à la page 3 |
| 3 | CNAME | `www` | <V name="DOMAIN" /> | |
| 4 | CAA | *(vide)* | Flag `0`, Tag `issue`, `letsencrypt.org` | |
| 5 | CAA | *(vide)* | Flag `0`, Tag `issue`, `sectigo.com` | ZeroSSL autorisé dans le panneau |
| 6 | TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:`<V name="ADMIN_EMAIL" /> | |

</When>

Chaque enregistrement se crée de la même façon, dans **Zone DNS** :

1. Clique sur **Ajouter un enregistrement**.
2. Choisis le type dans la liste.
3. Clique sur **Suivant**.
4. Remplis les champs donnés dans la sous-étape, avec le TTL à **5 min**.
5. Clique sur **Enregistrer**, puis vérifie que la nouvelle ligne apparaît dans la liste de la zone avant de passer au suivant.

Le champ **Source**, c'est ce qui se met devant le domaine. **Laissé vide, il désigne le domaine lui-même**, <V name="DOMAIN" />, sans rien devant : c'est ce qu'il te faut pour chaque enregistrement ici, sauf `www`<When is="MAIL" equals="infomaniak"> et `_dmarc`</When>.

<Warn>Si un site est en ligne sur ce domaine aujourd'hui, il cesse de répondre dès que les résolveurs prennent la nouvelle adresse : le VPS ne sert rien avant la page suivante. Enchaîne cette page et « Servir le site avec Caddy et HTTPS » sans pause.</Warn>

### Le nom nu vers le serveur (A)

**Ajouter un enregistrement** → **A** → **Suivant** → **Source** : laisse vide → adresse : <V name="SERVER_IP" /> → TTL **5 min** → **Enregistrer**.

Si un enregistrement A existe déjà sur le nom nu, modifie-le (n'en ajoute pas un second) pour qu'il pointe vers <V name="SERVER_IP" />.

Le serveur d'Infomaniak lui-même répond tout de suite, sans attendre la moindre propagation :

<Check cmd="dig +short A ${DOMAIN} @ns11.infomaniak.ch" expect="${SERVER_IP}" />

<Deep>Le nom nu ne peut pas être un CNAME (il porte les enregistrements SOA et NS, et un CNAME ne cohabite avec rien), d'où les A et AAAA directs. Certains fournisseurs proposent des enregistrements ALIAS ou « CNAME aplati » pour contourner ça ; avec une IP de serveur fixe, inutile.</Deep>

### IPv6 (AAAA), seulement si ping -6 a répondu

À la page « Sécuriser le serveur dans la première heure », `ping -6` depuis le serveur a répondu ou non.

- Il a répondu : **Ajouter un enregistrement** → **AAAA** → **Suivant** → **Source** : laisse vide → adresse : <V name="SERVER_IPV6" /> → TTL **5 min** → **Enregistrer**.
- Il n'a pas répondu : ne crée rien, et supprime tout AAAA déjà présent sur le nom nu.

<Warn>Un AAAA faux est pire que pas d'AAAA : les visiteurs IPv6 peuvent tomber en délai d'attente, et Let's Encrypt, qui essaie d'abord l'IPv6, peut échouer à valider le certificat.</Warn>

```bash on="mac"
dig +short AAAA ${DOMAIN} @ns11.infomaniak.ch
```

La réponse est <V name="SERVER_IPV6" /> si tu as créé l'enregistrement, rien sinon.

### www (CNAME)

Les gens tapent `www.` devant un domaine par réflexe ; ici `www` n'est qu'une redirection vers <V name="DOMAIN" />, que Caddy met en place à la page suivante.

Supprime d'abord tout enregistrement A ou AAAA sur `www`, puis : **Ajouter un enregistrement** → **CNAME** → **Suivant** → **Source** : `www` → cible : <V name="DOMAIN" /> → TTL **5 min** → **Enregistrer**.

Dans la liste de la zone, la nouvelle ligne affiche le service « Domain Connect » : c'est normal, rien à changer.

<Guided>Le CNAME dit « www est un autre nom de <V name="DOMAIN" /> » : si l'IP du serveur change un jour, tu ne modifies que le nom nu. Caddy redirigera `www` vers le nom nu, mais il lui faut quand même un certificat pour `www` pour le faire en HTTPS, donc les deux noms doivent mener au VPS. Un CNAME ne peut partager son nom avec aucun autre enregistrement : c'est pour ça que les anciens `www` partent d'abord ; sinon le formulaire refuse le CNAME.</Guided>

<Deep>Dans la vue avancée, l'enregistrement se lit `www 300 IN CNAME` suivi du domaine et d'un point final. Ce point marque un nom complet. Sans lui, une zone au format BIND ajoute l'origine et la cible devient le domaine écrit deux fois (`example.com.example.com.`), une faute classique. Le formulaire simple du Manager ajoute le point pour toi.</Deep>

### CAA pour Let's Encrypt

**Ajouter un enregistrement** → **CAA** → **Suivant** → **Source** : laisse vide → **Flag** : `0` → **Tag** : `issue` → **Valeur** : `letsencrypt.org` → TTL **5 min** → **Enregistrer**.

<Guided>N'importe quelle autorité de certification publique peut émettre un certificat pour ton domaine si quelqu'un passe sa vérification de domaine. Un enregistrement CAA réduit ça à celles que tu nommes : chaque autorité doit le consulter au moment d'émettre, et refuser si elle n'y figure pas. Caddy demande d'abord à Let's Encrypt et se replie sur ZeroSSL si Let's Encrypt est indisponible<When notFlag="ZEROSSL"> ; sans `sectigo.com` dans le CAA, ce repli est refusé, c'est le compromis que tu as choisi</When>.</Guided>

<Deep>Le CAA est vérifié par l'autorité au moment de l'émission (RFC 8659), pas par les navigateurs : il ne fait rien aux certificats déjà émis. La recherche remonte l'arbre : pour `www.`<V name="DOMAIN" />, l'autorité suit le CNAME, puis les noms parents, et c'est l'enregistrement du nom nu qui s'applique. `iodef` demande aux autorités de signaler les demandes refusées à une adresse ; peu le font, et ça ne coûte rien (voir les extras facultatifs). Il existe des formes plus strictes : `issuewild ";"` interdit les certificats wildcard, et le paramètre `accounturi` lie l'émission à un seul compte ACME.</Deep>

<When flag="ZEROSSL">

### CAA pour ZeroSSL

Un second enregistrement CAA, séparé du premier : une valeur par enregistrement. `sectigo.com` est le nom sous lequel les certificats ZeroSSL sont émis.

**Ajouter un enregistrement** → **CAA** → **Suivant** → **Source** : laisse vide → **Flag** : `0` → **Tag** : `issue` → **Valeur** : `sectigo.com` → TTL **5 min** → **Enregistrer**.

</When>

```bash on="mac"
dig +short CAA ${DOMAIN} @ns11.infomaniak.ch
```

<When flag="ZEROSSL">Une ligne par enregistrement CAA : `0 issue "letsencrypt.org"` et `0 issue "sectigo.com"`.</When><When notFlag="ZEROSSL">Une ligne : `0 issue "letsencrypt.org"`.</When>

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

### Pas de mail : SPF (TXT)

Rien n'envoie ni ne reçoit de mail pour ce domaine : publie-le. Les serveurs de réception rejettent alors tout ce qui prétend venir de <V name="DOMAIN" />.

<Warn>Si une boîte mail existe chez Infomaniak pour ce domaine (regarde **Service Mail** dans le Manager), supprimer ses MX coupe son courrier immédiatement. Dans ce cas, choisis plutôt « Email hébergé chez Infomaniak ».</Warn>

Sinon, supprime les MX d'Infomaniak restés en place et tout TXT qui commence par `v=spf1` : un domaine ne doit avoir qu'un seul enregistrement SPF.

Le Manager n'a **pas de type « SPF »** : un SPF est un enregistrement TXT. Dans la liste des types, **SSHFP** est juste à côté et n'a rien à voir (il publie les empreintes SSH d'un serveur).

**Ajouter un enregistrement** → **TXT** → **Suivant** → **Source** : laisse vide → valeur : `v=spf1 -all` → TTL **5 min** → **Enregistrer**.

<Guided>`v=spf1 -all` dit « aucun serveur n'a le droit d'envoyer au nom de ce domaine ».</Guided>

### Pas de mail : DMARC

Le Manager a un type **DMARC** dédié : prends-le plutôt que de taper le TXT à la main.

**Ajouter un enregistrement** → **DMARC** → **Suivant** → garde la politique sur reject → TTL **5 min** → **Enregistrer**.

Il écrit `v=DMARC1; p=reject; pct=100` sur `_dmarc.`<V name="DOMAIN" />. Dans la liste de la zone, la ligne s'affiche en « TXT (DMARC) » avec le service « Messagerie » : c'est normal.

<Guided>L'enregistrement DMARC demande aux destinataires de rejeter ce qui échoue aux contrôles SPF et DKIM, pour 100 % des messages (`pct=100`).</Guided>

<Deep>Une version plus stricte, écrite à la main, exige aussi une correspondance exacte du domaine et demande des rapports agrégés quotidiens : type **TXT**, **Source** `_dmarc`, valeur `v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:` suivi de <V name="ADMIN_EMAIL" />, à la place de l'enregistrement de type DMARC (pas à côté : un seul DMARC). Si <V name="ADMIN_EMAIL" /> est sur un autre domaine, les destinataires n'y envoient les rapports que si cet autre domaine publie un enregistrement d'autorisation (`<V name="DOMAIN" />._report._dmarc` sur l'autre domaine, RFC 7489) ; retire la partie `rua=` ou accepte que les rapports n'arrivent pas.</Deep>

</When>

<When is="MAIL" equals="infomaniak">

### Mail chez Infomaniak : DMARC seulement

Ta boîte repose sur les MX, SPF et DKIM que le Manager a créés : **garde-les exactement tels quels**. Ajoute seulement un enregistrement DMARC, d'abord en mode observation.

**Ajouter un enregistrement** → **TXT** → **Suivant** → **Source** : `_dmarc` → valeur : `v=DMARC1; p=none; rua=mailto:` suivi de <V name="ADMIN_EMAIL" />, sans espace → TTL **5 min** → **Enregistrer**.

<Note>Le Manager a aussi un type DMARC dédié, mais il écrit `p=reject` d'emblée, ce que cette étape évite justement. Ne t'en sers que si son formulaire te laisse choisir `none`.</Note>

<Guided>Avec `p=none`, les destinataires ne changent rien mais t'envoient chaque jour des rapports qui listent chaque serveur ayant envoyé du mail au nom de <V name="DOMAIN" />, et s'il a passé SPF et DKIM. Après deux semaines de rapports propres (Infomaniak seul, tout au vert), passe l'enregistrement à `p=quarantine`, puis quelques semaines plus tard à `p=reject`. Passer directement à `reject`, c'est risquer de perdre le mail d'un expéditeur oublié, comme un formulaire de contact ou un outil de newsletter.</Guided>

<Note>L'enregistrement SPF doit contenir `include:spf.infomaniak.ch`. Vérifie la valeur actuelle dans la FAQ mail d'Infomaniak avant de modifier quoi que ce soit ; le Manager sait aussi régénérer les enregistrements mail pour toi.</Note>

</When>

### Extras facultatifs

La page marche sans ces<When is="MAIL" equals="none"> trois</When> enregistrements, et le script de vérification ne fait que noter leur absence. Ajoute-les si tu veux fermer toutes les portes.

- **CAA iodef** : **CAA** → **Source** vide → **Flag** `0` → **Tag** `iodef` → **Valeur** `mailto:` suivi de <V name="ADMIN_EMAIL" />, sans espace. Si la liste des Tags ne propose pas `iodef`, passe.

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

- **MX nul** : **MX** → **Source** vide → priorité `0` → cible `.` (un point seul).
- **Révocation DKIM** : **TXT** → **Source** `*._domainkey` → valeur `v=DKIM1; p=`.

<Guided>Le MX nul (un point seul comme cible, RFC 7505) dit « ce domaine n'accepte aucun mail ». La clé DKIM vide révoque toute signature qu'un spammeur prétendrait avoir.</Guided>

<Note>Si le Manager refuse `.` comme cible de MX, passe le MX nul : SPF et DMARC font l'essentiel du travail.</Note>

</When>

<Deep>SPF compare l'expéditeur d'enveloppe à l'IP qui a livré le message ; DKIM vérifie une signature sur les en-têtes ; DMARC relie les deux au domaine visible du `From:` et dit quoi faire en cas d'échec. `adkim=s; aspf=s` exigent une correspondance exacte du domaine plutôt qu'un sous-domaine. Le wildcard `*._domainkey` répond pour n'importe quel sélecteur qu'un faussaire mettrait dans une signature, si bien que chaque contrôle DKIM échoue proprement au lieu de finir en « introuvable ».</Deep>

## Vérifier depuis le portable

D'abord le serveur d'Infomaniak lui-même : il montre la zone dès que tu as cliqué sur **Enregistrer**, sans délai de propagation.

```bash on="mac"
dig +short A ${DOMAIN} @ns11.infomaniak.ch
dig +short CNAME www.${DOMAIN} @ns11.infomaniak.ch
dig +short TXT ${DOMAIN} @ns11.infomaniak.ch
dig +short TXT _dmarc.${DOMAIN} @ns11.infomaniak.ch
```

Demande ensuite à deux résolveurs publics ce que le monde voit maintenant. Le premier essai peut montrer les anciennes réponses tant que l'ancien TTL n'est pas écoulé.

<Check cmd="dig +short A ${DOMAIN} @1.1.1.1" expect="${SERVER_IP}" />

<Check cmd="dig +short CNAME www.${DOMAIN} @1.1.1.1" expect="${DOMAIN}." />

<Check cmd="dig +short CAA ${DOMAIN} @1.1.1.1 | grep letsencrypt | tr -d '\042'" expect="0 issue letsencrypt.org" />

<Check cmd="dig +short TXT _dmarc.${DOMAIN} @1.1.1.1 | tr -d '\042' | cut -d';' -f1" expect="v=DMARC1" />

Si tu as créé l'AAAA, il répond aussi :

```bash on="mac"
dig +short AAAA ${DOMAIN} @1.1.1.1
```

Refais-les avec `@9.9.9.9` à la place de `@1.1.1.1` : deux résolveurs d'opérateurs différents qui sont d'accord, c'est bon signe pour la propagation.

<Guided>`@1.1.1.1` (Cloudflare) et `@9.9.9.9` (Quad9) contournent le résolveur de ton Mac : ce que tu vois, c'est ce que voit un visiteur sur un autre réseau. `tr -d '\042'` retire les guillemets autour des valeurs texte (042 est le code octal de `"`). Le script en haut de la page (niveau Automatique) vérifie ainsi chaque enregistrement de la page auprès des deux résolveurs.</Guided>

<Note>dig affiche les adresses IPv6 sous leur forme courte (`2001:db8::10`, pas `2001:0db8:0000:…`). Si la vérification AAAA ne diffère que par l'écriture, elle est bonne ; mets la forme courte dans le panneau des valeurs.</Note>

<Details summary="Si dig montre encore l'ancienne adresse">

Par ordre de probabilité :

- L'ancien TTL n'est pas encore écoulé. Interroge Infomaniak directement avec `@ns11.infomaniak.ch` : si la nouvelle valeur y apparaît, la zone est correcte et l'attente règle le reste.
- Ton Mac a gardé l'ancienne réponse en cache (sans `@serveur`). Vide-le avec `sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder`.
- Deux enregistrements existent sur le même nom : l'ancien a été complété au lieu d'être remplacé. Relis la zone.
- `@ns11.infomaniak.ch` montre l'ancienne valeur : la modification n'a pas été enregistrée (**Enregistrer**), ou tu as modifié la zone d'un autre domaine.
- dig affiche des lignes qui commencent par `\#` pour le CAA : le dig de macOS est trop ancien pour décoder ce type. `brew install bind` en installe un récent.

</Details>

<When flag="DNSSEC">

## Activer DNSSEC

Dans le Manager, ouvre <V name="DOMAIN" /> → **DNSSEC** → active. Infomaniak génère les clés, signe la zone et transmet l'enregistrement DS au registre de l'extension.

<Note>Le nom et l'emplacement du menu peuvent bouger d'une version du Manager à l'autre ; la FAQ DNSSEC d'Infomaniak donne le chemin actuel. L'activation demande que le domaine utilise les serveurs de noms d'Infomaniak, ce que la première vérification a confirmé.</Note>

<Warn>Si un jour tu déplaces l'hébergement DNS hors d'Infomaniak, désactive d'abord DNSSEC ici et attends une journée. Sinon le registre continue de pointer vers des clés que le nouveau fournisseur n'a pas, et chaque résolveur validant répond SERVFAIL : le domaine disparaît, site et mail compris.</Warn>

L'enregistrement DS peut mettre quelques heures, parfois jusqu'à 48, pour atteindre le registre. Ensuite, un résolveur validant marque ses réponses comme authentifiées (le drapeau `ad`) :

<Check cmd="dig +dnssec +noall +comments A ${DOMAIN} @1.1.1.1 | grep -o 'flags: qr rd ra ad'" expect="flags: qr rd ra ad" />

<Guided>Sans DNSSEC, un résolveur croit la première réponse qui arrive ; un attaquant sur le chemin peut en fabriquer une. Avec, chaque réponse porte une signature qui remonte au registre puis à la racine, en laquelle tout résolveur validant a déjà confiance. Pour voir cette chaîne, et avoir un diagnostic clair quand quelque chose casse, colle le domaine dans dnsviz.net.</Guided>

<Deep>La zone est signée par une clé de signature de zone (ZSK), elle-même signée par une clé de signature de clé (KSK) ; le registre publie un condensat de la KSK sous forme d'enregistrement DS dans la zone parente. Infomaniak fait tourner la ZSK pour toi. Le drapeau `ad` signifie seulement que le résolveur a validé ; `delv`, fourni avec BIND, fait la validation en local, mais il n'est pas sur macOS par défaut (`brew install bind` l'ajoute).</Deep>

</When>

## Reverse DNS chez OVH (facultatif)

Fais en sorte que l'IP du serveur réponde <V name="DOMAIN" /> quand on la cherche à l'envers. Dans l'espace client OVH : **Network** → **IP publiques** → `...` à côté de <V name="SERVER_IP" /> → **Modifier le reverse** → <V name="DOMAIN" />. Fais de même pour <V name="SERVER_IPV6" /> si elle est listée.

<Guided>Un enregistrement inverse (PTR) fait correspondre une IP à un nom. Les serveurs de mail destinataires en exigent un qui corresponde avant d'accepter du courrier d'un serveur : il te le faut si le VPS envoie un jour du mail (alertes, un formulaire de contact plus tard). Sinon, il rend seulement les logs et la sortie de `traceroute` plus lisibles. OVH vérifie que <V name="DOMAIN" /> mène déjà à cette IP avant d'accepter le reverse : fais-le après que les vérifications ci-dessus passent.</Guided>

<Note>OVH renomme de temps en temps les rubriques de son espace client ; la liste des IP peut se trouver sous « Bare Metal Cloud » → « Network » → « IP ». Le guide OVH « Configurer un reverse DNS » donne le chemin actuel.</Note>

<Deep>Le reverse vit dans un arbre séparé (`in-addr.arpa` pour l'IPv4, `ip6.arpa` pour l'IPv6), délégué au propriétaire du bloc d'adresses : OVH, pas Infomaniak. C'est pour ça qu'il se règle là-bas. Un reverse « confirmé », où l'enregistrement A du nom renvoie vers la même IP, est ce que vérifient les serveurs de mail.</Deep>

## Remonter le TTL

Une fois les vérifications passées et le site fonctionnel à la page suivante, passe le TTL des enregistrements créés ou modifiés à **1 h** (3600 secondes).

<Guided>Un TTL court, c'est plus de requêtes et des premières visites un peu plus lentes, et ça t'expose si les serveurs d'Infomaniak tombent : avec un TTL d'une heure, les résolveurs continuent de servir ton adresse depuis leur cache pendant cette heure. Rebaisse-le la veille de tout changement prévu, comme un nouveau serveur.</Guided>

## Terminé

<V name="DOMAIN" /> et `www` mènent au VPS (en IPv6 aussi si `ping -6` a répondu), et seules les autorités que tu as listées peuvent émettre des certificats pour eux.<When is="MAIL" equals="none"> Le mail qui prétend venir du domaine est rejeté.</When><When is="MAIL" equals="infomaniak"> Ton mail Infomaniak continue de circuler, sous une politique DMARC que tu resserres au fil des semaines.</When><When flag="DNSSEC"> La zone est signée.</When>

Page suivante : **Servir le site avec Caddy et HTTPS**. Caddy demande le certificat pour les deux noms à son premier démarrage, ce qui ne marche que parce que ce DNS est en place.
````

````yaml title="content/ovh-vps-static-site/point-the-domain/diagram.yaml"
# Quick: the lookup path, visitor → resolver → Infomaniak zone → VPS, and the CA reading CAA.
# Guided: the www CNAME and the CAA record as their own boxes. Deep: the DNSSEC chain from the
# registry, receiving mail servers checking SPF/DMARC, and the reverse (PTR) record at OVH.
title: { en: "Who answers, and with what", fr: "Qui répond, et quoi" }
caption:
  en: "A visitor's resolver asks the Infomaniak zone where ${DOMAIN} lives and gets the VPS's addresses. The same zone tells certificate authorities who may issue for the domain, and mail servers whether a message in its name is genuine."
  fr: "Le résolveur d'un visiteur demande à la zone Infomaniak où vit ${DOMAIN} et reçoit les adresses du VPS. La même zone dit aux autorités de certification qui peut émettre pour le domaine, et aux serveurs de mail si un message en son nom est authentique."

groups:
  - id: infomaniak
    label: { en: "Infomaniak", fr: "Infomaniak" }
    desc:
      en: "Registrar and DNS host of ${DOMAIN}. Everything you edit on this page lives here, in the Manager's Zone DNS."
      fr: "Bureau d'enregistrement et hébergeur DNS de ${DOMAIN}. Tout ce que tu modifies sur cette page vit ici, dans la Zone DNS du Manager."
  - id: ovh
    label: { en: "OVH", fr: "OVH" }
    desc:
      en: "Where the VPS runs, and the owner of its IP addresses (hence of their reverse DNS)."
      fr: "Là où tourne le VPS, et le propriétaire de ses adresses IP (donc de leur reverse DNS)."

nodes:
  - id: visitor
    kind: user
    label: { en: "A visitor", fr: "Un visiteur" }
    sub: "https://${DOMAIN}"
    desc:
      en: "Types the domain in a browser. Before any HTTPS, the browser needs an IP address for the name."
      fr: "Tape le domaine dans un navigateur. Avant tout HTTPS, le navigateur a besoin d'une adresse IP pour ce nom."
  - id: resolver
    kind: net
    label: { en: "Resolver", fr: "Résolveur" }
    sub: "ISP · 1.1.1.1 · 9.9.9.9"
    desc:
      en: "The visitor's DNS resolver. It asks the authoritative servers and caches the answer for the TTL, which is why changes take minutes to an hour to spread."
      fr: "Le résolveur DNS du visiteur. Il interroge les serveurs faisant autorité et garde la réponse en cache pendant le TTL : c'est pour ça qu'un changement met de quelques minutes à une heure à se propager."
    deep:
      sub: "recursive · caches for TTL · validates DNSSEC"
  - id: zone
    kind: store
    label: { en: "DNS zone", fr: "Zone DNS" }
    sub: "ns11 / ns12.infomaniak.ch"
    in: infomaniak
    focus: true
    desc:
      en: "The zone of ${DOMAIN}: A and AAAA to the VPS, CAA, and the mail records. Only used because the domain's nameservers are Infomaniak's."
      fr: "La zone de ${DOMAIN} : A et AAAA vers le VPS, CAA, et les enregistrements email. Utilisée seulement parce que les serveurs de noms du domaine sont ceux d'Infomaniak."
    guided:
      sub: "A ${SERVER_IP} · AAAA ${SERVER_IPV6}"
    deep:
      sub: "@ A ${SERVER_IP} · @ AAAA ${SERVER_IPV6} · TTL 300 → 3600"
  - id: vps
    kind: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    sub: "${SERVER_IP}"
    in: ovh
    desc:
      en: "The server the addresses lead to. It serves nothing on 80/443 until the next page installs Caddy."
      fr: "Le serveur vers lequel mènent les adresses. Il ne sert rien sur 80/443 avant que la page suivante installe Caddy."
    deep:
      sub: "${SERVER_IP} · ${SERVER_IPV6}"
  - id: ca
    kind: cloud
    label: { en: "Let's Encrypt", fr: "Let's Encrypt" }
    sub: "ACME CA"
    desc:
      en: "Issues the certificate on the next page. Before issuing, it must read the CAA record and refuse if it is not listed."
      fr: "Émet le certificat à la page suivante. Avant d'émettre, il doit lire l'enregistrement CAA et refuser s'il n'y figure pas."
    guided:
      sub: "letsencrypt.org"
  - id: www
    kind: file
    label: { en: "www record", fr: "Enregistrement www" }
    sub: "CNAME → ${DOMAIN}."
    in: infomaniak
    level: guided
    desc:
      en: "www is an alias of the bare name (${DOMAIN}, nothing in front): one place to change the IP. Caddy redirects it to the bare name but needs it to resolve to get its certificate."
      fr: "www est un alias du nom nu (${DOMAIN}, sans rien devant) : un seul endroit où changer l'IP. Caddy le redirige vers le nom nu mais a besoin qu'il résolve pour obtenir son certificat."
  - id: caa
    kind: file
    label: { en: "CAA record", fr: "Enregistrement CAA" }
    sub: "0 issue letsencrypt.org"
    in: infomaniak
    level: guided
    desc:
      en: "Names the certificate authorities allowed to issue for ${DOMAIN}. Any other CA must refuse."
      fr: "Nomme les autorités de certification autorisées à émettre pour ${DOMAIN}. Toute autre autorité doit refuser."
    deep:
      sub: "issue letsencrypt.org · iodef mailto:${ADMIN_EMAIL}"
  - id: registry
    kind: cloud
    label: { en: "TLD registry", fr: "Registre de l'extension" }
    sub: "DS record"
    level: deep
    when: { flag: DNSSEC }
    desc:
      en: "Publishes the NS delegation and, with DNSSEC, a DS hash of the zone's key. Resolvers chain trust from the root through it to your zone."
      fr: "Publie la délégation NS et, avec DNSSEC, un condensat DS de la clé de la zone. Les résolveurs enchaînent la confiance depuis la racine, à travers lui, jusqu'à ta zone."
  - id: mailservers
    kind: cloud
    label: { en: "Receiving mail servers", fr: "Serveurs de mail destinataires" }
    sub: "Gmail, Outlook…"
    level: deep
    when: { is: MAIL, equals: none }
    desc:
      en: "When a message claims to be from ${DOMAIN}, they read SPF and DMARC in the zone: no server is allowed, so the forgery is rejected."
      fr: "Quand un message prétend venir de ${DOMAIN}, ils lisent SPF et DMARC dans la zone : aucun serveur n'est autorisé, donc le faux est rejeté."
  - id: ptr
    kind: file
    label: { en: "Reverse DNS", fr: "Reverse DNS" }
    sub: "PTR ${SERVER_IP} → ${DOMAIN}"
    in: ovh
    level: deep
    desc:
      en: "Optional. Maps the IP back to the name. Set in the OVH panel because OVH owns the address block; needed if the server ever sends mail."
      fr: "Facultatif. Fait correspondre l'IP au nom. Se règle dans l'espace OVH car OVH possède le bloc d'adresses ; nécessaire si le serveur envoie un jour du mail."

edges:
  - from: visitor
    to: resolver
    label: "${DOMAIN} ?"
    desc: { en: "The browser asks its resolver for the name's addresses.", fr: "Le navigateur demande à son résolveur les adresses du nom." }
  - from: resolver
    to: zone
    label: "A · AAAA ?"
    deep: { label: "UDP/TCP 53 · A · AAAA · DO bit" }
    desc: { en: "On a cache miss, the resolver asks Infomaniak's authoritative servers.", fr: "Faute de réponse en cache, le résolveur interroge les serveurs faisant autorité d'Infomaniak." }
  - from: zone
    to: vps
    label: "${SERVER_IP}"
    guided: { label: "A ${SERVER_IP} · AAAA" }
    desc: { en: "The answer: the VPS's addresses. The browser then connects to the server directly.", fr: "La réponse : les adresses du VPS. Le navigateur se connecte ensuite directement au serveur." }
  - from: visitor
    to: vps
    label: "HTTPS (next page)"
    dashed: true
    desc: { en: "Once the address is known, the browser talks to the VPS. Caddy answers there from the next page on.", fr: "Une fois l'adresse connue, le navigateur parle au VPS. Caddy y répond à partir de la page suivante." }
  - from: ca
    to: zone
    label: "reads CAA"
    dashed: true
    max: quick
    desc: { en: "At issuance, the CA checks that it is allowed for this domain.", fr: "Au moment d'émettre, l'autorité vérifie qu'elle a le droit pour ce domaine." }
  - from: ca
    to: caa
    label: "reads CAA"
    dashed: true
    level: guided
    desc: { en: "At issuance, the CA reads the CAA record and refuses if it is not named.", fr: "Au moment d'émettre, l'autorité lit l'enregistrement CAA et refuse si elle n'est pas nommée." }
  - from: www
    to: zone
    label: "alias of @"
    dashed: true
    level: guided
    desc: { en: "Resolving www follows the CNAME to the bare name and returns the same addresses.", fr: "Résoudre www suit le CNAME jusqu'au nom nu et renvoie les mêmes adresses." }
  - from: registry
    to: zone
    label: "DS · chain of trust"
    dashed: true
    level: deep
    when: { flag: DNSSEC }
    desc: { en: "The DS at the registry vouches for the zone's key; validating resolvers reject answers whose signature does not match.", fr: "Le DS au registre garantit la clé de la zone ; les résolveurs validants rejettent les réponses dont la signature ne correspond pas." }
  - from: mailservers
    to: zone
    label: "SPF · DMARC → reject"
    dashed: true
    level: deep
    when: { is: MAIL, equals: none }
    desc: { en: "v=spf1 -all and p=reject: any mail in the domain's name fails and is refused.", fr: "v=spf1 -all et p=reject : tout mail au nom du domaine échoue et est refusé." }
  - from: ptr
    to: vps
    label: "PTR"
    dashed: true
    level: deep
    desc: { en: "The reverse lookup of the VPS's IP returns ${DOMAIN}, matching the forward A record.", fr: "La recherche inverse de l'IP du VPS renvoie ${DOMAIN}, en accord avec l'enregistrement A." }
````

---

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