# Source of "Serve the site with Caddy and HTTPS"

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/serve-with-caddy/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 that change the Caddyfile: how long browsers remember HTTPS-only, and whether access logs keep full IPs.
title:
  en: Serve the site with Caddy and HTTPS
  fr: Servir le site avec Caddy et HTTPS
summary:
  en: >-
    Caddy installed from its official repository, serving a placeholder page from the web root
    over HTTPS with a Let's Encrypt certificate it renews by itself. www and plain HTTP redirect to
    the bare domain (nothing in front), HTTP/3 is on, security headers and caching are set, access logs are rotated.
  fr: >-
    Caddy installé depuis son dépôt officiel, qui sert une page d'attente depuis la racine web en
    HTTPS, avec un certificat Let's Encrypt qu'il renouvelle tout seul. www et le HTTP simple
    redirigent vers le domaine nu (sans rien devant), HTTP/3 est actif, les en-têtes de sécurité et le cache sont
    réglés, les logs d'accès tournent.
difficulty: intermediate
tags: [caddy, https, lets-encrypt, web-server, debian]
authors: [thudal]
created: 2026-09-26
minutes: 20
validated: Caddy 2.11 · Debian 13 (trixie)
status: draft             # not yet run end to end by its author

choices:
  - key: HSTS
    type: select
    label: { en: HSTS (browsers remember HTTPS-only), fr: HSTS (les navigateurs retiennent le HTTPS obligatoire) }
    hint:
      en: How long a browser that visited once refuses plain HTTP for your domain. Start short while you test, move to a year once everything works.
      fr: Combien de temps un navigateur déjà venu refuse le HTTP simple pour ton domaine. Commence court pendant les tests, passe à un an quand tout marche.
    default: short
    options:
      - { value: "off", label: { en: Off (max-age=0), fr: Désactivé (max-age=0) } }
      - { value: short, label: { en: 1 day while testing, fr: 1 jour pendant les tests } }
      - { value: year, label: { en: 1 year, fr: 1 an } }
  - key: ANON_LOGS
    type: boolean
    label: { en: Mask visitor IPs in access logs, fr: Masquer les IP des visiteurs dans les logs }
    hint:
      en: Keeps only the first half of each IPv4 address (/16) and the first 32 bits of IPv6. Enough to spot abuse by network, not to identify a person. Fits a site that says nothing is tracked.
      fr: Ne garde que la première moitié de chaque IPv4 (/16) et les 32 premiers bits d'une IPv6. Assez pour repérer un abus par réseau, pas pour identifier une personne. Cohérent avec un site qui dit ne rien suivre.
    default: true
````

````mdx title="content/ovh-vps-static-site/serve-with-caddy/page-en.mdx"
{/* First pass — to be validated against https://caddyserver.com/docs/install, https://caddyserver.com/docs/caddyfile and https://caddyserver.com/docs/automatic-https before publishing. */}

The server is locked down and the domain points at it. This page puts a web server in front: it opens the web ports, installs Caddy from its official repository, creates the web root with a placeholder page, writes one Caddyfile, and checks from your laptop that `https://`<V name="DOMAIN" /> answers with a valid certificate. The next page replaces the placeholder with the real site.

<Run>

The script does the whole page for **<V name="DOMAIN" />**. On the server (`ssh vps`), open a new file `serve-with-caddy.sh` with <V name="EDITOR" />, paste every code block of this section into it in order, and run it there, logged in as <V name="USERNAME" />, with `sudo bash serve-with-caddy.sh`: sudo asks your password once, the script runs as root and never prompts again. Pages 3 and 4 must be done, and the DNS records must already answer with the server's addresses.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Serve with Caddy — https://${DOMAIN}
[ "$(id -u)" -eq 0 ] || { echo 'Run me with: sudo bash serve-with-caddy.sh'; exit 1; }

# 1. Web ports: 80 for ACME and the redirect, 443 TCP (HTTP/1.1, HTTP/2), 443 UDP (HTTP/3)
for rule in 80/tcp 443/tcp 443/udp; do ufw allow $rule; done

# 2. Caddy from the official repository
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y caddy

# 3. Web root with a placeholder release (current is left alone if it already exists)
install -d -m 755 ${WEB_ROOT}/releases/placeholder
echo '<!doctype html><meta charset="utf-8"><title>${DOMAIN}</title><p>${DOMAIN} is being set up. Back soon.</p>' > ${WEB_ROOT}/releases/placeholder/index.html
echo '<!doctype html><meta charset="utf-8"><title>Not found</title><p>404: nothing here.</p>' > ${WEB_ROOT}/releases/placeholder/404.html
[ -e ${WEB_ROOT}/current ] || ln -s releases/placeholder ${WEB_ROOT}/current
```

<When is="HSTS" equals="off">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>
<When is="HSTS" equals="short">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>
<When is="HSTS" equals="year">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. The Caddyfile, complete, indented with spaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>

```bash on="server" as="${USERNAME}"
# 5. Validate as the caddy user, reload, wait for the certificate
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
systemctl reload-or-restart caddy
for i in $(seq 1 45); do
  curl -sf -o /dev/null --resolve ${DOMAIN}:443:127.0.0.1 https://${DOMAIN}/ && break || sleep 2
done
curl -sf -o /dev/null --resolve ${DOMAIN}:443:127.0.0.1 https://${DOMAIN}/ \
  || { echo 'No valid HTTPS yet. Read: journalctl -u caddy -n 50 --no-pager'; exit 1; }
