# Source of "Launch checklist"

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/launch-checklist/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, SERVER_IPV6, USERNAME, SSH_PORT, DEPLOY_USER, DOMAIN, WEB_ROOT, ADMIN_EMAIL, LOCAL_DIR.
# No page variables: two choices only.

title:
  en: Launch checklist
  fr: Checklist de lancement
summary:
  en: >-
    Before you announce the site: robots.txt and sitemap.xml generated by Next.js, the heaviest
    files trimmed, and everything the previous pages set up verified from the outside — redirects,
    404, certificate, headers, IPv6, HTTP/3, performance — with one script that prints PASS or FAIL.
  fr: >-
    Avant d'annoncer le site : robots.txt et sitemap.xml générés par Next.js, les fichiers les plus
    lourds allégés, et tout ce que les pages précédentes ont mis en place vérifié depuis l'extérieur
    — redirections, 404, certificat, en-têtes, IPv6, HTTP/3, performance — avec un script qui
    affiche PASS ou FAIL.
difficulty: beginner
tags: [launch, seo, robots, sitemap, tls, hsts, performance, nextjs]
authors: [thudal]
created: 2026-09-26
minutes: 30
validated: Next.js 16 · Caddy 2.x · Sept 2026
status: draft             # not yet run end to end by its author

choices:
  - key: INDEXING
    type: boolean
    label: { en: Let search engines index the site, fr: Laisser les moteurs de recherche indexer le site }
    hint:
      en: Off, robots.txt asks every crawler to stay away. Recommended while the site still holds examples or fake features (a contact form that only simulates sending, for instance); switch it on and redeploy on launch day.
      fr: Désactivé, robots.txt demande à tous les robots de rester à l'écart. Recommandé tant que le site contient des exemples ou des fonctions factices (un formulaire de contact qui ne fait que simuler l'envoi, par exemple) ; active-le et redéploie le jour du lancement.
    default: true
  - key: HSTS_PRELOAD
    type: boolean
    label: { en: Submit the domain to the HSTS preload list, fr: Inscrire le domaine sur la liste de préchargement HSTS }
    hint:
      en: Browsers then refuse plain HTTP for the domain and every subdomain, even on a first visit. Hard to undo; leave it off unless you are sure.
      fr: Les navigateurs refusent alors le HTTP en clair pour le domaine et tous ses sous-domaines, même à la première visite. Difficile à défaire ; laisse désactivé si tu n'es pas sûr.
    default: false
````

````mdx title="content/ovh-vps-static-site/launch-checklist/page-en.mdx"
{/* First pass — to be validated against https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots , https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap , https://nextjs.org/docs/app/guides/static-exports and https://hstspreload.org before publishing. */}

The site is live. Before you tell anyone, this page adds the two files a static export does not produce by itself — `robots.txt` and `sitemap.xml` — trims the files that make pages slow, then checks from the outside, the way a visitor or a crawler sees it, everything pages 4 to 7 set up. It ends with the search engines and a short list for announcement day.

<Run>

Open a new file `launch-check.sh` in the project folder with <V name="EDITOR" />, paste every code block of this section into it in order, then run `bash launch-check.sh`. It runs **on your Mac**, from <V name="LOCAL_DIR" />, as yourself, with the site already live and `dig`, `curl` and `openssl` available (they ship with macOS). It writes `app/sitemap.ts` if missing and `app/robots.ts` (overwritten, to follow the indexing choice), deploys with `scripts/deploy.sh` from page 6 (which runs `npm ci` and the build itself), then prints PASS, FAIL or NOTE per outside check and exits non-zero if any check fails. Raising HSTS to a year stays a manual step: it needs sudo on the server.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Launch checklist — https://${DOMAIN}
cd ${LOCAL_DIR}
[ -d app ] || { echo "No app/ folder in $PWD: adjust the paths below (src/app?)." >&2; exit 1; }
if [ ! -f app/sitemap.ts ]; then
cat > app/sitemap.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
const ROUTES = [
  "/", "/bio/", "/bookmarks/", "/chess/", "/clippings/", "/contact/",
  "/endeavor/", "/how-i-want-to-live/", "/journey/", "/lately/", "/library/",
  "/own/", "/pearl/", "/picture/", "/support/", "/thought/", "/travel/", "/writing/",
];
export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date();
  return ROUTES.map((path) => ({ url: SITE + path, lastModified: now }));
}
EOF
fi
```

<When flag="INDEXING">

```bash on="mac"
cat > app/robots.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
EOF
```

</When>

<When notFlag="INDEXING">

```bash on="mac"
cat > app/robots.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", disallow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
EOF
```

</When>

```bash on="mac"
./scripts/deploy.sh
ls out/robots.txt out/sitemap.xml >/dev/null
FAILS=0
check() {
  if eval "$2" >/dev/null 2>&1; then echo "PASS  $1"; else echo "FAIL  $1"; FAILS=$((FAILS+1)); fi
}
code() { curl -s -o /dev/null -w '%{http_code}' "$1"; }
loc() { curl -sI "$1" | tr -d '\r' | awk 'tolower($1)=="location:" {print $2}'; }

check "https://${DOMAIN}/ answers 200" '[ "$(code https://${DOMAIN}/)" = 200 ]'
check "http:// redirects to https:// (301/308)" 'code http://${DOMAIN}/ | grep -Eq "^30[18]$" && [ "$(loc http://${DOMAIN}/)" = "https://${DOMAIN}/" ]'
check "www redirects to the bare domain (301/308)" 'code https://www.${DOMAIN}/ | grep -Eq "^30[18]$" && [ "$(loc https://www.${DOMAIN}/)" = "https://${DOMAIN}/" ]'
check "Unknown page returns 404" '[ "$(code https://${DOMAIN}/launch-check-$RANDOM/)" = 404 ]'
check "robots.txt answers 200" '[ "$(code https://${DOMAIN}/robots.txt)" = 200 ]'
check "sitemap.xml answers 200" '[ "$(code https://${DOMAIN}/sitemap.xml)" = 200 ]'
check "HSTS header present" 'curl -sI https://${DOMAIN}/ | grep -qi "^strict-transport-security:"'
check "Certificate issued by Let's Encrypt" 'echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | openssl x509 -noout -issuer | grep -q "Let.s Encrypt"'
check "A record is ${SERVER_IP}" 'dig +short A ${DOMAIN} | grep -qxF "${SERVER_IP}"'
if [ -n "$(dig +short AAAA ${DOMAIN})" ]; then echo "PASS  AAAA record resolves"; else echo "NOTE  no AAAA record (fine if you skipped IPv6 on page 4)"; fi
check "HTTP/3 advertised (alt-svc h3)" 'curl -sI https://${DOMAIN}/ | grep -qi "^alt-svc:.*h3"'
```

<When flag="HSTS_PRELOAD">

```bash on="mac"
check "HSTS header is preload-ready" 'curl -sI https://${DOMAIN}/ | grep -i "^strict-transport-security:" | grep -q "includeSubDomains; preload"'
```

</When>

```bash on="mac"
echo
if [ "$FAILS" -gt 0 ]; then
  echo "$FAILS check(s) failed. Fix them before announcing."
  exit 1
