# Source of "Open a WireGuard tunnel to the server"

The files of this tutorial, as they are in the repository. The authoring brief that defines the format follows them.

````yaml title="content/private-vps-behind-wireguard/series.yaml"
title:
  en: A private VPS behind WireGuard
  fr: Un VPS privé derrière WireGuard
summary:
  en: >-
    From a hardened Debian VPS (pages 1 to 3 of "A static site on an OVH VPS") to a server that
    answers no one on the internet but your own devices, through a WireGuard tunnel you run
    yourself: no third party in the path, a single UDP port open, every service reachable only
    from inside.
  fr: >-
    D'un VPS Debian durci (pages 1 à 3 de « Un site statique sur un VPS OVH ») à un serveur qui
    ne répond à personne sur internet sauf à tes propres appareils, à travers un tunnel WireGuard
    que tu tiens toi-même : aucun tiers sur le chemin, un seul port UDP ouvert, chaque service
    joignable uniquement de l'intérieur.
order:
  - open-a-wireguard-tunnel
  - close-the-public-door

groups:
  - id: server
    label: { en: Server, fr: Serveur }
    desc: { en: The VPS as the previous series left it., fr: Le VPS tel que la série précédente l'a laissé. }
  - id: tunnel
    label: { en: Tunnel, fr: Tunnel }
    desc: { en: The private network between the server and your devices., fr: Le réseau privé entre le serveur et tes appareils. }
  - id: laptop
    label: { en: Your Mac, fr: Ton Mac }
    desc: { en: How your Mac reaches the server once the tunnel is up., fr: Comment ton Mac atteint le serveur une fois le tunnel monté. }

vars:
  - key: SERVER_IP
    kind: ip
    group: server
    default: 203.0.113.10
    label: { en: Server public IPv4, fr: IPv4 publique du serveur }
    hint:
      en: The public IPv4 address of the VPS, the one you used with ssh vps until now.
      fr: L'adresse IPv4 publique du VPS, celle que tu utilisais jusqu'ici avec ssh vps.
    impact:
      en: Becomes the tunnel's endpoint on every device. After this series it is the only address of yours the internet sees, and only on one UDP port.
      fr: Devient l'extrémité du tunnel sur chaque appareil. Après cette série, c'est la seule adresse à toi que voit internet, et sur un seul port UDP.
  - key: USERNAME
    kind: user
    group: server
    default: ops
    label: { en: Your admin user, fr: Ton utilisateur admin }
    hint:
      en: The account created by "Secure the server in the first hour", with sudo and a password.
      fr: Le compte créé par « Sécuriser le serveur dans la première heure », avec sudo et un mot de passe.
    impact:
      en: Every server command runs as this user with sudo. Its password is also the KVM console login, your way back in if the tunnel breaks.
      fr: Chaque commande serveur tourne sous ce compte avec sudo. Son mot de passe est aussi celui de la console KVM, ton chemin de retour si le tunnel casse.
  - key: SSH_PORT
    kind: port
    group: server
    default: "1234"
    label: { en: SSH port, fr: Port SSH }
    hint:
      en: The port sshd listens on since the hardening page.
      fr: Le port sur lequel sshd écoute depuis la page de durcissement.
    impact:
      en: Page 2 closes it to the internet and opens it on the tunnel interface only. A different value from the server's means the firewall closes the wrong port.
      fr: La page 2 le ferme à internet et l'ouvre seulement sur l'interface du tunnel. Une valeur différente de celle du serveur, et le pare-feu ferme le mauvais port.
  - key: SSH_KEY_FILE
    kind: path
    group: laptop
    default: ~/.ssh/id_ed25519_vps
    label: { en: SSH key of the server, fr: Clé SSH du serveur }
    hint:
      en: The private key on your Mac that opens the server, created when ordering the VPS.
      fr: La clé privée de ton Mac qui ouvre le serveur, créée à la commande du VPS.
    impact:
      en: Written into the new ssh shortcut. A wrong path and ssh offers no key, then fails with Permission denied (publickey).
      fr: Écrite dans le nouveau raccourci ssh. Un mauvais chemin et ssh ne présente aucune clé, puis échoue avec Permission denied (publickey).
  - key: SSH_ALIAS
    kind: hostname
    group: laptop
    default: cave
    label: { en: Shortcut through the tunnel, fr: Raccourci par le tunnel }
    hint:
      en: The name you type after ssh to reach the server through the tunnel. One word, lowercase.
      fr: Le nom que tu tapes après ssh pour atteindre le serveur par le tunnel. Un mot, en minuscules.
    impact:
      en: Replaces ssh vps once the public door is closed. Scripts that still say ssh vps (deploys, backups) must switch to it.
      fr: Remplace ssh vps une fois la porte publique fermée. Les scripts qui disent encore ssh vps (déploiements, sauvegardes) doivent passer à celui-ci.
  - key: WG_PORT
    kind: port
    group: tunnel
    default: "51820"
    label: { en: WireGuard port (UDP), fr: Port WireGuard (UDP) }
    hint:
      en: The UDP port the server listens on for the tunnel. 51820 is WireGuard's usual port; any free UDP port works.
      fr: Le port UDP sur lequel le serveur attend le tunnel. 51820 est le port habituel de WireGuard ; n'importe quel port UDP libre convient.
    impact:
      en: Opened in the firewall and written in every device's configuration. After page 2, it is the only open port of the server.
      fr: Ouvert dans le pare-feu et écrit dans la configuration de chaque appareil. Après la page 2, c'est le seul port ouvert du serveur.
  - key: WG_SERVER_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.1
    label: { en: Server address in the tunnel, fr: Adresse du serveur dans le tunnel }
    hint:
      en: A private IPv4 address that exists only inside the tunnel. The server takes it with a /24, so the tunnel network is its first three numbers.
      fr: "Une adresse IPv4 privée qui n'existe que dans le tunnel. Le serveur la prend en /24 : le réseau du tunnel, ce sont ses trois premiers nombres."
    impact:
      en: Every service of the series listens on it. It must not overlap a network you use (home box, office VPN), or traffic goes to the wrong place.
      fr: Chaque service de la série écoute dessus. Elle ne doit chevaucher aucun réseau que tu utilises (box, VPN du bureau), sinon le trafic part au mauvais endroit.
  - key: WG_MAC_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.2
    label: { en: Mac address in the tunnel, fr: Adresse du Mac dans le tunnel }
    hint:
      en: Your Mac's address inside the tunnel, in the same /24 as the server's.
      fr: L'adresse de ton Mac dans le tunnel, dans le même /24 que celle du serveur.
    impact:
      en: The server accepts packets from your Mac's key only with this source address. Two devices with the same address and one of them stops working.
      fr: Le serveur n'accepte les paquets de la clé de ton Mac qu'avec cette adresse source. Deux appareils avec la même adresse, et l'un des deux cesse de marcher.
  - key: WG_PHONE_ADDR
    kind: ip
    group: tunnel
    default: 10.66.0.3
    when: { flag: PHONE }
    label: { en: Phone address in the tunnel, fr: Adresse du téléphone dans le tunnel }
    hint:
      en: Your phone's address inside the tunnel, in the same /24, different from the Mac's.
      fr: L'adresse de ton téléphone dans le tunnel, dans le même /24, différente de celle du Mac.
    impact:
      en: "Same rule as the Mac's: one address per device, inside the server's /24."
      fr: "Même règle que pour le Mac : une adresse par appareil, dans le /24 du serveur."

choices:
  - key: PHONE
    type: boolean
    label: { en: Also connect a phone, fr: Connecter aussi un téléphone }
    hint:
      en: Adds a second device to the tunnel, with its own key. You can come back and turn it on later.
      fr: Ajoute un second appareil au tunnel, avec sa propre clé. Tu peux revenir l'activer plus tard.
    default: false
````

````yaml title="content/private-vps-behind-wireguard/open-a-wireguard-tunnel/tuto.yaml"
# Inherits from ../series.yaml: SERVER_IP, USERNAME, SSH_PORT, SSH_KEY_FILE, SSH_ALIAS, WG_PORT, WG_SERVER_ADDR, WG_MAC_ADDR, WG_PHONE_ADDR, PHONE.
title:
  en: Open a WireGuard tunnel to the server
  fr: Ouvrir un tunnel WireGuard vers le serveur
summary:
  en: >-
    WireGuard running on the server on one UDP port, your Mac (and your phone if you want)
    connected to it with keys generated on each device, and ssh reaching the server through the
    tunnel under a new shortcut. The public SSH port stays open: page 2 closes it.
  fr: >-
    WireGuard en marche sur le serveur sur un port UDP, ton Mac (et ton téléphone si tu veux)
    connecté avec des clés générées sur chaque appareil, et ssh qui atteint le serveur par le
    tunnel sous un nouveau raccourci. Le port SSH public reste ouvert : la page 2 le ferme.
difficulty: intermediate
tags: [wireguard, vpn, ssh, ufw, debian, macos, security]
authors: [thudal]
created: 2026-10-02
minutes: 25
validated: Debian 13 (trixie) on OVHcloud VPS · WireGuard for macOS (App Store)
status: draft             # not yet run end to end by its author

groups:
  - id: keys
    label: { en: Public keys, fr: Clés publiques }
    desc:
      en: Each device generates its own key pair; only the public halves are copied here, never a private key.
      fr: Chaque appareil génère sa propre paire de clés ; seules les moitiés publiques sont copiées ici, jamais une clé privée.

vars:
  - key: MAC_WG_PUBKEY
    kind: text
    group: keys
    default: ""
    required: true
    label: { en: Mac public key, fr: Clé publique du Mac }
    hint:
      en: Shown at the top of the tunnel editor in the WireGuard app on your Mac, after "Public key". 44 characters ending in =.
      fr: Affichée en haut de l'éditeur de tunnel dans l'app WireGuard de ton Mac, après « Public key ». 44 caractères terminés par =.
    impact:
      en: Written in the server's configuration as the only key allowed to use the Mac's tunnel address. A wrong key and the handshake never completes, silently.
      fr: Écrite dans la configuration du serveur comme la seule clé autorisée à utiliser l'adresse tunnel du Mac. Une mauvaise clé et la poignée de main n'aboutit jamais, sans message d'erreur.
  - key: SERVER_WG_PUBKEY
    kind: text
    group: keys
    default: ""
    required: true
    label: { en: Server public key, fr: Clé publique du serveur }
    hint:
      en: The last line printed by the server step (or sudo wg show wg0 public-key on the server). 44 characters ending in =.
      fr: La dernière ligne affichée par l'étape serveur (ou sudo wg show wg0 public-key sur le serveur). 44 caractères terminés par =.
    impact:
      en: Pins the server's identity on every device. A device holding a wrong key refuses to talk, so no one can impersonate the server inside the tunnel.
      fr: "Épingle l'identité du serveur sur chaque appareil. Un appareil qui a une mauvaise clé refuse de parler : personne ne peut se faire passer pour le serveur dans le tunnel."
  - key: PHONE_WG_PUBKEY
    kind: text
    group: keys
    default: ""
    when: { flag: PHONE }
    label: { en: Phone public key, fr: Clé publique du téléphone }
    hint:
      en: Shown in the WireGuard app on the phone after "Generate keypair". 44 characters ending in =.
      fr: Affichée dans l'app WireGuard du téléphone après « Generate keypair ». 44 caractères terminés par =.
    impact:
      en: Same role as the Mac's key, for the phone's tunnel address. Losing the phone means removing this key from the server.
      fr: Même rôle que la clé du Mac, pour l'adresse tunnel du téléphone. Perdre le téléphone, c'est retirer cette clé du serveur.
````

````mdx title="content/private-vps-behind-wireguard/open-a-wireguard-tunnel/page-en.mdx"
{/* First pass — to be validated before publishing against https://www.wireguard.com/quickstart/ , man wg and man wg-quick (wireguard-tools, Debian 13), https://wiki.debian.org/WireGuard and the WireGuard app for macOS (App Store). */}

The server is hardened, but SSH still answers the whole internet. This page builds the private path that will replace it: WireGuard on the server, on a single UDP port, and your Mac<When flag="PHONE"> and your phone</When> connected to it with a key generated on each device. At the end, `ssh `<V name="SSH_ALIAS" /> goes through the tunnel. The old door stays open for the whole page: it only closes on page 2, once the tunnel is proven.

<Run>

Three parts, because each device's key must be born on that device. **Part 1, on your Mac**, in the WireGuard app: create an empty tunnel named <V name="SSH_ALIAS" /> and copy its public key into the values panel<When flag="PHONE">; do the same in the phone's app</When> (step "Install WireGuard on your Mac"). **Part 2, on the server** as <V name="USERNAME" /> through `ssh vps`: paste the first block into `wireguard.sh` with <V name="EDITOR" />, run `sudo bash wireguard.sh`, and copy the last line it prints (the server's public key) into the panel. **Part 3, on your Mac**: complete the tunnel in the app with the configuration block, activate it, then run the last block in a terminal.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Open a WireGuard tunnel, server part — ${SERVER_IP}:${WG_PORT}/udp. As ${USERNAME}: sudo bash wireguard.sh
[ "$(id -u)" -eq 0 ] || { echo 'Run me with: sudo bash wireguard.sh'; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y wireguard-tools
install -d -m 700 /etc/wireguard
[ -s /etc/wireguard/wg0.key ] || (umask 077 && wg genkey > /etc/wireguard/wg0.key)
cat > /etc/wireguard/wg0.conf <<'EOF'
[Interface]
Address = ${WG_SERVER_ADDR}/24
ListenPort = ${WG_PORT}
PostUp = wg set %i private-key /etc/wireguard/%i.key

[Peer]
# mac
PublicKey = ${MAC_WG_PUBKEY}
AllowedIPs = ${WG_MAC_ADDR}/32
EOF
chmod 600 /etc/wireguard/wg0.conf
```

<When flag="PHONE">

```bash on="server" as="${USERNAME}"
cat >> /etc/wireguard/wg0.conf <<'EOF'

[Peer]
# phone
PublicKey = ${PHONE_WG_PUBKEY}
AllowedIPs = ${WG_PHONE_ADDR}/32
EOF
```

</When>

```bash on="server" as="${USERNAME}"
ufw allow ${WG_PORT}/udp
systemctl enable wg-quick@wg0
systemctl restart wg-quick@wg0
systemctl is-active wg-quick@wg0
echo "Server public key, copy it into the panel:"
wg pubkey < /etc/wireguard/wg0.key
```

Part 3. In the WireGuard app on the Mac, open the <V name="SSH_ALIAS" /> tunnel (**Edit**), keep the `PrivateKey = …` line the app wrote, and put this below it:

```ini title="Tunnel ${SSH_ALIAS} — WireGuard app on the Mac"
Address = ${WG_MAC_ADDR}/32

[Peer]
PublicKey = ${SERVER_WG_PUBKEY}
AllowedIPs = ${WG_SERVER_ADDR}/32
Endpoint = ${SERVER_IP}:${WG_PORT}
```

Save, activate the tunnel, then in a terminal:

```bash on="mac"
ping -c 3 ${WG_SERVER_ADDR}
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host ${SSH_ALIAS}$' ~/.ssh/config || printf '\nHost ${SSH_ALIAS}\n  HostName ${WG_SERVER_ADDR}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ${SSH_KEY_FILE}\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh ${SSH_ALIAS} echo via-wireguard
```

<Warn>ssh asks you to confirm the host key, because the address is new. Compare the fingerprint first, as the step "Go through the tunnel" explains, then answer `yes`.</Warn>

</Run>

## Before you start

<Guided>You need the server as "Secure the server in the first hour" left it: your account <V name="USERNAME" /> with sudo, SSH on <V name="SSH_PORT" />, ufw active, and the `ssh vps` shortcut working from your Mac. Server commands are typed in an `ssh vps` session, Mac commands in another terminal.</Guided>

<Check cmd="ssh vps 'test -x /usr/sbin/ufw && echo ready'" expect="ready" />

<Guided>Nothing on this page closes anything: the public SSH port stays open until page 2. If a step fails here, you are still in through `ssh vps`.</Guided>

## Install WireGuard on your Mac

Install **WireGuard** from the Mac App Store (publisher: WireGuard Development Team). Open it, then **+** at the bottom left → **Add Empty Tunnel…**. Name it <V name="SSH_ALIAS" />. The app generates a key pair and shows the **public key** at the top of the editor.

Copy that public key into the **Mac public key** field of the values panel, then save the tunnel as it is. It is incomplete: part 3 finishes it. macOS asks to allow adding a VPN configuration: accept.

<Guided>The private key, on the `PrivateKey = …` line, never leaves your Mac: it stays in the system keychain, handled by the app. Only the public half travels. It cannot be used to get in: it only tells the server "this is how you will recognise this Mac".</Guided>

<Deep>The WireGuard app for macOS is the official implementation (wireguard-apple), running as a system network extension. The command-line alternative is `brew install wireguard-tools`, then `wg-quick up` with a file in `/opt/homebrew/etc/wireguard/`: it works, but needs sudo every time the tunnel goes up, and the private key lives in a plain file instead of the keychain. The app's menu labels (**Add Empty Tunnel…**, **Edit**, **Activate**) are the ones of the versions I know; check them in yours.</Deep>

<Note>Menu names can change between versions of the app. If you cannot find **Add Empty Tunnel…**, look in the **+** menu for the entry that creates a tunnel from nothing (not "from file").</Note>

## Install WireGuard on the server

In your `ssh vps` session, install the tools and generate the server's key into a file only root can read:

```bash on="server" as="${USERNAME}"
sudo apt update && sudo apt install -y wireguard-tools
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077 && wg genkey > /etc/wireguard/wg0.key'
```

<Guided>WireGuard's engine has been in the Linux kernel since 5.6: Debian 13 already has it. The `wireguard-tools` package brings the two commands you need: `wg`, which configures the interface, and `wg-quick`, which brings it up from a file. `umask 077` makes the key file born with mode `600`: it is never readable by another account, not even for a split second.</Guided>

<Deep>`wg genkey` draws 32 random bytes from `getrandom()` and encodes them in base64: a Curve25519 private key. Nothing else is generated or stored anywhere. Debian's `wireguard` package is a metapackage that pulls in `wireguard-tools`; installing the latter directly is enough. The kernel module loads by itself on the first `ip link add … type wireguard`, which `wg-quick` does.</Deep>

## Write the server configuration

Create `/etc/wireguard/wg0.conf` with `sudo` <V name="EDITOR" />:

<Annotated>

```ini file="/etc/wireguard/wg0.conf" on="server" as="${USERNAME}"
[Interface]
Address = ${WG_SERVER_ADDR}/24                          # (1)
ListenPort = ${WG_PORT}                                 # (2)
PostUp = wg set %i private-key /etc/wireguard/%i.key    # (3)

[Peer]
# mac
PublicKey = ${MAC_WG_PUBKEY}                            # (4)
AllowedIPs = ${WG_MAC_ADDR}/32                          # (5)
```

1. The server's address inside the tunnel. The `/24` declares the whole tunnel network on the `wg0` interface.
2. The UDP port the server waits on for encrypted packets.
3. The private key is read from its own file when the interface comes up: this configuration file holds no secret.
4. Your Mac's public key, copied from the panel.
5. The only source address this Mac may use inside the tunnel, and the only route to it.

</Annotated>

Then restrict its mode: `sudo chmod 600 /etc/wireguard/wg0.conf`.

<When flag="PHONE">

Add the phone as a second peer, with its own key and its own address:

```bash on="server" as="${USERNAME}"
sudo tee -a /etc/wireguard/wg0.conf > /dev/null <<'EOF'

[Peer]
# phone
PublicKey = ${PHONE_WG_PUBKEY}
AllowedIPs = ${WG_PHONE_ADDR}/32
EOF
```

</When>

<Guided>WireGuard has no users and no passwords: a peer is a public key and the addresses it may use. A packet encrypted with the Mac's key but carrying a source address other than <V name="WG_MAC_ADDR" /> is dropped. Adding a device is adding a `[Peer]` block; revoking one is removing its block.</Guided>

<Deep>WireGuard calls this *cryptokey routing*: `AllowedIPs` is an access list on the way in (source addresses accepted for this key) and a routing table on the way out (which key to encrypt a packet for, given its destination). No `SaveConfig = true` here: with it, `wg-quick` would rewrite this file on shutdown from the in-memory state, and a hand edit made while the interface runs would be lost. This file stays the single source of truth. The `PostUp` run by `wg-quick` goes through bash with `%i` replaced by the interface name; the interface is created without a key, then receives it at once. No `net.ipv4.ip_forward` and no NAT: the server routes nothing between your devices or to the internet. Each device only talks to the server, which keeps the surface minimal.</Deep>

## Open the port and start

Open the UDP port in the firewall, start the interface and have it come back on every boot, then print the server's public key:

```bash on="server" as="${USERNAME}"
sudo ufw allow ${WG_PORT}/udp
sudo systemctl enable --now wg-quick@wg0
sudo wg show wg0 public-key
```

Copy the key it prints into the **Server public key** field of the values panel.

<Check cmd="ssh vps systemctl is-active wg-quick@wg0" expect="active" />

<Guided>`wg-quick@wg0` is a templated systemd service: `wg0` names the file `/etc/wireguard/wg0.conf`. `enable` starts it on every boot, including after the automatic reboots of security updates. `sudo wg show` prints the interface, its port, and for each peer the time of the last handshake: it is your diagnostic command.</Guided>

<Deep>A WireGuard port is mute: the server answers no packet that is not signed by a key it knows. A scanner probing <V name="WG_PORT" />/udp gets nothing back, exactly as on a port closed by the firewall. Moving the port therefore buys almost no discretion, unlike SSH. Keep 51820 unless a network you use blocks it; some hotel Wi-Fi only lets a few UDP ports through, and 443/udp gets through more often.</Deep>

## Connect the Mac

In the WireGuard app on the Mac, select the <V name="SSH_ALIAS" /> tunnel, **Edit**. Keep the first line `PrivateKey = …` and add this below it:

```ini title="Tunnel ${SSH_ALIAS} — WireGuard app on the Mac"
Address = ${WG_MAC_ADDR}/32

[Peer]
PublicKey = ${SERVER_WG_PUBKEY}
AllowedIPs = ${WG_SERVER_ADDR}/32
Endpoint = ${SERVER_IP}:${WG_PORT}
```

Save, then **Activate**. From a terminal:

<Check cmd="ping -c 3 ${WG_SERVER_ADDR} | grep -o '3 packets received'" expect="3 packets received" />

<Guided>`AllowedIPs = `<V name="WG_SERVER_ADDR" />`/32`: only traffic for the server enters the tunnel. The rest of your browsing leaves normally, through your connection. That is deliberate: the tunnel is for reaching the server, not for hiding your browsing. `Endpoint` is the public address the encrypted packets are sent to.</Guided>

<Deep>If the ping gets no answer, look at `sudo wg show` on the server. No `latest handshake` line for the `mac` peer: packets do not arrive (UDP port closed, wrong `Endpoint`, OVH Network Firewall), or a public key is wrong on one side or the other. WireGuard never reports a wrong key: it stays silent. A handshake but no ping: the `Address` or `AllowedIPs` on one side does not match the other. No `PersistentKeepalive`: the Mac always speaks first, and the server never needs to reach it on its own. The tunnel thus stays silent when you are not using it.</Deep>

<Details summary="If the ping gets no answer">

In order:

- On the server, `sudo wg show`: does the `mac` peer have a `latest handshake`? If not, compare both public keys, character by character.
- `sudo ufw status` must show <V name="WG_PORT" />`/udp ALLOW`.
- In the app, is the tunnel really **Active**? Does `Data received` go up?
- OVH's **Network Firewall**, if you turned it on for the IP, filters before the VPS: add a UDP rule for <V name="WG_PORT" />.
- A network that blocks outbound UDP (some public Wi-Fi): try from your phone's hotspot.

</Details>

## Go through the tunnel

Add an ssh shortcut on your Mac that targets the server's tunnel address, then connect with it:

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host ${SSH_ALIAS}$' ~/.ssh/config || printf '\nHost ${SSH_ALIAS}\n  HostName ${WG_SERVER_ADDR}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ${SSH_KEY_FILE}\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh ${SSH_ALIAS} echo via-wireguard
```

ssh does not know this address yet and asks you to confirm the host key. Before answering `yes`, compare the fingerprint it shows with the server's, read through the old door:

```bash on="mac"
ssh vps ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```

<Check cmd="ssh ${SSH_ALIAS} echo via-wireguard" expect="via-wireguard" />

<Guided>You now have two paths to the same server: `ssh vps` over the internet, `ssh `<V name="SSH_ALIAS" /> through the tunnel. The second has two independent locks: the Mac's WireGuard key to enter the tunnel, then the SSH key to open a session.</Guided>

<Deep>The fingerprint check is almost a formality here: inside the tunnel, the server's identity is already guaranteed by its WireGuard key, pinned in the app. No one can sit between your Mac and <V name="WG_SERVER_ADDR" /> without the server's private key. Doing it anyway keeps the reflex. ssh stores the fingerprint under a new entry, `[`<V name="WG_SERVER_ADDR" />`]:`<V name="SSH_PORT" />, in `~/.ssh/known_hosts`. `IdentitiesOnly yes` stops ssh from offering the Mac's other keys first; with several keys loaded in the agent, sshd would cut the connection past `MaxAuthTries` (six attempts by default).</Deep>

<When flag="PHONE">

## Add the phone

Install **WireGuard** (App Store or Play Store, same publisher). **+** → **Create from scratch**, name the tunnel <V name="SSH_ALIAS" />, tap **Generate keypair** and copy the public key into the **Phone public key** field of the panel. The phone's `[Peer]` block must then be in `/etc/wireguard/wg0.conf` (step "Write the server configuration"): reload the interface if you add it afterwards.

```bash on="server" as="${USERNAME}"
sudo systemctl restart wg-quick@wg0
```

In the phone's app, fill in: **Addresses** <V name="WG_PHONE_ADDR" />`/32`; then **Add peer**: **Public key** <V name="SERVER_WG_PUBKEY" />, **Endpoint** <V name="SERVER_IP" />`:`<V name="WG_PORT" />, **Allowed IPs** <V name="WG_SERVER_ADDR" />`/32`. Save and activate.

<Guided>The phone has its own key, generated on it. The day you lose it, you remove its `[Peer]` block from the server and restart the interface: the Mac is not affected.</Guided>

</When>

## Done

| What | Before | After this page |
|---|---|---|
| Paths to the server | `ssh vps`, over the internet | `ssh vps` **and** `ssh `<V name="SSH_ALIAS" />, through the tunnel |
| Open ports | <V name="SSH_PORT" />/tcp | <V name="SSH_PORT" />/tcp and <V name="WG_PORT" />/udp |
| Devices in the tunnel | none | your Mac<When flag="PHONE"> and your phone</When>, each with its own key |

Next page: **Close the public door**. It takes <V name="SSH_PORT" />/tcp off the internet, keeps SSH on the tunnel only, and proves everything comes back after a reboot.
````

````mdx title="content/private-vps-behind-wireguard/open-a-wireguard-tunnel/page-fr.mdx"
{/* Premier jet — à valider avant publication contre https://www.wireguard.com/quickstart/ , man wg et man wg-quick (wireguard-tools, Debian 13), https://wiki.debian.org/WireGuard et l'app WireGuard pour macOS (App Store). */}

Le serveur est durci, mais SSH répond encore à tout internet. Cette page monte le chemin privé qui va le remplacer : WireGuard sur le serveur, sur un seul port UDP, et ton Mac<When flag="PHONE"> et ton téléphone</When> branché dessus avec une clé générée sur chaque appareil. À la fin, `ssh `<V name="SSH_ALIAS" /> passe par le tunnel. L'ancienne porte reste ouverte pendant toute la page : on ne la ferme qu'à la page 2, une fois le tunnel prouvé.

<Run>

Trois parties, parce que la clé de chaque appareil doit naître sur cet appareil. **Partie 1, sur ton Mac**, dans l'app WireGuard : crée un tunnel vide nommé <V name="SSH_ALIAS" /> et copie sa clé publique dans le panneau des valeurs<When flag="PHONE"> ; fais de même dans l'app du téléphone</When> (étape « Installer WireGuard sur ton Mac »). **Partie 2, sur le serveur** en <V name="USERNAME" /> via `ssh vps` : colle le premier bloc dans `wireguard.sh` avec <V name="EDITOR" />, lance `sudo bash wireguard.sh`, et copie la dernière ligne affichée (la clé publique du serveur) dans le panneau. **Partie 3, sur ton Mac** : complète le tunnel dans l'app avec le bloc de configuration, active-le, puis lance le dernier bloc dans un terminal.

```bash on="server" as="${USERNAME}"
#!/usr/bin/env bash
set -euo pipefail
# Ouvrir un tunnel WireGuard, partie serveur — ${SERVER_IP}:${WG_PORT}/udp. En ${USERNAME} : sudo bash wireguard.sh
[ "$(id -u)" -eq 0 ] || { echo 'Lance-moi avec : sudo bash wireguard.sh'; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y wireguard-tools
install -d -m 700 /etc/wireguard
[ -s /etc/wireguard/wg0.key ] || (umask 077 && wg genkey > /etc/wireguard/wg0.key)
cat > /etc/wireguard/wg0.conf <<'EOF'
[Interface]
Address = ${WG_SERVER_ADDR}/24
ListenPort = ${WG_PORT}
PostUp = wg set %i private-key /etc/wireguard/%i.key

[Peer]
# mac
PublicKey = ${MAC_WG_PUBKEY}
AllowedIPs = ${WG_MAC_ADDR}/32
EOF
chmod 600 /etc/wireguard/wg0.conf
```

<When flag="PHONE">

```bash on="server" as="${USERNAME}"
cat >> /etc/wireguard/wg0.conf <<'EOF'

[Peer]
# phone
PublicKey = ${PHONE_WG_PUBKEY}
AllowedIPs = ${WG_PHONE_ADDR}/32
EOF
```

</When>

```bash on="server" as="${USERNAME}"
ufw allow ${WG_PORT}/udp
systemctl enable wg-quick@wg0
systemctl restart wg-quick@wg0
systemctl is-active wg-quick@wg0
echo "Clé publique du serveur, à copier dans le panneau :"
wg pubkey < /etc/wireguard/wg0.key
```

Partie 3. Dans l'app WireGuard du Mac, ouvre le tunnel <V name="SSH_ALIAS" /> (**Edit**), garde la ligne `PrivateKey = …` que l'app a écrite, et mets ceci en dessous :

```ini title="Tunnel ${SSH_ALIAS} — app WireGuard du Mac"
Address = ${WG_MAC_ADDR}/32

[Peer]
PublicKey = ${SERVER_WG_PUBKEY}
AllowedIPs = ${WG_SERVER_ADDR}/32
Endpoint = ${SERVER_IP}:${WG_PORT}
```

Enregistre, active le tunnel, puis dans un terminal :

```bash on="mac"
ping -c 3 ${WG_SERVER_ADDR}
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host ${SSH_ALIAS}$' ~/.ssh/config || printf '\nHost ${SSH_ALIAS}\n  HostName ${WG_SERVER_ADDR}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ${SSH_KEY_FILE}\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh ${SSH_ALIAS} echo via-wireguard
```

<Warn>ssh demande de confirmer la clé d'hôte, parce que l'adresse est nouvelle. Compare d'abord l'empreinte, comme l'explique l'étape « Passer par le tunnel », puis réponds `yes`.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut le serveur dans l'état où l'a laissé « Sécuriser le serveur dans la première heure » : ton compte <V name="USERNAME" /> avec sudo, SSH sur <V name="SSH_PORT" />, ufw actif, et le raccourci `ssh vps` qui marche depuis ton Mac. Les commandes serveur se tapent dans une session `ssh vps`, les commandes Mac dans un autre terminal.</Guided>

<Check cmd="ssh vps 'test -x /usr/sbin/ufw && echo ready'" expect="ready" />

<Guided>Rien sur cette page ne ferme quoi que ce soit : le port SSH public reste ouvert jusqu'à la page 2. Si une étape rate ici, tu es toujours dedans par `ssh vps`.</Guided>

## Installer WireGuard sur ton Mac

Installe **WireGuard** depuis le Mac App Store (éditeur : WireGuard Development Team). Ouvre-le, puis **+** en bas à gauche → **Add Empty Tunnel…**. Nomme-le <V name="SSH_ALIAS" />. L'app génère une paire de clés et affiche la **clé publique** en haut de l'éditeur.

Copie cette clé publique dans le champ **Clé publique du Mac** du panneau des valeurs, puis enregistre le tunnel tel quel. Il est incomplet : la partie 3 le termine. macOS demande d'autoriser l'ajout d'une configuration VPN : accepte.

<Guided>La clé privée, sur la ligne `PrivateKey = …`, ne quitte jamais ton Mac : elle reste dans le trousseau système, gérée par l'app. Seule la moitié publique voyage. Elle ne permet pas d'entrer : elle dit seulement au serveur « voici à quoi tu reconnaîtras ce Mac ».</Guided>

<Deep>L'app WireGuard pour macOS est l'implémentation officielle (wireguard-apple), qui tourne comme une extension réseau du système. L'alternative en ligne de commande est `brew install wireguard-tools`, puis `wg-quick up` avec un fichier dans `/opt/homebrew/etc/wireguard/` : ça marche, mais il faut sudo à chaque montée du tunnel, et la clé privée vit dans un fichier en clair au lieu du trousseau. Les libellés des menus de l'app (**Add Empty Tunnel…**, **Edit**, **Activate**) sont en anglais dans les versions que je connais ; vérifie-les dans la tienne.</Deep>

<Note>Les noms de menus de l'app peuvent changer d'une version à l'autre. Si tu ne trouves pas **Add Empty Tunnel…**, cherche dans le menu **+** l'entrée qui crée un tunnel à partir de rien (et pas « à partir d'un fichier »).</Note>

## Installer WireGuard sur le serveur

Dans ta session `ssh vps`, installe les outils et génère la clé du serveur dans un fichier que seul root peut lire :

```bash on="server" as="${USERNAME}"
sudo apt update && sudo apt install -y wireguard-tools
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077 && wg genkey > /etc/wireguard/wg0.key'
```

<Guided>Le moteur de WireGuard est dans le noyau Linux depuis la version 5.6 : Debian 13 l'a déjà. Le paquet `wireguard-tools` apporte les deux commandes dont tu as besoin : `wg`, qui règle l'interface, et `wg-quick`, qui la monte à partir d'un fichier. `umask 077` fait naître le fichier de clé avec les droits `600` : il n'est jamais lisible par un autre compte, même une fraction de seconde.</Guided>

<Deep>`wg genkey` tire 32 octets aléatoires de `getrandom()` et les encode en base64 : c'est une clé privée Curve25519. Rien d'autre n'est généré ni enregistré ailleurs. Le paquet `wireguard` de Debian est un méta-paquet qui tire `wireguard-tools` ; installer directement ce dernier évite d'éventuelles recommandations inutiles. Le module noyau se charge tout seul au premier `ip link add … type wireguard`, ce que fait `wg-quick`.</Deep>

## Écrire la configuration du serveur

Crée `/etc/wireguard/wg0.conf` avec `sudo` <V name="EDITOR" /> :

<Annotated>

```ini file="/etc/wireguard/wg0.conf" on="server" as="${USERNAME}"
[Interface]
Address = ${WG_SERVER_ADDR}/24                          # (1)
ListenPort = ${WG_PORT}                                 # (2)
PostUp = wg set %i private-key /etc/wireguard/%i.key    # (3)

[Peer]
# mac
PublicKey = ${MAC_WG_PUBKEY}                            # (4)
AllowedIPs = ${WG_MAC_ADDR}/32                          # (5)
```

1. L'adresse du serveur dans le tunnel. Le `/24` déclare tout le réseau du tunnel sur l'interface `wg0`.
2. Le port UDP sur lequel le serveur attend les paquets chiffrés.
3. La clé privée est lue depuis son propre fichier au démarrage de l'interface : ce fichier de configuration ne contient aucun secret.
4. La clé publique de ton Mac, copiée depuis le panneau.
5. La seule adresse source que ce Mac a le droit d'utiliser dans le tunnel, et la seule route vers lui.

</Annotated>

Puis restreins ses droits : `sudo chmod 600 /etc/wireguard/wg0.conf`.

<When flag="PHONE">

Ajoute le téléphone comme second pair, avec sa propre clé et sa propre adresse :

```bash on="server" as="${USERNAME}"
sudo tee -a /etc/wireguard/wg0.conf > /dev/null <<'EOF'

[Peer]
# phone
PublicKey = ${PHONE_WG_PUBKEY}
AllowedIPs = ${WG_PHONE_ADDR}/32
EOF
```

</When>

<Guided>Il n'y a ni utilisateur ni mot de passe dans WireGuard : un pair, c'est une clé publique et les adresses qu'elle a le droit d'utiliser. Un paquet qui arrive chiffré par la clé du Mac mais avec une autre adresse source que <V name="WG_MAC_ADDR" /> est jeté. Ajouter un appareil, c'est ajouter un bloc `[Peer]` ; en révoquer un, c'est retirer le sien.</Guided>

<Deep>WireGuard appelle ça le *cryptokey routing* : `AllowedIPs` sert de liste de contrôle à l'entrée (adresses source acceptées pour cette clé) et de table de routage à la sortie (vers quelle clé chiffrer un paquet destiné à telle adresse). Pas de `SaveConfig = true` ici : avec, `wg-quick` réécrirait ce fichier à l'arrêt à partir de l'état en mémoire, et une modification faite à la main pendant que l'interface tourne serait perdue. Ce fichier reste la seule source de vérité. Le `PostUp` lancé par `wg-quick` est exécuté par bash avec `%i` remplacé par le nom de l'interface ; l'interface est créée sans clé, puis la reçoit aussitôt. Ni `net.ipv4.ip_forward` ni NAT : le serveur ne route rien entre tes appareils ni vers internet. Chaque appareil ne parle qu'au serveur, ce qui garde la surface au minimum.</Deep>

## Ouvrir le port et démarrer

Ouvre le port UDP dans le pare-feu, démarre l'interface et fais-la remonter à chaque démarrage, puis affiche la clé publique du serveur :

```bash on="server" as="${USERNAME}"
sudo ufw allow ${WG_PORT}/udp
sudo systemctl enable --now wg-quick@wg0
sudo wg show wg0 public-key
```

Copie la clé affichée dans le champ **Clé publique du serveur** du panneau des valeurs.

<Check cmd="ssh vps systemctl is-active wg-quick@wg0" expect="active" />

<Guided>`wg-quick@wg0` est un service systemd paramétré : `wg0` désigne le fichier `/etc/wireguard/wg0.conf`. `enable` le fait démarrer à chaque boot, y compris après les redémarrages automatiques des mises à jour de sécurité. `sudo wg show` affiche l'interface, son port, et pour chaque pair la date de la dernière poignée de main : c'est ta commande de diagnostic.</Guided>

<Deep>Un port WireGuard est muet : le serveur ne répond à aucun paquet qui n'est pas signé par une clé qu'il connaît. Un scanner qui sonde <V name="WG_PORT" />/udp ne reçoit rien, exactement comme sur un port fermé par le pare-feu. Changer le port n'apporte donc presque rien en discrétion, contrairement à SSH. Garde 51820 sauf si un réseau que tu fréquentes le bloque ; certains Wi-Fi d'hôtel ne laissent passer que quelques ports UDP, et 443/udp passe plus souvent.</Deep>

## Brancher le Mac

Dans l'app WireGuard du Mac, sélectionne le tunnel <V name="SSH_ALIAS" />, **Edit**. Garde la première ligne `PrivateKey = …` et ajoute ceci en dessous :

```ini title="Tunnel ${SSH_ALIAS} — app WireGuard du Mac"
Address = ${WG_MAC_ADDR}/32

[Peer]
PublicKey = ${SERVER_WG_PUBKEY}
AllowedIPs = ${WG_SERVER_ADDR}/32
Endpoint = ${SERVER_IP}:${WG_PORT}
```

Enregistre, puis **Activate**. Depuis un terminal :

<Check cmd="ping -c 3 ${WG_SERVER_ADDR} | grep -o '3 packets received'" expect="3 packets received" />

<Guided>`AllowedIPs = `<V name="WG_SERVER_ADDR" />`/32` : seul le trafic destiné au serveur entre dans le tunnel. Le reste de ta navigation sort normalement, par ta connexion. C'est voulu : le tunnel sert à atteindre la cave, pas à cacher ta navigation. `Endpoint`, c'est l'adresse publique où envoyer les paquets chiffrés.</Guided>

<Deep>Si le ping ne répond pas, regarde `sudo wg show` sur le serveur. Pas de ligne `latest handshake` pour le pair `mac` : les paquets n'arrivent pas (port UDP fermé, mauvais `Endpoint`, Network Firewall d'OVH), ou une clé publique est fausse d'un côté ou de l'autre. WireGuard ne signale jamais une clé fausse : il se tait. Un handshake présent mais pas de ping : l'`Address` ou l'`AllowedIPs` d'un côté ne correspond pas à l'autre. Pas de `PersistentKeepalive` : c'est toujours le Mac qui parle en premier, et le serveur n'a jamais besoin de le joindre de lui-même. Le tunnel reste ainsi silencieux quand tu ne t'en sers pas.</Deep>

<Details summary="Si le ping ne répond pas">

Dans l'ordre :

- Sur le serveur, `sudo wg show` : le pair `mac` a-t-il un `latest handshake` ? Sinon, compare les deux clés publiques, caractère par caractère.
- `sudo ufw status` doit montrer <V name="WG_PORT" />`/udp ALLOW`.
- Dans l'app, le tunnel est-il bien **Active** ? Les compteurs `Data received` augmentent-ils ?
- Le **Network Firewall** d'OVH, si tu l'as activé sur l'IP, filtre avant le VPS : ajoute une règle UDP pour <V name="WG_PORT" />.
- Un réseau qui bloque l'UDP sortant (certains Wi-Fi publics) : essaie depuis le partage de connexion de ton téléphone.

</Details>

## Passer par le tunnel

Ajoute à ton Mac un raccourci ssh qui vise l'adresse du serveur dans le tunnel, puis connecte-toi avec :

```bash on="mac"
touch ~/.ssh/config && chmod 600 ~/.ssh/config
grep -q '^Host ${SSH_ALIAS}$' ~/.ssh/config || printf '\nHost ${SSH_ALIAS}\n  HostName ${WG_SERVER_ADDR}\n  Port ${SSH_PORT}\n  User ${USERNAME}\n  IdentityFile ${SSH_KEY_FILE}\n  IdentitiesOnly yes\n  AddKeysToAgent yes\n  UseKeychain yes\n' >> ~/.ssh/config
ssh ${SSH_ALIAS} echo via-wireguard
```

ssh ne connaît pas encore cette adresse et demande de confirmer la clé d'hôte. Avant de répondre `yes`, compare l'empreinte affichée avec celle du serveur, lue par l'ancienne porte :

```bash on="mac"
ssh vps ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```

<Check cmd="ssh ${SSH_ALIAS} echo via-wireguard" expect="via-wireguard" />

<Guided>Tu as maintenant deux chemins vers le même serveur : `ssh vps` par internet, `ssh `<V name="SSH_ALIAS" /> par le tunnel. Le second a deux verrous indépendants : il faut la clé WireGuard du Mac pour entrer dans le tunnel, puis la clé SSH pour ouvrir une session.</Guided>

<Deep>La comparaison d'empreinte est presque une formalité ici : dans le tunnel, l'identité du serveur est déjà garantie par sa clé WireGuard, épinglée dans l'app. Personne ne peut s'interposer entre ton Mac et <V name="WG_SERVER_ADDR" /> sans la clé privée du serveur. La faire quand même garde le bon réflexe. ssh range l'empreinte sous une nouvelle entrée, `[`<V name="WG_SERVER_ADDR" />`]:`<V name="SSH_PORT" />, dans `~/.ssh/known_hosts`. `IdentitiesOnly yes` empêche ssh de présenter d'abord les autres clés du Mac ; avec plusieurs clés chargées dans l'agent, sshd couperait la connexion au-delà de `MaxAuthTries` (six essais par défaut).</Deep>

<When flag="PHONE">

## Ajouter le téléphone

Installe **WireGuard** (App Store ou Play Store, même éditeur). **+** → **Create from scratch**, nomme le tunnel <V name="SSH_ALIAS" />, touche **Generate keypair** et copie la clé publique dans le champ **Clé publique du téléphone** du panneau. Le bloc `[Peer]` du téléphone doit ensuite être dans `/etc/wireguard/wg0.conf` (étape « Écrire la configuration du serveur ») : recharge l'interface si tu l'ajoutes après coup.

```bash on="server" as="${USERNAME}"
sudo systemctl restart wg-quick@wg0
```

Dans l'app du téléphone, renseigne : **Addresses** <V name="WG_PHONE_ADDR" />`/32` ; puis **Add peer** : **Public key** <V name="SERVER_WG_PUBKEY" />, **Endpoint** <V name="SERVER_IP" />`:`<V name="WG_PORT" />, **Allowed IPs** <V name="WG_SERVER_ADDR" />`/32`. Enregistre et active.

<Guided>Le téléphone a sa propre clé, générée sur lui. Le jour où tu le perds, tu retires son bloc `[Peer]` du serveur et tu relances l'interface : le Mac n'est pas touché.</Guided>

</When>

## Terminé

| Quoi | Avant | Après cette page |
|---|---|---|
| Chemins vers le serveur | `ssh vps`, par internet | `ssh vps` **et** `ssh `<V name="SSH_ALIAS" />, par le tunnel |
| Ports ouverts | <V name="SSH_PORT" />/tcp | <V name="SSH_PORT" />/tcp et <V name="WG_PORT" />/udp |
| Appareils dans le tunnel | aucun | ton Mac<When flag="PHONE"> et ton téléphone</When>, chacun avec sa clé |

Page suivante : **Fermer la porte publique**. Elle retire <V name="SSH_PORT" />/tcp d'internet, ne garde SSH que sur le tunnel, et prouve que tout revient après un redémarrage.
````

````yaml title="content/private-vps-behind-wireguard/open-a-wireguard-tunnel/diagram.yaml"
# Quick: Mac, tunnel, server port, wg0, sshd. Guided: the firewall and the old public door still open.
# Deep: key files, cryptokey routing, the systemd unit.
title: { en: "A second way in, private", fr: "Une seconde entrée, privée" }
caption:
  en: "Your Mac reaches ${WG_SERVER_ADDR} through an encrypted UDP tunnel to ${SERVER_IP}:${WG_PORT}, then opens an SSH session inside it. The public SSH port is still open on this page."
  fr: "Ton Mac atteint ${WG_SERVER_ADDR} par un tunnel UDP chiffré vers ${SERVER_IP}:${WG_PORT}, puis ouvre une session SSH dedans. Le port SSH public est encore ouvert sur cette page."

groups:
  - id: devices
    label: { en: "Your devices", fr: "Tes appareils" }
    desc:
      en: "Each device holds its own WireGuard private key, generated on it, and the server's public key."
      fr: "Chaque appareil détient sa propre clé privée WireGuard, générée sur lui, et la clé publique du serveur."
  - id: server
    label: { en: "Your VPS", fr: "Ton VPS" }
    desc:
      en: "The hardened Debian VPS. Everything inside this box listens on ${SERVER_IP} or on the tunnel address ${WG_SERVER_ADDR}."
      fr: "Le VPS Debian durci. Tout ce qui est dans cette boîte écoute sur ${SERVER_IP} ou sur l'adresse tunnel ${WG_SERVER_ADDR}."

nodes:
  - id: mac
    kind: client
    label: { en: "Your Mac", fr: "Ton Mac" }
    sub: "ssh ${SSH_ALIAS}"
    in: devices
    desc:
      en: "The WireGuard app holds the tunnel ${SSH_ALIAS} (address ${WG_MAC_ADDR}); ~/.ssh/config holds the shortcut ${SSH_ALIAS} → ${WG_SERVER_ADDR}:${SSH_PORT}."
      fr: "L'app WireGuard détient le tunnel ${SSH_ALIAS} (adresse ${WG_MAC_ADDR}) ; ~/.ssh/config détient le raccourci ${SSH_ALIAS} → ${WG_SERVER_ADDR}:${SSH_PORT}."
    deep:
      sub: { en: "${WG_MAC_ADDR} · key in the keychain · AllowedIPs ${WG_SERVER_ADDR}/32", fr: "${WG_MAC_ADDR} · clé dans le trousseau · AllowedIPs ${WG_SERVER_ADDR}/32" }
  - id: phone
    kind: client
    label: { en: "Your phone", fr: "Ton téléphone" }
    sub: "${WG_PHONE_ADDR}"
    in: devices
    when: { flag: PHONE }
    desc:
      en: "A second peer with its own key and address. Revoked alone if lost."
      fr: "Un second pair avec sa propre clé et son adresse. Révoqué seul en cas de perte."
  - id: internet
    kind: cloud
    label: { en: "Internet", fr: "Internet" }
    sub: "UDP ${WG_PORT}"
    desc:
      en: "Carries only encrypted UDP packets between your devices and ${SERVER_IP}. Anyone on the path sees their size and timing, never their content."
      fr: "Ne transporte que des paquets UDP chiffrés entre tes appareils et ${SERVER_IP}. Qui est sur le chemin voit leur taille et leur rythme, jamais leur contenu."
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "${WG_PORT}/udp · ${SSH_PORT}/tcp"
    in: server
    level: guided
    desc:
      en: "ufw lets in ${WG_PORT}/udp (new) and ${SSH_PORT}/tcp (still public until page 2)."
      fr: "ufw laisse entrer ${WG_PORT}/udp (nouveau) et ${SSH_PORT}/tcp (encore public jusqu'à la page 2)."
  - id: wg0
    kind: net
    label: { en: "wg0", fr: "wg0" }
    sub: "${WG_SERVER_ADDR}"
    in: server
    focus: true
    desc:
      en: "The WireGuard interface. Decrypts packets from known keys only and drops everything else without a reply."
      fr: "L'interface WireGuard. Ne déchiffre que les paquets des clés connues et jette tout le reste sans répondre."
    guided:
      sub: "${WG_SERVER_ADDR}/24 · :${WG_PORT}/udp"
    deep:
      sub: "wg-quick@wg0 · /etc/wireguard/wg0.conf · key in wg0.key (600)"
  - id: sshd
    kind: server
    label: { en: "sshd", fr: "sshd" }
    sub: ":${SSH_PORT}"
    in: server
    desc:
      en: "The same SSH daemon as before, now reached at ${WG_SERVER_ADDR} from inside the tunnel, and still at ${SERVER_IP} from outside."
      fr: "Le même démon SSH qu'avant, atteint maintenant en ${WG_SERVER_ADDR} depuis le tunnel, et encore en ${SERVER_IP} depuis l'extérieur."
  - id: keyfile
    kind: file
    label: { en: "wg0.key", fr: "wg0.key" }
    sub: "/etc/wireguard"
    in: server
    level: deep
    desc:
      en: "The server's private key, mode 600, read by PostUp when the interface comes up. The configuration file itself holds no secret."
      fr: "La clé privée du serveur, droits 600, lue par PostUp au montage de l'interface. Le fichier de configuration ne contient aucun secret."

edges:
  - { from: mac, to: internet, label: "WireGuard", desc: { en: "Encrypted with the Mac's key, addressed to ${SERVER_IP}:${WG_PORT}.", fr: "Chiffré avec la clé du Mac, adressé à ${SERVER_IP}:${WG_PORT}." } }
  - { from: phone, to: internet, label: "WireGuard", when: { flag: PHONE }, desc: { en: "Same tunnel, the phone's own key.", fr: "Même tunnel, la propre clé du téléphone." } }
  - { from: internet, to: wg0, label: "UDP ${WG_PORT}", max: quick, desc: { en: "The encrypted packets reach the WireGuard interface.", fr: "Les paquets chiffrés atteignent l'interface WireGuard." } }
  - { from: internet, to: firewall, label: "UDP ${WG_PORT}", level: guided, desc: { en: "The firewall lets the tunnel's UDP port in.", fr: "Le pare-feu laisse entrer le port UDP du tunnel." } }
  - { from: firewall, to: wg0, level: guided, desc: { en: "Only packets signed by a known key are decrypted.", fr: "Seuls les paquets signés par une clé connue sont déchiffrés." } }
  - { from: wg0, to: sshd, label: "TCP ${SSH_PORT}", desc: { en: "Inside the tunnel, ssh ${SSH_ALIAS} reaches sshd at ${WG_SERVER_ADDR}:${SSH_PORT}.", fr: "Dans le tunnel, ssh ${SSH_ALIAS} atteint sshd en ${WG_SERVER_ADDR}:${SSH_PORT}." } }
  - { from: firewall, to: sshd, label: "${SSH_PORT}/tcp public", dashed: true, level: guided, desc: { en: "The old public door, still open on this page. Page 2 closes it.", fr: "L'ancienne porte publique, encore ouverte sur cette page. La page 2 la ferme." } }
  - { from: keyfile, to: wg0, dashed: true, level: deep, desc: { en: "PostUp = wg set %i private-key /etc/wireguard/%i.key", fr: "PostUp = wg set %i private-key /etc/wireguard/%i.key" } }
````

---

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