echo "Done: https://${DOMAIN} answers with a valid certificate."
```

</Run>

## Before you start

<Guided>You need pages 3 and 4 done: you log in with `ssh vps`, sudo asks your password, and the DNS records for <V name="DOMAIN" /> and `www.`<V name="DOMAIN" /> point at the server. Each block says where to type it: on the server, logged in as <V name="USERNAME" /> (`ssh vps`), or on your Mac. Check DNS from your laptop first: Let's Encrypt will look the name up the same way.</Guided>

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

## Open the web ports

```bash on="server" as="${USERNAME}"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
```

<Guided>Port 443 over TCP carries HTTPS (HTTP/1.1 and HTTP/2); 443 over UDP carries HTTP/3, which runs on QUIC. Port 80 stays open even though the site is HTTPS-only: Let's Encrypt's HTTP-01 challenge comes in on 80, and visitors who type the bare domain land there before being redirected. `sudo ufw status` should now list the three rules next to your SSH port.</Guided>

<Deep>ufw applies each rule to IPv4 and IPv6 (`IPV6=yes` in `/etc/default/ufw` is the Debian default), so you see each port twice in `ufw status`. If you enabled OVH's optional Network Firewall in the control panel, it sits in front of the VPS and drops traffic before ufw ever sees it: allow TCP 80, TCP 443 and UDP 443 there as well.</Deep>

## Install Caddy

```bash on="server" as="${USERNAME}"
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
```

<Guided>The first line installs what apt needs to trust a third-party repository (`gnupg` is added to the official list because minimal images sometimes lack it). The next lines fetch the Caddy signing key, add the repository, and make both readable by apt. The package starts Caddy right away with a default config that serves a welcome page on port 80; you replace that config two steps down.</Guided>

<Deep>Debian ships its own `caddy` package, but it follows Debian's release cycle and lags upstream by months; the Cloudsmith repository is maintained by the Caddy project and follows every release, and `apt upgrade` keeps it current. The package creates a system user `caddy` (home `/var/lib/caddy`, no shell), a systemd unit `caddy.service` that runs `caddy run --config /etc/caddy/Caddyfile` as that user, with just the capabilities to bind ports below 1024 and to size network buffers for QUIC, and `/var/log/caddy` owned by `caddy`. Certificates and ACME account keys live under `/var/lib/caddy/.local/share/caddy`: back that directory up (page 7), or a rebuilt server has to request everything again.</Deep>

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

## Create the web root and a placeholder

<Warn>Run `ln -sfn` here only on a fresh server. Once page 6 has deployed, this line would put the placeholder back online in place of your site.</Warn>

```bash on="server" as="${USERNAME}"
sudo install -d -m 755 ${WEB_ROOT}/releases/placeholder
echo '<!doctype html><meta charset="utf-8"><title>${DOMAIN}</title><p>${DOMAIN} is being set up. Back soon.</p>' | sudo tee ${WEB_ROOT}/releases/placeholder/index.html
echo '<!doctype html><meta charset="utf-8"><title>Not found</title><p>404: nothing here.</p>' | sudo tee ${WEB_ROOT}/releases/placeholder/404.html
sudo ln -sfn releases/placeholder ${WEB_ROOT}/current
```

<Guided>Every version of the site will be a directory under `releases/`, and `current` is a symbolic link to the one that is live. Caddy only ever serves `current`. Deploying means uploading a new release next to the old one, then moving the link in one operation; rolling back means pointing the link at the previous directory. The placeholder is release zero, so Caddy has something real to serve while you set up HTTPS. Page 6 hands the whole tree to <V name="DEPLOY_USER" /> and fills it.</Guided>

<Deep>The link is relative (`releases/placeholder`, not an absolute path), so the tree can be moved or restored elsewhere without breaking. Caddy does not cache the link: `file_server` opens `current/...` on each request and the kernel resolves the symlink at that moment, so a swap takes effect on the next request with no reload. Requests already in flight finish on the old release, which is why old releases are kept for a while instead of deleted immediately.</Deep>

## Write the Caddyfile

The block below is the whole of `/etc/caddy/Caddyfile`, already set for the two choices of this page, HSTS and IP masking in the logs: change them in the panel and the file follows. Copy the command and paste it in the terminal. It replaces the default file in one go; there is nothing to add to it afterwards.

<When is="HSTS" equals="off">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>
<When is="HSTS" equals="short">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>
<When is="HSTS" equals="year">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>

<Guided>The file is indented with spaces, not tabs: a tab pasted into a terminal can trigger completion and garble the paste. Caddy reads both the same way.</Guided>

<Guided>

What each part of the file does, top to bottom:

1. **Global options**, the first `{ … }`. The email goes to the ACME account: Let's Encrypt writes there if a certificate cannot be renewed. No other TLS setting is needed; naming a domain as a site address is what turns on automatic HTTPS.
2. **`www.`<V name="DOMAIN" />** gets its own certificate and a permanent redirect (`permanent` means 301) to the bare name, <V name="DOMAIN" />, nothing in front, keeping the path and query string. `{uri}` is a Caddy placeholder, filled per request. Plain HTTP needs no line: Caddy redirects it to HTTPS by itself, with a 308.
3. **`root`**: serve files from the `current` symlink. The `*` means "for every request".
4. **`encode`**: compress text responses on the fly, zstd for browsers that accept it, gzip otherwise. Images and video are already compressed and are skipped.
5. **`file_server`**: the static file server. It serves `dir/index.html` for `/dir/`, and redirects `/dir` to `/dir/`, which is exactly the URL shape of a Next.js export with `trailingSlash: true`.
6. **`handle_errors 404`**: on a 404, serve the site's own `404.html` with the 404 status kept. The `404` argument limits this block to not-found errors.
7. **`header { … }`**: stop browsers from guessing content types, send only the origin to other sites, refuse to be framed, and deny camera, microphone and location to the page.
8. **`-Server`**: remove the `Server: Caddy` header. It hides nothing serious, but it is one less line for scanners to match.
9. **`@static`**: Next.js puts a content hash in every file name under `/_next/static/`, so a file there never changes: browsers may keep it a year without asking again.
10. **`@media`**: media files keep their names across deploys, so they get one week, not forever.
11. **`Strict-Transport-Security`**: your HSTS choice, explained just below.
12. **`log`**: one JSON line per request, rotated at 10 MiB, ten old files kept and gzipped. Errors and certificate events stay in the journal.

</Guided>

<Deep>

There is no `try_files` on purpose. A common copy-paste adds `try_files {path} {path}/ /index.html`, which is for single-page apps: here it would serve the home page with a 200 for every missing URL, and make `/about` and `/about/` both answer, which search engines see as duplicate pages. `file_server` alone already maps `/about/` to `about/index.html` and turns missing files into a 404, which `handle_errors` then dresses.

One side effect to know: the `header { … }` block removes `Server`, which makes Caddy apply the whole block when the response is written, and the 404 page is written by the `handle_errors` route instead. So 404 responses carry HSTS and the cache rules but not the four security headers, and still say `Server: Caddy`. Harmless for a static page; copy the `header` block inside `handle_errors` if you want them identical. The cache rules apply to a 404 too: a missing file under `/_next/static/` is answered "immutable", which is fine because a hashed name never comes back with other content.

</Deep>

<Note>`handle_errors` with a status code argument needs Caddy 2.8 or later; the repository installs a newer one. On an older Caddy, drop the `404` and the block handles every error.</Note>

**HSTS.** The `Strict-Transport-Security` line of the file follows your choice.

<When is="HSTS" equals="off">

<Guided>`max-age=0` is "off" said explicitly: a browser that received a longer HSTS policy earlier forgets it on the next visit. Without the line, a browser that ever saw one would keep enforcing it.</Guided>

</When>

<When is="HSTS" equals="short">

<Guided>For one day after each visit, the browser rewrites any `http://` link to your domain into `https://` before sending anything, and refuses to let the visitor click through a certificate error. One day is short enough to undo a mistake. Move to a year once page 8 is done.</Guided>