fi
echo "All checks passed. Next: SSL Labs, securityheaders.com, Lighthouse, then raise HSTS to a year."
```

</Run>

## Before you start

The site is deployed with the script from page 6 and answers on its domain. Everything on this page runs on your Mac, from the project folder, except the HSTS edits on the server. Line up two friends as external testers for the end: one on mobile data, one on another operator's Wi-Fi. First, the home page answers:

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

## Add robots.txt and sitemap.xml

`output: "export"` writes only what the app declares. Next.js has two metadata files for this: `app/robots.ts` and `app/sitemap.ts`. At build time each becomes a plain file in `out/`.

Set the indexing choice in the panel first. Leave it **off** while the site still holds examples or features that only pretend to work, such as a contact form that simulates sending: crawlers would index them as they are. Switch it on, and redeploy, on launch day.

In a terminal on your Mac, go to the project folder, then copy each file block below and paste it in the terminal: each one writes its file.

```bash on="mac"
cd ${LOCAL_DIR}
```

<When flag="INDEXING">

```ts file="app/robots.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
```

</When>

<When notFlag="INDEXING">

```ts file="app/robots.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", disallow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
```

</When>

```ts file="app/sitemap.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
const ROUTES = [
  "/", "/bio/", "/bookmarks/", "/chess/", "/clippings/", "/contact/",
  "/endeavor/", "/how-i-want-to-live/", "/journey/", "/lately/", "/library/",
  "/own/", "/pearl/", "/picture/", "/support/", "/thought/", "/travel/", "/writing/",
];
export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date();
  return ROUTES.map((path) => ({ url: SITE + path, lastModified: now }));
}
```

<Note>`export const dynamic = "force-static"` is what lets these two routes build with `output: "export"` in recent Next.js versions; without it the build stops with an error naming `/robots.txt`. Confirm in the Next.js docs, "Metadata files: robots.txt" and "sitemap.xml", and the "Static exports" guide.</Note>

<Guided>

Each URL keeps its trailing slash, because `trailingSlash: true` makes `/bio/` the canonical address and `/bio` a redirect. A sitemap should list only canonical URLs. The list holds the top-level pages. The detail pages — `/clippings/[slug]`, `/endeavor/[slug]`, `/picture/[slug]`, `/writing/[slug]`, `/writing/series/[name]` — can be appended by calling, inside `sitemap()`, the function your page's `generateStaticParams` calls, and mapping each slug to `SITE + "/writing/" + slug + "/"`. If that function is `async`, make `sitemap()` `async` too.

Leave out the private pages under `/contact/` and `/chess/` that carry a code in the URL. They are private conversations, marked `noindex`: listing them would hand their addresses to every crawler.

</Guided>

Deploy with the script from page 6, which builds before uploading, then check both files were generated:

```bash on="mac"
cd ${LOCAL_DIR}
./scripts/deploy.sh
ls out/robots.txt out/sitemap.xml
```

<Check cmd="curl -s https://${DOMAIN}/robots.txt | head -1" expect="User-Agent: *" />

<Note>Next.js writes the first line as `User-Agent: *`, with a capital A. Crawlers do not care about the casing; if your version prints `User-agent`, the file is fine.</Note>

<Details summary="If the build fails or out/robots.txt is missing">

A `public/robots.txt` or `public/sitemap.xml` conflicts with the generated one: delete the one in `public/`. An error naming `dynamic` or `revalidate` on `/robots.txt` means the `force-static` line is missing. A project laid out in `src/app/` takes both files there.

</Details>

<Deep>Do not put the private `/contact/` and `/chess/` URLs in a `Disallow` line to hide them. `robots.txt` is public, so the list becomes a map of what you want hidden; and a disallowed URL can still be indexed from a link elsewhere, because the crawler never fetches the page and never sees its `noindex`. The `noindex` meta tag, on a page crawlers are allowed to fetch, is the tool that keeps it out of results.</Deep>

## Trim the heaviest files

A 50 MB WAV on a page is a 50 MB download on a phone. List everything over 5 MB that the site serves:

```bash on="mac"
cd ${LOCAL_DIR}
find out -type f -size +5M -exec ls -lh {} \; | sort -k5 -h
```

<Guided>If you already compressed the heavy media, this list is short. What is usually left: WAV files, phone-size JPEGs (4000 px wide and more) and a few MP4s.</Guided>

Audio: convert each WAV to Opus, or MP3 for the oldest browsers, then change the reference in the code.

```bash on="mac"
brew install ffmpeg
ffmpeg -i public/audio/track.wav -c:a libopus -b:a 128k public/audio/track.opus
ffmpeg -i public/audio/track.wav -c:a libmp3lame -q:a 2 public/audio/track.mp3
grep -rn '\.wav' app components lib 2>/dev/null
```

<Guided>`track.wav` stands for your file's real name and folder. 128 kbit/s Opus is transparent for music and about 25 times smaller than 16-bit stereo WAV. Offer both formats with two `source` elements inside the `audio` tag and each browser picks the one it plays.</Guided>

Images: nothing on a web page needs more than 2400 px on its long side.

<Warn>`sips -Z` overwrites the image in place, with no undo. Work on a copy: duplicate `public/` first, and never run it on the originals in `photos-26-og/`. Time Machine, set up on page 1, is the safety net if something slips.</Warn>

```bash on="mac"
cd ${LOCAL_DIR}
cp -R public ../public-before-resize
find public -iname '*.jp*g' -size +1M -exec sips -Z 2400 {} \;
du -sh ../public-before-resize public
```

<Guided>`sips` ships with macOS. `-Z 2400` scales the image so its longest side is 2400 px, keeping proportions; smaller images are left alone. Look at a few results before you delete the copy. Run `./scripts/deploy.sh` again when you are happy; it rebuilds.</Guided>

<Deep>

AVIF and WebP are 30 to 50 % smaller than JPEG at the same quality. `brew install webp libavif` gives `cwebp -q 80 in.jpg -o out.webp` and `avifenc in.jpg out.avif`; serve them with a `picture` element and a JPEG fallback.

Do not count on `next/image` for this. With `output: "export"` there is no server to resize images on request, so the default loader cannot run: the project either sets `images: { unoptimized: true }` or needs a custom loader pointing at an image CDN. Confirm in the Next.js "Static exports" guide, section "Image Optimization". Resizing at build time, as above, is the simplest path for a site this size.

</Deep>

## Check from the outside

### Redirects and status codes

```bash on="mac"
curl -sI http://${DOMAIN}/ | grep -iE '^(HTTP|location)'
curl -sI https://www.${DOMAIN}/ | grep -iE '^(HTTP|location)'
curl -s -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/no-such-page/
```

```text title="Expected output"
HTTP/1.1 308 Permanent Redirect
Location: https://${DOMAIN}/
HTTP/2 301
location: https://${DOMAIN}/
404
```

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

<Guided>Open the same missing address in a browser: you should see your styled 404 page, not a blank "Not Found". Caddy's `handle_errors` block from page 5 serves `404.html` with the 404 status, so search engines do not index missing pages as real ones.</Guided>

### TLS

Open SSL Labs' test for your domain. It takes about two minutes. Aim for A; A+ needs an HSTS max-age of at least six months, which the step after these checks sets.

```bash on="mac"
open "https://www.ssllabs.com/ssltest/analyze.html?d=${DOMAIN}"
curl -sv -o /dev/null https://${DOMAIN}/ 2>&1 | grep -E 'issuer|expire date'
```

<Check cmd={"echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | openssl x509 -noout -issuer | grep -o 'Let.s Encrypt'"} expect="Let's Encrypt" />

<Guided>The issuer line reads `O=Let's Encrypt` with an intermediate such as `E7` or `R12`. If it says ZeroSSL, Caddy fell back to its second authority: the site works, but check that the CAA record from page 4 allows it, or the next renewal will fail.</Guided>