</When>

<When is="HSTS" equals="year">

<Warn>With a year, every browser that visits once will refuse plain HTTP and certificate errors on <V name="DOMAIN" /> for twelve months. If HTTPS breaks, those visitors cannot get in until it is fixed. Set it only once HTTPS has worked for a few days.</Warn>

</When>

<Warn>No `includeSubDomains` and no `preload` here. Preload (hstspreload.org) bakes your domain into browsers and requires both; it is a one-way door, since removal takes months and depends on browser releases. Add it only if every subdomain, forever, will serve valid HTTPS.</Warn>

<When flag="ANON_LOGS">

<Guided>The `format filter` part of the `log` block cuts each address before it is written: `203.0.113.45` becomes `203.0.0.0`, and an IPv6 keeps its first 32 bits. You still see which networks hammer the site, never who a visitor is. Nothing unmasked touches the disk.</Guided>

<Note>The `format filter` syntax changed across Caddy versions: older ones required `wrap json` and a `fields { }` block around the filters. If `caddy validate` complains, compare with caddyserver.com/docs/caddyfile/directives/log.</Note>

</When>

<When notFlag="ANON_LOGS">

You chose full addresses: each line of the access log records the visitor's complete IP.

<Note>A full IP is personal data under the GDPR. Keeping it for security is defensible; say so in your legal notice, and keep rotation short.</Note>

</When>

## Validate and reload

<Warn>Validate with `sudo -u caddy`, exactly as below, never as root: as root, validation creates the log file owned by root, and Caddy can no longer write it after the reload.</Warn>

```bash on="server" as="${USERNAME}"
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -f
```

<Guided>`validate` parses the file and sets up every module without serving, and ends with `Valid configuration`. Before that it may print a warning, `Caddyfile input is not formatted`: Caddy's own style indents with tabs and this file uses spaces. It is harmless; leave the file as it is. `reload` hands the new file to the running Caddy with no dropped connections; if the file is broken, the reload fails and the old configuration keeps serving. In the journal, wait for `certificate obtained successfully` for <V name="DOMAIN" /> and for `www.`<V name="DOMAIN" />, usually within 30 seconds, then stop following with Ctrl+C.</Guided>

<Deep>Validate as the `caddy` user, not as root and not as yourself. Validation opens the log file: as root it would create `/var/log/caddy/access.log` owned by root with mode 600, and the service, running as `caddy`, would then fail to open it on reload; as <V name="USERNAME" /> it fails with "permission denied". Running as `caddy` also checks what the service will actually be allowed to read.</Deep>

<Details summary="If the certificate fails">

Read the error line in the journal first; it names the cause. These lookups, from your laptop, cover the DNS side. By likelihood:

```bash on="mac"
dig +short A ${DOMAIN}
dig +short A www.${DOMAIN}
dig +short AAAA ${DOMAIN}
dig +short CAA ${DOMAIN}
```

- **DNS**: both A lookups must answer <V name="SERVER_IP" />. Right after a change, wait for the old TTL to expire.
- **Wrong AAAA**: if the AAAA lookup answers anything, it must be <V name="SERVER_IPV6" />. Let's Encrypt tries IPv6 first, so a stale AAAA fails validation even when IPv4 is perfect. No AAAA at all is fine.
- **Port 80 closed**: `sudo ufw status` must show `80/tcp ALLOW`; if OVH's Network Firewall is on, open 80 there too.
- **CAA**: the CAA lookup must be empty or contain `letsencrypt.org`. When Let's Encrypt fails, Caddy falls back to ZeroSSL, whose CAA identifier is `sectigo.com`.
- **Rate limits**: Let's Encrypt allows 5 failed validations per hostname per hour. While you debug, point Caddy at the staging CA: open the file by hand on the server,

```bash on="server" as="${USERNAME}"
sudo ${EDITOR} /etc/caddy/Caddyfile
```

and add this line inside the global options block at the top, under `email`, then validate and reload as above:

```text
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
```

Staging certificates are not trusted by browsers, so curl will complain; that is expected. Once the journal shows success, paste the Caddyfile command of the previous step again (it rewrites the file without the line), then validate and reload: Caddy stores certificates per CA and requests a real one.

</Details>

## Check it from your laptop

<Check cmd="curl -sI https://${DOMAIN}/ | head -1" expect="HTTP/2 200" />

<Check cmd="curl -sI https://www.${DOMAIN}/ | head -1" expect="HTTP/2 301" />

<Check cmd="curl -sI https://www.${DOMAIN}/ | grep -i '^location'" expect="location: https://${DOMAIN}/" />

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

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

<Guided>The first answer proves the certificate is valid (curl would refuse it otherwise) and that HTTP/2 is negotiated. HTML pages get no `Cache-Control`, so browsers revalidate them and a deploy shows on the next load. The next two prove the www redirect: a 301 to `https://`<V name="DOMAIN" />. The fourth proves missing pages get a real 404, not a 200. The last one counts the `strict-transport-security` header: 1 means your HSTS line is served. Open `http://`<V name="DOMAIN" /> in a browser too: it lands on `https://` without a warning.</Guided>

<Deep>

HTTP/3 is on by default: Caddy announces it in an `Alt-Svc` header, and browsers switch to QUIC on the next request.

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

The first should show `h3=":443"`. The second works only if your curl was built with HTTP/3 (`curl -V` lists `HTTP3` under Features); the macOS system curl was not, as far as we know.

There is no Content-Security-Policy yet. A Next.js export includes inline scripts for hydration, so a strict CSP needs either per-build hashes or `'unsafe-inline'`, and a wrong CSP breaks the site silently. When you add one, start with `Content-Security-Policy-Report-Only` in the `header` block, watch the browser console for a while, then switch to enforcing.

</Deep>

## Done

<V name="DOMAIN" /> now answers over HTTPS with a Let's Encrypt certificate that Caddy renews on its own, well before expiry. `www` and plain HTTP redirect to `https://`<V name="DOMAIN" />, HTTP/3 is available, static assets are cached hard, and the access log rotates itself<When flag="ANON_LOGS"> with masked addresses</When>. Visitors see the placeholder.

The next page, **Deploy atomic releases with rsync**, builds the Next.js site on your laptop, uploads it as a new release under <V name="WEB_ROOT" />, and swaps the `current` link to put it live.
````

````mdx title="content/ovh-vps-static-site/serve-with-caddy/page-fr.mdx"
{/* Premier jet — à valider contre https://caddyserver.com/docs/install, https://caddyserver.com/docs/caddyfile et https://caddyserver.com/docs/automatic-https avant publication. */}

Le serveur est verrouillé et le domaine pointe dessus. Cette page met un serveur web devant : elle ouvre les ports web, installe Caddy depuis son dépôt officiel, crée la racine web avec une page d'attente, écrit un seul Caddyfile, et vérifie depuis ton portable que `https://`<V name="DOMAIN" /> répond avec un certificat valide. La page suivante remplace la page d'attente par le vrai site.

<Run>

Le script fait toute la page pour **<V name="DOMAIN" />**. Sur le serveur (`ssh vps`), ouvre un nouveau fichier `serve-with-caddy.sh` avec <V name="EDITOR" />, colles-y tous les blocs de code de cette section dans l'ordre, et lance-le là-bas, connecté en <V name="USERNAME" />, avec `sudo bash serve-with-caddy.sh` : sudo demande ton mot de passe une fois, le script tourne en root et ne pose plus aucune question. Les pages 3 et 4 doivent être faites, et le DNS doit déjà répondre avec les adresses du serveur.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Servir avec Caddy — https://${DOMAIN}
[ "$(id -u)" -eq 0 ] || { echo 'Lance-moi avec : sudo bash serve-with-caddy.sh'; exit 1; }

# 1. Ports web : 80 pour ACME et la redirection, 443 TCP (HTTP/1.1, HTTP/2), 443 UDP (HTTP/3)
for rule in 80/tcp 443/tcp 443/udp; do ufw allow $rule; done

# 2. Caddy depuis le dépôt officiel
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y caddy

# 3. Racine web avec une release d'attente (current n'est pas touché s'il existe déjà)
install -d -m 755 ${WEB_ROOT}/releases/placeholder
echo '<!doctype html><meta charset="utf-8"><title>${DOMAIN}</title><p>${DOMAIN} est en cours de mise en place. Revenez bientôt.</p>' > ${WEB_ROOT}/releases/placeholder/index.html
echo '<!doctype html><meta charset="utf-8"><title>Introuvable</title><p>404 : rien ici.</p>' > ${WEB_ROOT}/releases/placeholder/404.html
[ -e ${WEB_ROOT}/current ] || ln -s releases/placeholder ${WEB_ROOT}/current
```

<When is="HSTS" equals="off">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>
<When is="HSTS" equals="short">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>
<When is="HSTS" equals="year">

<When flag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
EOF
```

</When>

<When notFlag="ANON_LOGS">

```bash on="server" as="${USERNAME}"
# 4. Le Caddyfile, complet, indenté avec des espaces
cat > /etc/caddy/Caddyfile <<'EOF'
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
EOF
```

</When>

</When>

```bash on="server" as="${USERNAME}"
# 5. Valider en tant qu'utilisateur caddy, recharger, attendre le certificat
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
systemctl reload-or-restart caddy
for i in $(seq 1 45); do
  curl -sf -o /dev/null --resolve ${DOMAIN}:443:127.0.0.1 https://${DOMAIN}/ && break || sleep 2
done
curl -sf -o /dev/null --resolve ${DOMAIN}:443:127.0.0.1 https://${DOMAIN}/ \
  || { echo 'Pas encore de HTTPS valide. Lis : journalctl -u caddy -n 50 --no-pager'; exit 1; }
echo "Terminé : https://${DOMAIN} répond avec un certificat valide."
```

</Run>

## Avant de commencer

<Guided>Il te faut les pages 3 et 4 : tu te connectes avec `ssh vps`, sudo te demande ton mot de passe, et les enregistrements DNS de <V name="DOMAIN" /> et de `www.`<V name="DOMAIN" /> pointent sur le serveur. Chaque bloc dit où le taper : sur le serveur, connecté en <V name="USERNAME" /> (`ssh vps`), ou sur ton Mac. Vérifie d'abord le DNS depuis ton portable : Let's Encrypt résoudra le nom de la même façon.</Guided>

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

## Ouvrir les ports web

```bash on="server" as="${USERNAME}"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
```

<Guided>Le port 443 en TCP transporte le HTTPS (HTTP/1.1 et HTTP/2) ; le 443 en UDP transporte HTTP/3, qui roule sur QUIC. Le port 80 reste ouvert même si le site est tout en HTTPS : le défi HTTP-01 de Let's Encrypt arrive par le 80, et le visiteur qui tape le domaine nu y atterrit avant d'être redirigé. `sudo ufw status` doit maintenant lister les trois règles à côté de ton port SSH.</Guided>

<Deep>ufw applique chaque règle en IPv4 et en IPv6 (`IPV6=yes` dans `/etc/default/ufw` est le défaut sous Debian), d'où chaque port listé deux fois dans `ufw status`. Si tu as activé le Network Firewall optionnel d'OVH dans l'espace client, il est placé devant le VPS et jette le trafic avant même que ufw le voie : autorise aussi TCP 80, TCP 443 et UDP 443 là-bas.</Deep>

## Installer Caddy

```bash on="server" as="${USERNAME}"
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
```

<Guided>La première ligne installe ce dont apt a besoin pour faire confiance à un dépôt tiers (`gnupg` s'ajoute à la liste officielle, parce que les images minimales ne l'ont pas toujours). Les suivantes récupèrent la clé de signature de Caddy, ajoutent le dépôt, et rendent les deux lisibles par apt. Le paquet démarre Caddy tout de suite avec une config par défaut qui sert une page d'accueil sur le port 80 ; tu la remplaces deux étapes plus bas.</Guided>

<Deep>Debian a son propre paquet `caddy`, mais il suit le cycle de Debian et accuse des mois de retard sur l'amont ; le dépôt Cloudsmith est tenu par le projet Caddy, suit chaque version, et `apt upgrade` le garde à jour. Le paquet crée un utilisateur système `caddy` (home `/var/lib/caddy`, pas de shell), une unité systemd `caddy.service` qui lance `caddy run --config /etc/caddy/Caddyfile` sous cet utilisateur, avec juste les capacités d'écouter sous le port 1024 et de dimensionner les tampons réseau pour QUIC, et `/var/log/caddy` qui appartient à `caddy`. Les certificats et les clés du compte ACME vivent sous `/var/lib/caddy/.local/share/caddy` : sauvegarde ce répertoire (page 7), sinon un serveur reconstruit doit tout redemander.</Deep>

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

## Créer la racine web et une page d'attente