### Security headers

```bash on="mac"
open "https://securityheaders.com/?q=https://${DOMAIN}/&followRedirects=on"
curl -sI https://${DOMAIN}/ | grep -iE '^(strict-transport|x-content-type|x-frame|referrer|permissions|content-security)'
```

Expect `strict-transport-security`, `x-content-type-options: nosniff`, `referrer-policy` and whatever else page 5 set. A missing `content-security-policy` is expected at this stage: it costs a grade, not security you already had.

<Note>If securityheaders.com is gone or asks for an account, Mozilla's HTTP Observatory (developer.mozilla.org/en-US/observatory) runs the same checks for free.</Note>

### IPv6 and HTTP/3

```bash on="mac"
dig +short AAAA ${DOMAIN}
curl -6 -sI https://${DOMAIN}/ | head -1
curl -sI https://${DOMAIN}/ | grep -i '^alt-svc'
```

<Note>`curl -6` fails with "Couldn't connect" if your own network has no IPv6, which is common on home boxes. Then test from outside: ipv6-test.com/validate.php, or the phone of a tester on mobile data.</Note>

<Guided>`alt-svc: h3=":443"` means Caddy tells browsers HTTP/3 is available on UDP 443, which page 5 opened in ufw; browsers switch on their next request. `curl --http3` tests HTTP/3 itself, but only if `curl -V` lists `HTTP3`. macOS's own curl does not: use http3check.net instead.</Guided>

### Mail spoofing

If you told page 4 the domain sends no mail, check that the "no mail" records are published, so nobody can send phishing in your name:

```bash on="mac"
dig +short TXT _dmarc.${DOMAIN}
dig +short TXT ${DOMAIN} | grep spf1
```

<Guided>Look for `p=reject` in the DMARC line and `-all` at the end of the SPF line. If the domain does send mail (an Infomaniak mailbox, for example), these records come from your mail provider instead; leave them as they are.</Guided>

### Performance

In Chrome, open the site in a private window, then DevTools → Lighthouse → Mobile → Analyze page load. Or use PageSpeed Insights, which runs the same test from Google's servers:

```bash on="mac"
open "https://pagespeed.web.dev/report?url=https://${DOMAIN}/"
```

<Guided>Look at Largest Contentful Paint and the "Avoid enormous network payloads" line first: on this site they point at the images and audio of the previous step. Test the heaviest page too, not only the home page — `/picture/` or `/travel/` rather than `/`.</Guided>

## Raise HSTS to a year

Page 5 started the HSTS header at one day. Once every check above passes, raise it to a year, on the server:

<Warn>With a year, every browser that visits once refuses plain HTTP for <V name="DOMAIN" /> for the next twelve months, even if the certificate breaks or you remove the header later. Lowering it again only reaches visitors who come back. Raise it only when HTTPS works and the checks above are green.</Warn>

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

```bash on="server" as="${USERNAME}"
sudo sed -i 's/max-age=86400"/max-age=31536000"/' /etc/caddy/Caddyfile
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

<Check cmd="curl -sI https://${DOMAIN}/ | grep -i '^strict-transport-security'" expect="strict-transport-security: max-age=31536000" />

<Guided>If you picked another value on page 5, `sed` changes nothing: edit the `header Strict-Transport-Security` line with `sudo ${EDITOR} /etc/caddy/Caddyfile` instead. Validate as the `caddy` user, as on page 5: run as root, `caddy validate` can leave root-owned log files that Caddy can no longer write.</Guided>

## Search engines

<When flag="INDEXING">

Tell Google and Bing the site exists and where the sitemap is.

1. In Google Search Console, add a property of type **Domain** with <V name="DOMAIN" />. Google shows a TXT record starting with `google-site-verification=`.
2. In the Infomaniak Manager, Zone DNS of the domain (as on page 4), add that TXT record on the domain itself, then click Verify in Search Console. It can take a few minutes.
3. In Search Console → Sitemaps, enter `sitemap.xml` and submit.
4. In Bing Webmaster Tools, sign in and choose the import from Google Search Console: it copies the site and the sitemap.

```bash on="mac"
dig +short TXT ${DOMAIN} | grep google-site-verification
```

<Guided>Leave the TXT record in the zone forever: Google re-checks it, and removing it un-verifies the property. A Domain property covers `https://`, `http://` and `www.` in one go, which is why it needs DNS rather than a file on the site.</Guided>

</When>

<When notFlag="INDEXING">

Crawlers are asked to stay away for now. On launch day: switch the indexing choice on, update `app/robots.ts` as this page then shows it (or run the script again), and run `./scripts/deploy.sh`. Then register the site in Google Search Console and Bing Webmaster Tools and submit `sitemap.xml`; the page shows the steps once the choice is on.

</When>

<Guided>`robots.txt` is a request, not access control. Polite crawlers follow it; anyone else can still fetch every URL. What must stay private needs a login or must not be published at all.</Guided>

<When flag="HSTS_PRELOAD">

## HSTS preload

Browsers ship with a built-in list of domains that are HTTPS-only. Once <V name="DOMAIN" /> is on it, no browser ever makes a plain HTTP request to it or to any subdomain, not even on a first visit.

<Warn>This is a one-way door. Every subdomain of <V name="DOMAIN" />, present and future — `mail.`, a test host, a third-party service on a CNAME — must serve valid HTTPS forever, or it becomes unreachable. Removal is possible but takes months, because it rides on browser releases and old browsers keep the entry.</Warn>

1. List every name in the Infomaniak Zone DNS and check each one answers over HTTPS with a valid certificate.
2. On the server, switch the `Strict-Transport-Security` line of `/etc/caddy/Caddyfile` from page 5 to the preload value, whatever it was before, then validate and reload:

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