<Warn>Ne lance `ln -sfn` ici que sur un serveur neuf. Une fois que la page 6 a déployé, cette ligne remettrait la page d'attente en ligne à la place de ton site.</Warn>

```bash on="server" as="${USERNAME}"
sudo install -d -m 755 ${WEB_ROOT}/releases/placeholder
echo '<!doctype html><meta charset="utf-8"><title>${DOMAIN}</title><p>${DOMAIN} est en cours de mise en place. Revenez bientôt.</p>' | sudo tee ${WEB_ROOT}/releases/placeholder/index.html
echo '<!doctype html><meta charset="utf-8"><title>Introuvable</title><p>404 : rien ici.</p>' | sudo tee ${WEB_ROOT}/releases/placeholder/404.html
sudo ln -sfn releases/placeholder ${WEB_ROOT}/current
```

<Guided>Chaque version du site sera un répertoire sous `releases/`, et `current` est un lien symbolique vers celle qui est en ligne. Caddy ne sert jamais que `current`. Déployer, c'est envoyer une nouvelle release à côté de l'ancienne, puis déplacer le lien en une seule opération ; revenir en arrière, c'est pointer le lien sur le répertoire précédent. La page d'attente est la release zéro : Caddy a quelque chose de réel à servir pendant que tu mets en place le HTTPS. La page 6 confie toute l'arborescence à <V name="DEPLOY_USER" /> et la remplit.</Guided>

<Deep>Le lien est relatif (`releases/placeholder`, pas un chemin absolu) : l'arborescence peut être déplacée ou restaurée ailleurs sans casser. Caddy ne met pas le lien en cache : `file_server` ouvre `current/...` à chaque requête et le noyau résout le lien à ce moment-là, donc une bascule prend effet dès la requête suivante, sans rechargement. Les requêtes déjà en cours se terminent sur l'ancienne release, c'est pourquoi on garde les anciennes releases un moment au lieu de les effacer tout de suite.</Deep>

## Écrire le Caddyfile

Le bloc ci-dessous est le `/etc/caddy/Caddyfile` en entier, déjà réglé selon les deux choix de cette page, HSTS et masquage des IP dans les logs : change-les dans le panneau et le fichier suit. Copie la commande et colle-la dans le terminal. Elle remplace le fichier par défaut d'un coup ; il n'y a rien à y ajouter ensuite.

<When is="HSTS" equals="off">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=0"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>
<When is="HSTS" equals="short">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=86400"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>
<When is="HSTS" equals="year">

<When flag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
        format filter {
            request>remote_ip ip_mask 16 32
            request>client_ip ip_mask 16 32
        }
    }
}
```

</When>

<When notFlag="ANON_LOGS">

```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}"
{
    email ${ADMIN_EMAIL}
}

www.${DOMAIN} {
    redir https://${DOMAIN}{uri} permanent
}

${DOMAIN} {
    root * ${WEB_ROOT}/current
    encode zstd gzip
    file_server

    handle_errors 404 {
        rewrite * /404.html
        file_server
    }

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        X-Frame-Options "DENY"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }

    @static path /_next/static/*
    header @static Cache-Control "public, max-age=31536000, immutable"

    @media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
    header @media Cache-Control "public, max-age=604800"

    header Strict-Transport-Security "max-age=31536000"

    log {
        output file /var/log/caddy/access.log {
            roll_size 10MiB
            roll_keep 10
        }
    }
}
```

</When>

</When>

<Guided>Le fichier est indenté avec des espaces, pas des tabulations : une tabulation collée dans un terminal peut déclencher la complétion et abîmer le collage. Caddy lit les deux de la même façon.</Guided>

<Guided>

Ce que fait chaque partie du fichier, de haut en bas :

1. **Options globales**, le premier `{ … }`. L'email va au compte ACME : Let's Encrypt y écrit si un certificat ne peut pas être renouvelé. Aucun autre réglage TLS n'est nécessaire ; c'est le fait de nommer un domaine comme adresse de site qui active le HTTPS automatique.
2. **`www.`<V name="DOMAIN" />** reçoit son propre certificat et une redirection permanente (`permanent` veut dire 301) vers le nom nu, <V name="DOMAIN" />, sans rien devant, en gardant le chemin et la query string. `{uri}` est un placeholder de Caddy, rempli à chaque requête. Le HTTP simple n'a besoin d'aucune ligne : Caddy le redirige tout seul vers HTTPS, en 308.
3. **`root`** : servir les fichiers depuis le lien `current`. Le `*` veut dire « pour toutes les requêtes ».
4. **`encode`** : compresser les réponses texte à la volée, en zstd pour les navigateurs qui l'acceptent, en gzip sinon. Les images et vidéos sont déjà compressées et sont laissées telles quelles.
5. **`file_server`** : le serveur de fichiers statiques. Il sert `dir/index.html` pour `/dir/`, et redirige `/dir` vers `/dir/`, exactement la forme d'URL d'un export Next.js avec `trailingSlash: true`.
6. **`handle_errors 404`** : sur une 404, servir le `404.html` du site en gardant le statut 404. L'argument `404` limite ce bloc aux erreurs « introuvable ».
7. **`header { … }`** : empêcher les navigateurs de deviner les types de contenu, n'envoyer que l'origine aux autres sites, refuser d'être affiché dans un cadre, et refuser caméra, micro et localisation à la page.
8. **`-Server`** : retirer l'en-tête `Server: Caddy`. Il ne cache rien de grave, mais c'est une ligne de moins à reconnaître pour les scanners.
9. **`@static`** : Next.js met une empreinte du contenu dans chaque nom de fichier sous `/_next/static/`, donc un fichier là-dedans ne change jamais : les navigateurs peuvent le garder un an sans redemander.
10. **`@media`** : les médias gardent leur nom d'un déploiement à l'autre, donc une semaine, pas une éternité.
11. **`Strict-Transport-Security`** : ton choix HSTS, expliqué juste en dessous.
12. **`log`** : une ligne JSON par requête, rotation à 10 Mio, dix anciens fichiers gardés et compressés en gzip. Les erreurs et les événements de certificat restent dans le journal.

</Guided>

<Deep>

Pas de `try_files`, exprès. Un copier-coller courant ajoute `try_files {path} {path}/ /index.html`, qui est fait pour les applications monopage : ici, ça servirait la page d'accueil avec un 200 pour chaque URL manquante, et ferait répondre `/about` comme `/about/`, ce que les moteurs de recherche voient comme des pages en double. `file_server` seul fait déjà correspondre `/about/` à `about/index.html` et transforme les fichiers manquants en 404, que `handle_errors` habille ensuite.

Un effet de bord à connaître : le bloc `header { … }` retire `Server`, ce qui pousse Caddy à appliquer tout le bloc au moment d'écrire la réponse, or la page 404 est écrite par la route `handle_errors`. Les réponses 404 portent donc HSTS et les règles de cache, mais pas les quatre en-têtes de sécurité, et disent encore `Server: Caddy`. Sans gravité pour une page statique ; recopie le bloc `header` dans `handle_errors` si tu les veux identiques. Les règles de cache s'appliquent aussi à une 404 : un fichier manquant sous `/_next/static/` est déclaré « immutable », ce qui ne pose pas de problème puisqu'un nom avec empreinte ne revient jamais avec un autre contenu.

</Deep>

<Note>`handle_errors` avec un code de statut en argument demande Caddy 2.8 ou plus ; le dépôt installe une version plus récente. Sur un Caddy plus ancien, retire le `404` et le bloc traite toutes les erreurs.</Note>

**HSTS.** La ligne `Strict-Transport-Security` du fichier suit ton choix.

<When is="HSTS" equals="off">

<Guided>`max-age=0`, c'est « désactivé » dit explicitement : un navigateur qui a reçu une politique HSTS plus longue auparavant l'oublie à la visite suivante. Sans la ligne, un navigateur qui en a vu une continuerait de l'appliquer.</Guided>

</When>

<When is="HSTS" equals="short">

<Guided>Pendant un jour après chaque visite, le navigateur réécrit tout lien `http://` vers ton domaine en `https://` avant d'envoyer quoi que ce soit, et interdit au visiteur de passer outre une erreur de certificat. Un jour, c'est assez court pour rattraper une erreur. Passe à un an une fois la page 8 terminée.</Guided>

</When>

<When is="HSTS" equals="year">

<Warn>Avec un an, chaque navigateur venu une seule fois refusera le HTTP simple et les erreurs de certificat sur <V name="DOMAIN" /> pendant douze mois. Si le HTTPS casse, ces visiteurs ne peuvent plus entrer tant que ce n'est pas réparé. Ne le mets qu'une fois que le HTTPS a tenu quelques jours.</Warn>

</When>

<Warn>Pas de `includeSubDomains` ni de `preload` ici. Le preload (hstspreload.org) inscrit ton domaine en dur dans les navigateurs et exige les deux ; c'est une porte à sens unique, car en sortir prend des mois et dépend des versions des navigateurs. Ne l'ajoute que si chaque sous-domaine, pour toujours, servira un HTTPS valide.</Warn>

<When flag="ANON_LOGS">

<Guided>La partie `format filter` du bloc `log` tronque chaque adresse avant d'être écrite : `203.0.113.45` devient `203.0.0.0`, et une IPv6 garde ses 32 premiers bits. Tu vois encore quels réseaux martèlent le site, jamais qui est un visiteur. Rien de non masqué ne touche le disque.</Guided>

<Note>La syntaxe de `format filter` a changé selon les versions de Caddy : les anciennes exigeaient `wrap json` et un bloc `fields { }` autour des filtres. Si `caddy validate` proteste, compare avec caddyserver.com/docs/caddyfile/directives/log.</Note>

</When>

<When notFlag="ANON_LOGS">

Tu as choisi les adresses complètes : chaque ligne du log d'accès enregistre l'IP entière du visiteur.

<Note>Une IP complète est une donnée personnelle au sens du RGPD. La garder pour la sécurité se défend ; dis-le dans tes mentions légales, et garde une rotation courte.</Note>

</When>

## Valider et recharger

<Warn>Valide avec `sudo -u caddy`, exactement comme ci-dessous, jamais en root : en root, la validation crée le fichier de log au nom de root, et Caddy ne peut plus l'écrire après le rechargement.</Warn>

```bash on="server" as="${USERNAME}"
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -f
```

<Guided>`validate` lit le fichier et prépare chaque module sans rien servir, et se termine par `Valid configuration`. Avant, il peut afficher un avertissement, `Caddyfile input is not formatted` : le style de Caddy indente avec des tabulations et ce fichier utilise des espaces. C'est sans conséquence ; laisse le fichier tel quel. `reload` passe le nouveau fichier au Caddy en marche sans couper de connexion ; si le fichier est cassé, le rechargement échoue et l'ancienne configuration continue de servir. Dans le journal, attends `certificate obtained successfully` pour <V name="DOMAIN" /> et pour `www.`<V name="DOMAIN" />, en général en moins de 30 secondes, puis arrête le suivi avec Ctrl+C.</Guided>

<Deep>Valide en tant qu'utilisateur `caddy`, ni en root ni en ton nom. La validation ouvre le fichier de log : en root, elle créerait `/var/log/caddy/access.log` appartenant à root en mode 600, et le service, qui tourne en `caddy`, ne pourrait plus l'ouvrir au rechargement ; en <V name="USERNAME" />, elle échoue avec « permission denied ». Valider en `caddy` vérifie aussi ce que le service aura réellement le droit de lire.</Deep>

<Details summary="Si le certificat échoue">

Lis d'abord la ligne d'erreur dans le journal : elle nomme la cause. Ces requêtes, depuis ton portable, couvrent le côté DNS. Par ordre de probabilité :

```bash on="mac"
dig +short A ${DOMAIN}
dig +short A www.${DOMAIN}
dig +short AAAA ${DOMAIN}
dig +short CAA ${DOMAIN}
```

- **DNS** : les deux requêtes A doivent répondre <V name="SERVER_IP" />. Juste après un changement, attends que l'ancien TTL expire.
- **AAAA faux** : si la requête AAAA répond quelque chose, ce doit être <V name="SERVER_IPV6" />. Let's Encrypt essaie l'IPv6 en premier, donc un AAAA périmé fait échouer la validation même quand l'IPv4 est parfaite. Pas d'AAAA du tout, ça va.
- **Port 80 fermé** : `sudo ufw status` doit montrer `80/tcp ALLOW` ; si le Network Firewall d'OVH est actif, ouvre le 80 là-bas aussi.
- **CAA** : la requête CAA doit être vide ou contenir `letsencrypt.org`. Quand Let's Encrypt échoue, Caddy se rabat sur ZeroSSL, dont l'identifiant CAA est `sectigo.com`.
- **Limites de débit** : Let's Encrypt accepte 5 validations échouées par nom d'hôte et par heure. Pendant que tu cherches, fais pointer Caddy vers la CA de test (staging) : ouvre le fichier à la main sur le serveur,

```bash on="server" as="${USERNAME}"
sudo ${EDITOR} /etc/caddy/Caddyfile
```

puis ajoute cette ligne dans le bloc d'options globales, en haut, sous `email`, et valide puis recharge comme plus haut :

```text
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
```

Les certificats de staging ne sont pas reconnus par les navigateurs, donc curl va râler : c'est normal. Une fois le succès affiché dans le journal, recolle la commande du Caddyfile de l'étape précédente (elle réécrit le fichier sans la ligne), puis valide et recharge : Caddy range les certificats par CA et en demande un vrai.

</Details>

## Vérifier depuis ton portable

<Check cmd="curl -sI https://${DOMAIN}/ | head -1" expect="HTTP/2 200" />

<Check cmd="curl -sI https://www.${DOMAIN}/ | head -1" expect="HTTP/2 301" />

<Check cmd="curl -sI https://www.${DOMAIN}/ | grep -i '^location'" expect="location: https://${DOMAIN}/" />

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

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

<Guided>La première réponse prouve que le certificat est valide (sinon curl le refuserait) et que HTTP/2 est négocié. Les pages HTML n'ont pas de `Cache-Control` : les navigateurs les revalident, et un déploiement se voit au chargement suivant. Les deux suivantes prouvent la redirection de www : un 301 vers `https://`<V name="DOMAIN" />. La quatrième prouve qu'une page manquante reçoit une vraie 404, pas un 200. La dernière compte l'en-tête `strict-transport-security` : 1 veut dire que ta ligne HSTS est bien servie. Ouvre aussi `http://`<V name="DOMAIN" /> dans un navigateur : tu arrives sur `https://` sans avertissement.</Guided>

<Deep>

HTTP/3 est actif par défaut : Caddy l'annonce dans un en-tête `Alt-Svc`, et les navigateurs passent en QUIC à la requête suivante.

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

La première doit montrer `h3=":443"`. La seconde ne marche que si ton curl a été compilé avec HTTP/3 (`curl -V` liste `HTTP3` dans Features) ; à notre connaissance, le curl système de macOS ne l'est pas.

Il n'y a pas encore de Content-Security-Policy. Un export Next.js contient des scripts en ligne pour l'hydratation, donc une CSP stricte demande soit des empreintes par build, soit `'unsafe-inline'`, et une CSP fausse casse le site sans bruit. Quand tu en ajoutes une, commence par `Content-Security-Policy-Report-Only` dans le bloc `header`, surveille la console du navigateur un moment, puis passe en mode bloquant.

</Deep>

## Terminé

<V name="DOMAIN" /> répond maintenant en HTTPS avec un certificat Let's Encrypt que Caddy renouvelle tout seul, bien avant l'expiration. `www` et le HTTP simple redirigent vers `https://`<V name="DOMAIN" />, HTTP/3 est disponible, les ressources statiques sont mises en cache pour longtemps, et le log d'accès tourne tout seul<When flag="ANON_LOGS"> avec des adresses masquées</When>. Les visiteurs voient la page d'attente.

La page suivante, **Déployer des versions atomiques avec rsync**, construit le site Next.js sur ton portable, l'envoie comme nouvelle release sous <V name="WEB_ROOT" />, et bascule le lien `current` pour la mettre en ligne.
````

````yaml title="content/ovh-vps-static-site/serve-with-caddy/diagram.yaml"
# Quick: the visitor, Caddy, the Caddyfile, the current link and Let's Encrypt.
# Guided: the firewall in front and the placeholder release behind the link.
# Deep: ports and protocols on Caddy, the systemd unit, the certificate store, the access log (masked or not).
title: { en: "One web server, one certificate, one link", fr: "Un serveur web, un certificat, un lien" }
caption:
  en: "Visitors reach Caddy on 443 (and 80, which only redirects). Caddy gets and renews its certificate from Let's Encrypt on its own, and serves whatever release the current link points at."
  fr: "Les visiteurs atteignent Caddy sur le 443 (et le 80, qui ne fait que rediriger). Caddy obtient et renouvelle son certificat auprès de Let's Encrypt tout seul, et sert la release sur laquelle pointe le lien current."

groups:
  - id: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The Debian server secured on page 3. This page adds the web ports, Caddy and the web root."
      fr: "Le serveur Debian sécurisé en page 3. Cette page ajoute les ports web, Caddy et la racine web."

nodes:
  - id: visitor
    kind: user
    label: { en: "Visitor", fr: "Visiteur" }
    sub: "https://${DOMAIN}"
    desc:
      en: "Anyone opening the site. Whether they type www, http:// or neither, they end up on https://${DOMAIN}."
      fr: "Quiconque ouvre le site. Qu'il tape www, http:// ou rien, il finit sur https://${DOMAIN}."
    deep:
      sub: "browser · DNS A/AAAA → ${SERVER_IP}"
  - id: letsencrypt
    kind: cloud
    label: { en: "Let's Encrypt", fr: "Let's Encrypt" }
    sub: "ACME"
    desc:
      en: "The free certificate authority. It checks that the server really answers for ${DOMAIN}, then signs a certificate. Caddy renews it well before expiry."
      fr: "L'autorité de certification gratuite. Elle vérifie que le serveur répond vraiment pour ${DOMAIN}, puis signe un certificat. Caddy le renouvelle bien avant l'expiration."
    deep:
      sub: "ACME v2 · HTTP-01 on :80 / TLS-ALPN-01 on :443 · fallback ZeroSSL"
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "80 · 443 · 443/udp"
    in: server
    level: guided
    desc:
      en: "ufw now also lets in 80/tcp (ACME challenge and redirect), 443/tcp (HTTP/1.1 and HTTP/2) and 443/udp (HTTP/3)."
      fr: "ufw laisse maintenant aussi passer 80/tcp (défi ACME et redirection), 443/tcp (HTTP/1.1 et HTTP/2) et 443/udp (HTTP/3)."
    deep:
      sub: "ufw · allow 80/tcp 443/tcp 443/udp · IPv4 + IPv6"
  - id: caddy
    kind: service
    label: { en: "Caddy", fr: "Caddy" }
    sub: "HTTPS · HTTP/3"
    in: server
    focus: true
    desc:
      en: "The web server. Terminates TLS, redirects www and plain HTTP, compresses, adds security and cache headers, and serves files from the web root."
      fr: "Le serveur web. Termine le TLS, redirige www et le HTTP simple, compresse, ajoute les en-têtes de sécurité et de cache, et sert les fichiers de la racine web."
    guided:
      sub: "TLS · www → ${DOMAIN} · headers · file_server"
    deep:
      sub: ":80 → 308 https · :443 TCP h1/h2 · :443 UDP h3 · user caddy"
  - id: caddyfile
    kind: file
    label: { en: "Caddyfile", fr: "Caddyfile" }
    sub: "/etc/caddy/Caddyfile"
    in: server
    desc:
      en: "The whole configuration in one file: the email for ACME, the www redirect, the site block with headers, caching and logs."
      fr: "Toute la configuration dans un fichier : l'email pour ACME, la redirection www, le bloc du site avec en-têtes, cache et logs."
  - id: current
    kind: file
    label: { en: "current", fr: "current" }
    sub: "${WEB_ROOT}/current"
    in: server
    desc:
      en: "A symbolic link to the live release. Caddy only ever serves this path; moving the link is a deploy."
      fr: "Un lien symbolique vers la release en ligne. Caddy ne sert jamais que ce chemin ; déplacer le lien, c'est déployer."
    deep:
      sub: "symlink → releases/placeholder · resolved per request"
  - id: placeholder
    kind: file
    label: { en: "Placeholder release", fr: "Release d'attente" }
    sub: "releases/placeholder/"
    in: server
    level: guided
    desc:
      en: "index.html and 404.html, release zero. Page 6 puts real releases next to it and moves the link."
      fr: "index.html et 404.html, la release zéro. La page 6 pose les vraies releases à côté et déplace le lien."
  - id: unit
    kind: service
    label: { en: "systemd unit", fr: "Unité systemd" }
    sub: "caddy.service"
    in: server
    level: deep
    desc:
      en: "Starts Caddy at boot as the unprivileged user caddy; reload hands it a new Caddyfile without dropping connections."
      fr: "Démarre Caddy au boot sous l'utilisateur non privilégié caddy ; reload lui passe un nouveau Caddyfile sans couper les connexions."
    deep:
      sub: "User=caddy · CAP_NET_BIND_SERVICE · ExecReload=caddy reload"
  - id: certs
    kind: store
    label: { en: "Certificates", fr: "Certificats" }
    sub: "/var/lib/caddy"
    in: server
    level: deep
    desc:
      en: "Certificates, private keys and the ACME account, under /var/lib/caddy/.local/share/caddy. Back it up on page 7."
      fr: "Certificats, clés privées et compte ACME, sous /var/lib/caddy/.local/share/caddy. À sauvegarder en page 7."
  - id: accesslog
    kind: file
    label: { en: "Access log", fr: "Log d'accès" }
    sub: "/var/log/caddy/access.log"
    in: server
    level: deep
    desc:
      en: "One JSON line per request, rotated at 10 MiB, ten gzipped files kept."
      fr: "Une ligne JSON par requête, rotation à 10 Mio, dix fichiers gzip gardés."

edges:
  - from: visitor
    to: caddy
    label: "HTTPS"
    max: quick
    desc: { en: "The visitor's browser talks HTTPS to Caddy, over HTTP/2 or HTTP/3.", fr: "Le navigateur du visiteur parle HTTPS avec Caddy, en HTTP/2 ou HTTP/3." }
  - from: visitor
    to: firewall
    label: "HTTPS · 443"
    level: guided
    deep: { label: "TCP 80 · TCP 443 (h1/h2) · UDP 443 (h3/QUIC)" }
    desc: { en: "Web traffic enters through the three ports opened on this page.", fr: "Le trafic web entre par les trois ports ouverts sur cette page." }
  - from: firewall
    to: caddy
    level: guided
    desc: { en: "Only the web ports and your SSH port get through.", fr: "Seuls les ports web et ton port SSH passent." }
  - from: caddy
    to: visitor
    label: "www → 301 → ${DOMAIN}"
    dashed: true
    deep: { label: "www → 301 · http → 308 · Location: https://${DOMAIN}/" }
    desc: { en: "www.${DOMAIN} and plain HTTP answer with a redirect to https://${DOMAIN}, path kept.", fr: "www.${DOMAIN} et le HTTP simple répondent par une redirection vers https://${DOMAIN}, chemin conservé." }
  - from: caddy
    to: current
    label: "serves"
    deep: { label: "file_server · root * ${WEB_ROOT}/current" }
    desc: { en: "Every request is served from the current link, with dir/index.html for /dir/ and 404.html for missing files.", fr: "Chaque requête est servie depuis le lien current, avec dir/index.html pour /dir/ et 404.html pour les fichiers manquants." }
  - from: current
    to: placeholder
    label: "symlink"
    level: guided
    desc: { en: "For now the link points at the placeholder; the next page swaps it to a real release.", fr: "Pour l'instant le lien pointe sur la page d'attente ; la page suivante le bascule vers une vraie release." }
  - from: caddyfile
    to: caddy
    label: "loads"
    dashed: true
    desc: { en: "Read at start and on reload. Validate it first: a broken file makes the reload fail and the old config keeps serving.", fr: "Lu au démarrage et au rechargement. Valide-le d'abord : un fichier cassé fait échouer le rechargement et l'ancienne config continue de servir." }
  - from: caddy
    to: letsencrypt
    label: "ACME"
    dashed: true
    deep: { label: "ACME order · HTTP-01 / TLS-ALPN-01 · renew at 2/3 of lifetime" }
    desc: { en: "Caddy asks for a certificate for ${DOMAIN} and www.${DOMAIN}, proves control of the name, and renews on its own.", fr: "Caddy demande un certificat pour ${DOMAIN} et www.${DOMAIN}, prouve qu'il contrôle le nom, et renouvelle tout seul." }
  - from: unit
    to: caddy
    label: "runs"
    dashed: true
    level: deep
    desc: { en: "systemd starts Caddy as user caddy and restarts it at boot.", fr: "systemd lance Caddy sous l'utilisateur caddy et le redémarre au boot." }
  - from: caddy
    to: certs
    label: "stores"
    dashed: true
    level: deep
    desc: { en: "Certificates and keys are written here and reused across restarts.", fr: "Certificats et clés sont écrits ici et réutilisés d'un redémarrage à l'autre." }
  - from: caddy
    to: accesslog
    label: "IPs masked /16 · /32"
    dashed: true
    level: deep
    when: { flag: ANON_LOGS }
    desc: { en: "Visitor addresses are truncated before being written: IPv4 to /16, IPv6 to /32.", fr: "Les adresses des visiteurs sont tronquées avant d'être écrites : IPv4 en /16, IPv6 en /32." }
  - from: caddy
    to: accesslog
    label: "full IPs"
    dashed: true
    level: deep
    when: { notFlag: ANON_LOGS }
    desc: { en: "Each line keeps the visitor's complete IP address.", fr: "Chaque ligne garde l'adresse IP complète du visiteur." }
````

---

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