```bash on="server" as="${USERNAME}"
sudo sed -i 's/header Strict-Transport-Security "[^"]*"/header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"/' /etc/caddy/Caddyfile
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

3. On hstspreload.org, enter <V name="DOMAIN" />, fix anything the eligibility check reports, tick the confirmations and submit.

<Check cmd="curl -sI https://${DOMAIN}/ | grep -i '^strict-transport-security'" expect="strict-transport-security: max-age=63072000; includeSubDomains; preload" />

<Deep>The requirements, as checked by hstspreload.org: a valid certificate, HTTP redirecting to HTTPS on the same host (Caddy does), and over HTTPS on the bare domain (<V name="DOMAIN" />, nothing in front) a header with max-age of at least one year, `includeSubDomains` and `preload`. The status goes to "pending", then the domain lands in Chromium's source list within weeks; Firefox, Safari and Edge derive their lists from it. Delisting goes through hstspreload.org/removal and takes the same slow path in reverse.</Deep>

</When>

## Announce

A last look before you post the link and ask your external testers to open the site on their phones:

- **Favicon**: `/favicon.ico` answers 200 (the command below). Next.js serves `app/favicon.ico` or `app/icon.png`.
- **Link preview image**: a 1200 × 630 JPEG as `app/opengraph-image.jpg` (Next.js adds the meta tags) and `metadataBase` set to https://<V name="DOMAIN" /> in the root layout, so the image URL is absolute. Export it from the originals in `photos-26-og/`; they stay out of the site. Paste the link in a message to yourself to see the preview.
- **404 page**: styled, with a way back home.
- **Stubs**: the contact form and chess save have no backend yet; hide them or label them "coming soon".
- **Monitoring**: the uptime monitor from page 7 is green and its alerts reach <V name="ADMIN_EMAIL" />.
- **After launch**: the RSS feeds for `/lately` from the project's TODO can come later; a feed added next week works the same.

```bash on="mac"
curl -s -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/favicon.ico
curl -s https://${DOMAIN}/ | grep -o '<meta property="og:image"[^>]*>'
```

## Done

The series is complete: a Next.js site built on your Mac, served over HTTPS by Caddy from your own Debian VPS at OVH, on a domain managed at Infomaniak, deployed atomically, backed up, monitored, and checked from the outside<When flag="INDEXING">, with Google and Bing told where the sitemap is</When>. The Run script of this page doubles as a smoke test after any big change.

A later page, not written yet — page 9 — will give the contact form and the chess saves a backend on the same VPS: a small Node service behind Caddy, with SQLite.
````

````mdx title="content/ovh-vps-static-site/launch-checklist/page-fr.mdx"
{/* Premier jet — à valider contre https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots , https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap , https://nextjs.org/docs/app/guides/static-exports et https://hstspreload.org avant publication. */}

Le site est en ligne. Avant d'en parler à qui que ce soit, cette page ajoute les deux fichiers qu'un export statique ne produit pas tout seul — `robots.txt` et `sitemap.xml` —, allège les fichiers qui ralentissent les pages, puis vérifie depuis l'extérieur, comme le voient un visiteur ou un robot d'indexation, tout ce que les pages 4 à 7 ont mis en place. Elle se termine par les moteurs de recherche et une courte liste pour le jour de l'annonce.

<Run>

Ouvre un nouveau fichier `launch-check.sh` dans le dossier du projet avec <V name="EDITOR" />, colles-y tous les blocs de code de cette section dans l'ordre, puis lance `bash launch-check.sh`. Il tourne **sur ton Mac**, depuis <V name="LOCAL_DIR" />, sous ton compte, avec le site déjà en ligne et `dig`, `curl` et `openssl` disponibles (ils sont fournis avec macOS). Il écrit `app/sitemap.ts` s'il manque et `app/robots.ts` (écrasé, pour suivre le choix d'indexation), déploie avec `scripts/deploy.sh` de la page 6 (qui lance lui-même `npm ci` et le build), puis affiche PASS, FAIL ou NOTE pour chaque vérification externe et sort en erreur si l'une échoue. Passer HSTS à un an reste une étape manuelle : elle demande sudo sur le serveur.

```bash on="mac"
#!/usr/bin/env bash
set -euo pipefail
# Launch checklist — https://${DOMAIN}
cd ${LOCAL_DIR}
[ -d app ] || { echo "No app/ folder in $PWD: adjust the paths below (src/app?)." >&2; exit 1; }
if [ ! -f app/sitemap.ts ]; then
cat > app/sitemap.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
const ROUTES = [
  "/", "/bio/", "/bookmarks/", "/chess/", "/clippings/", "/contact/",
  "/endeavor/", "/how-i-want-to-live/", "/journey/", "/lately/", "/library/",
  "/own/", "/pearl/", "/picture/", "/support/", "/thought/", "/travel/", "/writing/",
];
export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date();
  return ROUTES.map((path) => ({ url: SITE + path, lastModified: now }));
}
EOF
fi
```

<When flag="INDEXING">

```bash on="mac"
cat > app/robots.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
EOF
```

</When>

<When notFlag="INDEXING">

```bash on="mac"
cat > app/robots.ts <<'EOF'
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", disallow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
EOF
```

</When>

```bash on="mac"
./scripts/deploy.sh
ls out/robots.txt out/sitemap.xml >/dev/null
FAILS=0
check() {
  if eval "$2" >/dev/null 2>&1; then echo "PASS  $1"; else echo "FAIL  $1"; FAILS=$((FAILS+1)); fi
}
code() { curl -s -o /dev/null -w '%{http_code}' "$1"; }
loc() { curl -sI "$1" | tr -d '\r' | awk 'tolower($1)=="location:" {print $2}'; }

check "https://${DOMAIN}/ answers 200" '[ "$(code https://${DOMAIN}/)" = 200 ]'
check "http:// redirects to https:// (301/308)" 'code http://${DOMAIN}/ | grep -Eq "^30[18]$" && [ "$(loc http://${DOMAIN}/)" = "https://${DOMAIN}/" ]'
check "www redirects to the bare domain (301/308)" 'code https://www.${DOMAIN}/ | grep -Eq "^30[18]$" && [ "$(loc https://www.${DOMAIN}/)" = "https://${DOMAIN}/" ]'
check "Unknown page returns 404" '[ "$(code https://${DOMAIN}/launch-check-$RANDOM/)" = 404 ]'
check "robots.txt answers 200" '[ "$(code https://${DOMAIN}/robots.txt)" = 200 ]'
check "sitemap.xml answers 200" '[ "$(code https://${DOMAIN}/sitemap.xml)" = 200 ]'
check "HSTS header present" 'curl -sI https://${DOMAIN}/ | grep -qi "^strict-transport-security:"'
check "Certificate issued by Let's Encrypt" 'echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | openssl x509 -noout -issuer | grep -q "Let.s Encrypt"'
check "A record is ${SERVER_IP}" 'dig +short A ${DOMAIN} | grep -qxF "${SERVER_IP}"'
if [ -n "$(dig +short AAAA ${DOMAIN})" ]; then echo "PASS  AAAA record resolves"; else echo "NOTE  no AAAA record (fine if you skipped IPv6 on page 4)"; fi
check "HTTP/3 advertised (alt-svc h3)" 'curl -sI https://${DOMAIN}/ | grep -qi "^alt-svc:.*h3"'
```

<When flag="HSTS_PRELOAD">

```bash on="mac"
check "HSTS header is preload-ready" 'curl -sI https://${DOMAIN}/ | grep -i "^strict-transport-security:" | grep -q "includeSubDomains; preload"'
```

</When>

```bash on="mac"
echo
if [ "$FAILS" -gt 0 ]; then
  echo "$FAILS check(s) failed. Fix them before announcing."
  exit 1
fi
echo "All checks passed. Next: SSL Labs, securityheaders.com, Lighthouse, then raise HSTS to a year."
```

</Run>

## Avant de commencer

Le site est déployé avec le script de la page 6 et répond sur son domaine. Tout ce qui suit se fait sur ton Mac, depuis le dossier du projet, sauf les modifications HSTS sur le serveur. Prévois deux amis comme testeurs externes pour la fin : un en données mobiles, un sur le Wi-Fi d'un autre opérateur. D'abord, la page d'accueil répond :

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

## Ajouter robots.txt et sitemap.xml

`output: "export"` n'écrit que ce que l'application déclare. Next.js a deux fichiers de métadonnées pour ça : `app/robots.ts` et `app/sitemap.ts`. Au build, chacun devient un fichier ordinaire dans `out/`.

Règle d'abord le choix d'indexation dans le panneau. Laisse-le **désactivé** tant que le site contient encore des exemples ou des fonctions qui font semblant de marcher, comme un formulaire de contact qui simule l'envoi : les robots les indexeraient tels quels. Active-le, et redéploie, le jour du lancement.

Dans un terminal sur ton Mac, va dans le dossier du projet, puis copie chaque bloc de fichier ci-dessous et colle-le dans le terminal : chacun écrit son fichier.

```bash on="mac"
cd ${LOCAL_DIR}
```

<When flag="INDEXING">

```ts file="app/robots.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
```

</When>

<When notFlag="INDEXING">

```ts file="app/robots.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", disallow: "/" },
    sitemap: SITE + "/sitemap.xml",
  };
}
```

</When>

```ts file="app/sitemap.ts" on="mac"
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const SITE = "https://${DOMAIN}";
const ROUTES = [
  "/", "/bio/", "/bookmarks/", "/chess/", "/clippings/", "/contact/",
  "/endeavor/", "/how-i-want-to-live/", "/journey/", "/lately/", "/library/",
  "/own/", "/pearl/", "/picture/", "/support/", "/thought/", "/travel/", "/writing/",
];
export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date();
  return ROUTES.map((path) => ({ url: SITE + path, lastModified: now }));
}
```

<Note>C'est `export const dynamic = "force-static"` qui permet à ces deux routes de se construire avec `output: "export"` dans les versions récentes de Next.js ; sans lui, le build s'arrête sur une erreur qui cite `/robots.txt`. À confirmer dans la doc Next.js, « Metadata files: robots.txt » et « sitemap.xml », et dans le guide « Static exports ».</Note>

<Guided>

Chaque URL garde sa barre finale, parce que `trailingSlash: true` fait de `/bio/` l'adresse canonique et de `/bio` une redirection. Un sitemap ne doit lister que des URL canoniques. La liste contient les pages de premier niveau. Les pages de détail — `/clippings/[slug]`, `/endeavor/[slug]`, `/picture/[slug]`, `/writing/[slug]`, `/writing/series/[name]` — s'ajoutent en appelant, dans `sitemap()`, la fonction qu'appelle le `generateStaticParams` de la page concernée, puis en transformant chaque slug en `SITE + "/writing/" + slug + "/"`. Si cette fonction est `async`, rends `sitemap()` `async` aussi.

Laisse de côté les pages privées sous `/contact/` et `/chess/` qui portent un code dans l'URL. Ce sont des conversations privées, marquées `noindex` : les lister reviendrait à donner leurs adresses à tous les robots.

</Guided>

Déploie avec le script de la page 6, qui construit avant d'envoyer, puis vérifie que les deux fichiers ont bien été générés :

```bash on="mac"
cd ${LOCAL_DIR}
./scripts/deploy.sh
ls out/robots.txt out/sitemap.xml
```

<Check cmd="curl -s https://${DOMAIN}/robots.txt | head -1" expect="User-Agent: *" />

<Note>Next.js écrit la première ligne `User-Agent: *`, avec un A majuscule. Les robots ignorent la casse ; si ta version affiche `User-agent`, le fichier est bon.</Note>

<Details summary="Si le build échoue ou si out/robots.txt manque">

Un `public/robots.txt` ou `public/sitemap.xml` entre en conflit avec celui qui est généré : supprime celui de `public/`. Une erreur qui cite `dynamic` ou `revalidate` sur `/robots.txt` veut dire que la ligne `force-static` manque. Un projet organisé en `src/app/` prend les deux fichiers à cet endroit.

</Details>

<Deep>Ne mets pas les URL privées de `/contact/` et `/chess/` dans une ligne `Disallow` pour les cacher. `robots.txt` est public : la liste devient la carte de ce que tu veux cacher. Et une URL interdite peut quand même être indexée à partir d'un lien ailleurs, puisque le robot ne récupère jamais la page et ne voit donc jamais son `noindex`. La balise meta `noindex`, sur une page que les robots ont le droit de lire, est l'outil qui la garde hors des résultats.</Deep>

## Alléger les fichiers les plus lourds

Un WAV de 50 Mo sur une page, c'est 50 Mo à télécharger sur un téléphone. Liste tout ce que le site sert au-delà de 5 Mo :

```bash on="mac"
cd ${LOCAL_DIR}
find out -type f -size +5M -exec ls -lh {} \; | sort -k5 -h
```

<Guided>Si tu as déjà compressé les médias lourds, la liste est courte. Ce qui reste d'habitude : des WAV, des JPEG sortis du téléphone (4000 px de large et plus) et quelques MP4.</Guided>

L'audio : convertis chaque WAV en Opus, ou en MP3 pour les navigateurs les plus anciens, puis change la référence dans le code.

```bash on="mac"
brew install ffmpeg
ffmpeg -i public/audio/track.wav -c:a libopus -b:a 128k public/audio/track.opus
ffmpeg -i public/audio/track.wav -c:a libmp3lame -q:a 2 public/audio/track.mp3
grep -rn '\.wav' app components lib 2>/dev/null
```

<Guided>`track.wav` représente le vrai nom et le vrai dossier de ton fichier. De l'Opus à 128 kbit/s est transparent pour de la musique et environ 25 fois plus léger qu'un WAV stéréo 16 bits. Propose les deux formats avec deux éléments `source` dans la balise `audio` : chaque navigateur prend celui qu'il sait lire.</Guided>

Les images : rien sur une page web n'a besoin de plus de 2400 px sur le grand côté.

<Warn>`sips -Z` écrase l'image sur place, sans retour possible. Travaille sur une copie : duplique `public/` d'abord, et ne le lance jamais sur les originaux de `photos-26-og/`. Time Machine, mis en place en page 1, reste le filet de sécurité si quelque chose t'échappe.</Warn>

```bash on="mac"
cd ${LOCAL_DIR}
cp -R public ../public-before-resize
find public -iname '*.jp*g' -size +1M -exec sips -Z 2400 {} \;
du -sh ../public-before-resize public
```

<Guided>`sips` est fourni avec macOS. `-Z 2400` redimensionne l'image pour que son plus grand côté fasse 2400 px, en gardant les proportions ; les images plus petites ne bougent pas. Regarde quelques résultats avant de supprimer la copie. Relance `./scripts/deploy.sh` quand ça te va ; il reconstruit le site.</Guided>

<Deep>

AVIF et WebP pèsent 30 à 50 % de moins que le JPEG à qualité égale. `brew install webp libavif` donne `cwebp -q 80 in.jpg -o out.webp` et `avifenc in.jpg out.avif` ; sers-les avec un élément `picture` et un JPEG en repli.

Ne compte pas sur `next/image` pour ça. Avec `output: "export"`, il n'y a pas de serveur pour redimensionner les images à la demande, donc le loader par défaut ne peut pas tourner : le projet met soit `images: { unoptimized: true }`, soit un loader personnalisé qui pointe vers un CDN d'images. À confirmer dans le guide Next.js « Static exports », section « Image Optimization ». Redimensionner au build, comme ci-dessus, reste le plus simple pour un site de cette taille.

</Deep>

## Vérifier depuis l'extérieur

### Redirections et codes de statut

```bash on="mac"
curl -sI http://${DOMAIN}/ | grep -iE '^(HTTP|location)'
curl -sI https://www.${DOMAIN}/ | grep -iE '^(HTTP|location)'
curl -s -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/no-such-page/
```

```text title="Sortie attendue"
HTTP/1.1 308 Permanent Redirect
Location: https://${DOMAIN}/
HTTP/2 301
location: https://${DOMAIN}/
404
```

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

<Guided>Ouvre la même adresse inexistante dans un navigateur : tu dois voir ta page 404 mise en forme, pas un « Not Found » tout blanc. Le bloc `handle_errors` de Caddy (page 5) sert `404.html` avec le statut 404, pour que les moteurs de recherche n'indexent pas des pages manquantes comme de vraies pages.</Guided>

### TLS

Ouvre le test SSL Labs pour ton domaine. Il prend environ deux minutes. Vise A ; A+ demande un max-age HSTS d'au moins six mois, que l'étape qui suit ces vérifications met en place.

```bash on="mac"
open "https://www.ssllabs.com/ssltest/analyze.html?d=${DOMAIN}"
curl -sv -o /dev/null https://${DOMAIN}/ 2>&1 | grep -E 'issuer|expire date'
```

<Check cmd={"echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | openssl x509 -noout -issuer | grep -o 'Let.s Encrypt'"} expect="Let's Encrypt" />

<Guided>La ligne de l'émetteur indique `O=Let's Encrypt` avec un intermédiaire comme `E7` ou `R12`. Si elle dit ZeroSSL, Caddy s'est rabattu sur sa seconde autorité : le site marche, mais vérifie que l'enregistrement CAA de la page 4 l'autorise, sinon le prochain renouvellement échouera.</Guided>

### En-têtes de sécurité

```bash on="mac"
open "https://securityheaders.com/?q=https://${DOMAIN}/&followRedirects=on"
curl -sI https://${DOMAIN}/ | grep -iE '^(strict-transport|x-content-type|x-frame|referrer|permissions|content-security)'
```

Attends-toi à `strict-transport-security`, `x-content-type-options: nosniff`, `referrer-policy` et ce que la page 5 a ajouté d'autre. L'absence de `content-security-policy` est normale à ce stade : elle coûte une note, pas une sécurité que tu avais déjà.

<Note>Si securityheaders.com a disparu ou demande un compte, l'HTTP Observatory de Mozilla (developer.mozilla.org/en-US/observatory) fait les mêmes vérifications gratuitement.</Note>

### IPv6 et HTTP/3

```bash on="mac"
dig +short AAAA ${DOMAIN}
curl -6 -sI https://${DOMAIN}/ | head -1
curl -sI https://${DOMAIN}/ | grep -i '^alt-svc'
```

<Note>`curl -6` échoue avec « Couldn't connect » si ton propre réseau n'a pas d'IPv6, ce qui est courant sur les box. Teste alors depuis l'extérieur : ipv6-test.com/validate.php, ou le téléphone d'un testeur en données mobiles.</Note>

<Guided>`alt-svc: h3=":443"` veut dire que Caddy annonce aux navigateurs que HTTP/3 est disponible sur UDP 443, que la page 5 a ouvert dans ufw ; les navigateurs basculent à la requête suivante. `curl --http3` teste HTTP/3 lui-même, mais seulement si `curl -V` liste `HTTP3`. Le curl de macOS ne le fait pas : passe par http3check.net.</Guided>

### Usurpation d'adresse mail

Si tu as indiqué en page 4 que le domaine n'envoie pas de mail, vérifie que les enregistrements « pas de mail » sont publiés, pour que personne ne puisse envoyer du hameçonnage en ton nom :

```bash on="mac"
dig +short TXT _dmarc.${DOMAIN}
dig +short TXT ${DOMAIN} | grep spf1
```

<Guided>Cherche `p=reject` dans la ligne DMARC et `-all` à la fin de la ligne SPF. Si le domaine envoie du mail (une boîte Infomaniak, par exemple), ces enregistrements viennent de ton fournisseur de mail ; laisse-les tels quels.</Guided>

### Performance

Dans Chrome, ouvre le site en navigation privée, puis DevTools → Lighthouse → Mobile → Analyze page load. Ou passe par PageSpeed Insights, qui lance le même test depuis les serveurs de Google :

```bash on="mac"
open "https://pagespeed.web.dev/report?url=https://${DOMAIN}/"
```

<Guided>Regarde d'abord le Largest Contentful Paint et la ligne « Avoid enormous network payloads » : sur ce site, ils pointent vers les images et l'audio de l'étape précédente. Teste aussi la page la plus lourde, pas seulement l'accueil — `/picture/` ou `/travel/` plutôt que `/`.</Guided>

## Passer HSTS à un an

La page 5 a démarré l'en-tête HSTS à un jour. Une fois toutes les vérifications ci-dessus au vert, passe-le à un an, sur le serveur :

<Warn>Avec un an, chaque navigateur venu une fois refuse le HTTP en clair pour <V name="DOMAIN" /> pendant les douze mois suivants, même si le certificat casse ou si tu retires l'en-tête plus tard. Le rabaisser ne touche que les visiteurs qui reviennent. Ne le monte que quand HTTPS marche et que les vérifications ci-dessus sont vertes.</Warn>

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

```bash on="server" as="${USERNAME}"
sudo sed -i 's/max-age=86400"/max-age=31536000"/' /etc/caddy/Caddyfile
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

<Check cmd="curl -sI https://${DOMAIN}/ | grep -i '^strict-transport-security'" expect="strict-transport-security: max-age=31536000" />

<Guided>Si tu as choisi une autre valeur en page 5, `sed` ne change rien : modifie la ligne `header Strict-Transport-Security` avec `sudo ${EDITOR} /etc/caddy/Caddyfile` à la place. Valide sous l'utilisateur `caddy`, comme en page 5 : lancé en root, `caddy validate` peut laisser des fichiers de log appartenant à root, que Caddy ne peut plus écrire.</Guided>

## Moteurs de recherche

<When flag="INDEXING">

Dis à Google et à Bing que le site existe et où se trouve le sitemap.

1. Dans Google Search Console, ajoute une propriété de type **Domaine** avec <V name="DOMAIN" />. Google affiche un enregistrement TXT qui commence par `google-site-verification=`.
2. Dans le Manager Infomaniak, Zone DNS du domaine (comme en page 4), ajoute cet enregistrement TXT sur le domaine lui-même, puis clique sur Valider dans Search Console. Ça peut prendre quelques minutes.
3. Dans Search Console → Sitemaps, saisis `sitemap.xml` et envoie.
4. Dans Bing Webmaster Tools, connecte-toi et choisis l'import depuis Google Search Console : il reprend le site et le sitemap.

```bash on="mac"
dig +short TXT ${DOMAIN} | grep google-site-verification
```

<Guided>Laisse l'enregistrement TXT dans la zone pour toujours : Google le revérifie, et le supprimer fait perdre la validation de la propriété. Une propriété Domaine couvre `https://`, `http://` et `www.` d'un coup, c'est pour ça qu'elle passe par le DNS plutôt que par un fichier sur le site.</Guided>

</When>

<When notFlag="INDEXING">

Pour l'instant, les robots sont priés de rester à l'écart. Le jour du lancement : active le choix d'indexation, mets à jour `app/robots.ts` comme la page le montre alors (ou relance le script), et lance `./scripts/deploy.sh`. Inscris ensuite le site dans Google Search Console et Bing Webmaster Tools et envoie `sitemap.xml` ; la page affiche les étapes une fois le choix activé.

</When>

<Guided>`robots.txt` est une demande, pas un contrôle d'accès. Les robots polis la respectent ; n'importe qui d'autre peut quand même récupérer chaque URL. Ce qui doit rester privé a besoin d'une connexion, ou ne doit pas être publié du tout.</Guided>

<When flag="HSTS_PRELOAD">

## Préchargement HSTS

Les navigateurs embarquent une liste de domaines accessibles en HTTPS seulement. Une fois <V name="DOMAIN" /> dessus, aucun navigateur ne fait plus jamais de requête HTTP en clair vers lui ni vers ses sous-domaines, même à la toute première visite.

<Warn>C'est un aller sans retour. Chaque sous-domaine de <V name="DOMAIN" />, actuel et futur — `mail.`, une machine de test, un service tiers derrière un CNAME — devra servir un HTTPS valide pour toujours, sinon il devient injoignable. Le retrait est possible mais prend des mois, parce qu'il suit les versions des navigateurs et que les vieux navigateurs gardent l'entrée.</Warn>

1. Liste tous les noms de la Zone DNS Infomaniak et vérifie que chacun répond en HTTPS avec un certificat valide.
2. Sur le serveur, passe la ligne `Strict-Transport-Security` de `/etc/caddy/Caddyfile` (page 5) à la valeur de préchargement, quelle que soit sa valeur d'avant, puis valide et recharge :

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

```bash on="server" as="${USERNAME}"
sudo sed -i 's/header Strict-Transport-Security "[^"]*"/header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"/' /etc/caddy/Caddyfile
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```

3. Sur hstspreload.org, saisis <V name="DOMAIN" />, corrige ce que le test d'éligibilité signale, coche les confirmations et envoie.

<Check cmd="curl -sI https://${DOMAIN}/ | grep -i '^strict-transport-security'" expect="strict-transport-security: max-age=63072000; includeSubDomains; preload" />

<Deep>Les exigences, telles que hstspreload.org les vérifie : un certificat valide, HTTP qui redirige vers HTTPS sur le même nom (Caddy le fait), et en HTTPS sur le domaine nu (<V name="DOMAIN" />, sans rien devant) un en-tête avec un max-age d'au moins un an, `includeSubDomains` et `preload`. Le statut passe à « pending », puis le domaine entre dans la liste du code source de Chromium en quelques semaines ; Firefox, Safari et Edge en dérivent leurs propres listes. Le retrait passe par hstspreload.org/removal et suit le même chemin lent en sens inverse.</Deep>

</When>

## Annoncer

Un dernier coup d'œil avant de publier le lien et de demander à tes testeurs externes d'ouvrir le site sur leur téléphone :

- **Favicon** : `/favicon.ico` répond 200 (commande ci-dessous). Next.js sert `app/favicon.ico` ou `app/icon.png`.
- **Image d'aperçu des liens** : un JPEG de 1200 × 630 en `app/opengraph-image.jpg` (Next.js ajoute les balises meta) et `metadataBase` réglé sur https://<V name="DOMAIN" /> dans le layout racine, pour que l'URL de l'image soit absolue. Exporte-la depuis les originaux de `photos-26-og/` ; eux restent hors du site. Colle le lien dans un message à toi-même pour voir l'aperçu.
- **Page 404** : mise en forme, avec un chemin vers l'accueil.
- **Fonctions en attente** : le formulaire de contact et la sauvegarde des parties d'échecs n'ont pas encore de backend ; cache-les ou marque-les « bientôt ».
- **Surveillance** : la sonde de disponibilité de la page 7 est au vert et ses alertes arrivent à <V name="ADMIN_EMAIL" />.
- **Après le lancement** : les flux RSS de `/lately` notés dans le TODO du projet peuvent attendre ; un flux ajouté la semaine prochaine marche pareil.

```bash on="mac"
curl -s -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/favicon.ico
curl -s https://${DOMAIN}/ | grep -o '<meta property="og:image"[^>]*>'
```

## Terminé

La série est complète : un site Next.js construit sur ton Mac, servi en HTTPS par Caddy depuis ton propre VPS Debian chez OVH, sur un domaine géré chez Infomaniak, déployé de façon atomique, sauvegardé, surveillé et vérifié depuis l'extérieur<When flag="INDEXING">, avec Google et Bing qui savent où trouver le sitemap</When>. Le script Automatique de cette page sert aussi de test rapide après chaque gros changement.

Une page à venir, pas encore écrite — la page 9 — donnera un backend au formulaire de contact et aux sauvegardes d'échecs sur le même VPS : un petit service Node derrière Caddy, avec SQLite.
````

````yaml title="content/ovh-vps-static-site/launch-checklist/diagram.yaml"
# Quick: your laptop, the public testers, the live site, the search engines.
# Guided: the two files crawlers read, served by the site.
# Deep: DNS and the IPv6 path, HTTP/3 on UDP 443, and the HSTS preload list when chosen.
title: { en: "The site, seen from the outside", fr: "Le site, vu de l'extérieur" }
caption:
  en: "Your laptop and a few public testers check https://${DOMAIN} the way a visitor reaches it: redirects, certificate, headers, IPv6, speed. Search engines read robots.txt and sitemap.xml from the same server."
  fr: "Ton portable et quelques testeurs publics vérifient https://${DOMAIN} comme un visiteur l'atteint : redirections, certificat, en-têtes, IPv6, vitesse. Les moteurs de recherche lisent robots.txt et sitemap.xml sur le même serveur."

groups:
  - id: vps
    label: { en: "Your VPS at OVH", fr: "Ton VPS chez OVH" }
    desc:
      en: "The server set up by pages 3 to 7. Nothing here changes on this page, except the Caddyfile header if you go for HSTS preload."
      fr: "Le serveur mis en place par les pages 3 à 7. Rien n'y change sur cette page, sauf l'en-tête du Caddyfile si tu optes pour le préchargement HSTS."

nodes:
  - id: laptop
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "curl · dig · openssl"
    desc:
      en: "Runs the launch script: builds, deploys, then checks status codes, redirects, certificate, headers and DNS, printing PASS or FAIL per line."
      fr: "Lance le script de lancement : construit, déploie, puis vérifie codes de statut, redirections, certificat, en-têtes et DNS, avec PASS ou FAIL par ligne."
    deep:
      sub: "${LOCAL_DIR} · npm run build · deploy.sh · curl / dig / openssl"
  - id: testers
    kind: cloud
    label: { en: "Public testers", fr: "Testeurs publics" }
    sub: "SSL Labs · securityheaders · Lighthouse"
    desc:
      en: "Services that test the site from their own servers: TLS grade, security headers, performance. They see what a stranger sees, without your laptop's caches."
      fr: "Des services qui testent le site depuis leurs propres serveurs : note TLS, en-têtes de sécurité, performance. Ils voient ce que voit un inconnu, sans les caches de ton portable."
    deep:
      sub: "SSL Labs · securityheaders / HTTP Observatory · PageSpeed · http3check · ipv6-test"
  - id: dns
    kind: net
    label: { en: "DNS at Infomaniak", fr: "DNS chez Infomaniak" }
    sub: "A · AAAA · TXT"
    level: deep
    desc:
      en: "The zone from page 4. The checks read A and AAAA to find the server, TXT for DMARC and SPF, and Google's verification record."
      fr: "La zone de la page 4. Les vérifications lisent A et AAAA pour trouver le serveur, TXT pour DMARC et SPF, et l'enregistrement de validation de Google."
    deep:
      sub: "A ${SERVER_IP} · AAAA ${SERVER_IPV6} · TXT _dmarc"
  - id: site
    kind: server
    label: { en: "https://${DOMAIN}", fr: "https://${DOMAIN}" }
    sub: "Caddy · static files"
    in: vps
    focus: true
    desc:
      en: "The live site: Caddy serving the current release. Everything on this page is checked against it from outside."
      fr: "Le site en ligne : Caddy qui sert la release courante. Tout ce que vérifie cette page est testé contre lui, depuis l'extérieur."
    guided:
      sub: "Caddy · 308 redirects · 404.html · HSTS"
    deep:
      sub: "Caddy · TCP 80 → 308 · TCP 443 (h2) · UDP 443 (h3) · ${WEB_ROOT}/current"
  - id: robots
    kind: file
    label: { en: "robots.txt", fr: "robots.txt" }
    sub: "from app/robots.ts"
    in: vps
    level: guided
    desc:
      en: "Tells polite crawlers what they may fetch and where the sitemap is. A request, not access control."
      fr: "Dit aux robots polis ce qu'ils peuvent récupérer et où est le sitemap. Une demande, pas un contrôle d'accès."
  - id: sitemap
    kind: file
    label: { en: "sitemap.xml", fr: "sitemap.xml" }
    sub: "from app/sitemap.ts"
    in: vps
    level: guided
    desc:
      en: "The list of canonical URLs, with trailing slashes. Private /contact/ and /chess/ codes are left out."
      fr: "La liste des URL canoniques, avec barre finale. Les codes privés de /contact/ et /chess/ en sont exclus."
  - id: engines
    kind: cloud
    label: { en: "Search engines", fr: "Moteurs de recherche" }
    sub: "Google · Bing"
    desc:
      en: "Crawl the site following robots.txt. With indexing on, Search Console and Bing Webmaster Tools receive the sitemap."
      fr: "Parcourent le site en suivant robots.txt. Avec l'indexation activée, Search Console et Bing Webmaster Tools reçoivent le sitemap."
    guided:
      sub: "Search Console · Bing Webmaster Tools"
  - id: preload
    kind: store
    label: { en: "HSTS preload list", fr: "Liste de préchargement HSTS" }
    sub: "hstspreload.org"
    level: deep
    when: { flag: HSTS_PRELOAD }
    desc:
      en: "A list compiled into Chromium and reused by other browsers: listed domains are HTTPS-only from the first visit. Getting off it takes months."
      fr: "Une liste compilée dans Chromium et reprise par les autres navigateurs : les domaines inscrits sont HTTPS seulement dès la première visite. En sortir prend des mois."

edges:
  - from: laptop
    to: testers
    label: "open"
    desc:
      en: "You open each tester with your domain in the URL; they run from their own servers."
      fr: "Tu ouvres chaque testeur avec ton domaine dans l'URL ; ils tournent depuis leurs propres serveurs."
  - from: testers
    to: site
    label: "scan"
    deep: { label: "TLS handshake · headers · page load · h3 probe" }
    desc:
      en: "The testers connect as a stranger would: full TLS handshake, headers, a real page load on a throttled phone profile."
      fr: "Les testeurs se connectent comme un inconnu : poignée de main TLS complète, en-têtes, vrai chargement de page avec un profil de téléphone bridé."
  - from: laptop
    to: site
    label: "curl · openssl"
    guided: { label: "http → 308 · www → 308 · 404 · cert" }
    deep: { label: "TCP 80/443 · IPv4 + IPv6 (curl -6) · SNI ${DOMAIN}" }
    desc:
      en: "The launch script's checks: status codes, redirect targets, HSTS header, certificate issuer."
      fr: "Les vérifications du script de lancement : codes de statut, cibles des redirections, en-tête HSTS, émetteur du certificat."
  - from: laptop
    to: dns
    label: "dig"
    dashed: true
    level: deep
    desc:
      en: "dig asks for A, AAAA and TXT records; the AAAA must be ${SERVER_IPV6}."
      fr: "dig demande les enregistrements A, AAAA et TXT ; l'AAAA doit valoir ${SERVER_IPV6}."
  - from: dns
    to: site
    label: "IPv6 path"
    dashed: true
    level: deep
    desc:
      en: "IPv6 visitors reach the server through the AAAA record. A wrong AAAA means timeouts for them only, which is why it is tested from outside."
      fr: "Les visiteurs IPv6 atteignent le serveur par l'enregistrement AAAA. Un AAAA faux, c'est des délais d'attente pour eux seuls : d'où le test depuis l'extérieur."
  - from: engines
    to: site
    label: "crawl"
    max: quick
    desc:
      en: "Crawlers fetch robots.txt first, then the sitemap, then the pages."
      fr: "Les robots récupèrent d'abord robots.txt, puis le sitemap, puis les pages."
  - from: engines
    to: robots
    label: "reads first"
    level: guided
    desc:
      en: "Every polite crawler fetches /robots.txt before anything else and follows its rules."
      fr: "Tout robot poli récupère /robots.txt avant le reste et suit ses règles."
  - from: engines
    to: sitemap
    label: "reads"
    level: guided
    desc:
      en: "The sitemap tells crawlers which URLs exist, so pages with few links get found too."
      fr: "Le sitemap dit aux robots quelles URL existent, pour que les pages peu liées soient trouvées aussi."
  - from: robots
    to: site
    label: "served by"
    dashed: true
    level: guided
    desc:
      en: "Both files are plain files in out/, served by Caddy like any page."
      fr: "Les deux fichiers sont des fichiers ordinaires dans out/, servis par Caddy comme n'importe quelle page."
  - from: sitemap
    to: site
    label: "served by"
    dashed: true
    level: guided
    desc:
      en: "Generated at build time from app/sitemap.ts, deployed with the rest of the release."
      fr: "Généré au build à partir de app/sitemap.ts, déployé avec le reste de la release."
  - from: site
    to: preload
    label: "submitted"
    dashed: true
    level: deep
    when: { flag: HSTS_PRELOAD }
    desc:
      en: "Once the header carries includeSubDomains and preload, you submit the domain on hstspreload.org; browsers pick it up in later releases."
      fr: "Une fois que l'en-tête porte includeSubDomains et preload, tu inscris le domaine sur hstspreload.org ; les navigateurs le prennent dans leurs versions suivantes."
````

---

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