# Source of "Configurer NetBox pour ton équipe"

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

````yaml title="content/netbox/series.yaml"
title:
  en: NetBox
  fr: NetBox
summary:
  en: >-
    NetBox is the source of truth for your network: devices, IPs, cables, circuits.
    This series takes it from a blank server to something you maintain and automate against.
  fr: >-
    NetBox est la source de vérité de ton réseau : équipements, IP, câbles, circuits.
    Cette série part d'un serveur vierge et va jusqu'au maintien et à l'automatisation.
order:
  - install-from-scratch
  - configure-for-your-team
  - maintain-and-upgrade
  - automate-with-the-api

# Shared by every page: the reader fills these once for the whole series.
groups:
  - id: host
    label: { en: Server, fr: Serveur }
    desc: { en: Where NetBox runs and how it is reached., fr: Où tourne NetBox et comment on l'atteint. }
  - id: auth
    label: { en: Directory, fr: Annuaire }
    desc: { en: Your LDAP or Active Directory., fr: Ton LDAP ou Active Directory. }
    when: { flag: LDAP }

vars:
  - key: NETBOX_HOST
    kind: hostname
    group: host
    default: netbox.example.com
    label: { en: NetBox hostname, fr: Nom d'hôte de NetBox }
    hint:
      en: The address users will type in their browser to reach NetBox. It must already point to this server in your DNS.
      fr: L'adresse que les utilisateurs taperont dans le navigateur pour atteindre NetBox. Elle doit déjà pointer vers ce serveur dans ton DNS.
    impact:
      en: Written into ALLOWED_HOSTS (Django refuses any other name with a 400 error), into the web server configuration and into the TLS certificate. Several names are possible, separated by commas.
      fr: Écrit dans ALLOWED_HOSTS (Django refuse tout autre nom avec une erreur 400), dans la configuration du serveur web et dans le certificat TLS. Plusieurs noms sont possibles, séparés par des virgules.
  - key: INSTALL_DIR
    kind: path
    group: host
    default: /opt/netbox
    label: { en: Install directory, fr: Répertoire d'installation }
    hint:
      en: The folder where NetBox's code and Python environment live. Keep the default unless your organisation has a rule about it.
      fr: Le dossier où vivent le code de NetBox et son environnement Python. Garde la valeur par défaut sauf règle interne.
    impact:
      en: The systemd units, the upgrade script and the web server configuration shipped with NetBox all assume /opt/netbox. Changing it means editing those files too.
      fr: Les unités systemd, le script d'upgrade et la configuration du serveur web livrés avec NetBox supposent tous /opt/netbox. Le changer oblige à éditer ces fichiers aussi.
  - key: LDAP_URI
    kind: text
    group: auth
    default: ldaps://ad.example.com:636
    label: { en: LDAP server URI, fr: URI du serveur LDAP }
    hint:
      en: Address of your directory server, with the protocol. Use ldaps:// (port 636) so credentials travel encrypted.
      fr: Adresse de ton serveur d'annuaire, avec le protocole. Utilise ldaps:// (port 636) pour que les identifiants circulent chiffrés.
    impact:
      en: Every login makes NetBox contact this address. If it is unreachable, nobody can log in except local accounts such as the first administrator.
      fr: Chaque connexion fait contacter cette adresse par NetBox. Si elle est injoignable, plus personne ne peut se connecter sauf les comptes locaux comme le premier administrateur.
  - key: LDAP_BIND_DN
    kind: text
    group: auth
    default: CN=netbox,OU=Service Accounts,DC=example,DC=com
    label: { en: Bind DN, fr: DN de bind }
    hint:
      en: The directory account NetBox uses to search for users before checking their password. A dedicated read-only service account, written as a full distinguished name.
      fr: Le compte d'annuaire que NetBox utilise pour chercher les utilisateurs avant de vérifier leur mot de passe. Un compte de service dédié en lecture seule, écrit sous forme de DN complet.
    impact:
      en: Its password goes into ldap_config.py. Give it read access to the users and groups subtrees only.
      fr: Son mot de passe va dans ldap_config.py. Donne-lui un accès en lecture aux sous-arbres utilisateurs et groupes uniquement.
  - key: LDAP_BASE_DN
    kind: text
    group: auth
    default: DC=example,DC=com
    label: { en: User search base, fr: Base de recherche utilisateurs }
    hint:
      en: Where in the directory tree NetBox looks for people. Usually the domain root, or a narrower OU if only some staff should log in.
      fr: Où, dans l'arbre de l'annuaire, NetBox cherche les personnes. En général la racine du domaine, ou une OU plus étroite si seule une partie du personnel doit se connecter.
    impact:
      en: A user outside this subtree simply cannot log in, with no explicit error. Combine with a group filter for finer control.
      fr: Un utilisateur hors de ce sous-arbre ne peut simplement pas se connecter, sans erreur explicite. Combine avec un filtre de groupe pour un contrôle plus fin.

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: WEB
    type: select
    label: { en: Web server, fr: Serveur web }
    default: nginx
    options:
      - { value: nginx, label: { en: nginx, fr: nginx } }
      - { value: apache, label: { en: Apache, fr: Apache } }
  - key: LDAP
    type: boolean
    label: { en: Authenticate against LDAP / Active Directory, fr: Authentifier via LDAP / Active Directory }
    default: false
````

````yaml title="content/netbox/configure-for-your-team/tuto.yaml"
# Inherits from ../series.yaml: NETBOX_HOST, INSTALL_DIR, OS, WEB, LDAP and the host/auth groups.

title:
  en: Configure NetBox for your team
  fr: Configurer NetBox pour ton équipe
summary:
  en: >-
    The settings you want before opening NetBox to colleagues: time zone and banners, outgoing
    mail, groups and permissions, the first plugins, and the custom fields your data needs.
  fr: >-
    Les réglages à faire avant d'ouvrir NetBox aux collègues : fuseau et bandeaux, envoi de mail,
    groupes et permissions, premiers plugins, et les champs personnalisés dont tes données ont besoin.
difficulty: intermediate
tags: [netbox, permissions, plugins, api]
authors: [thudal]
created: 2026-09-24
minutes: 40
validated: NetBox 4.4 · Ubuntu 24.04
status: draft             # not yet run end to end by its author

groups:
  - id: mail
    label: { en: Outgoing mail, fr: Envoi de mail }
    desc: { en: Only used when mail is enabled., fr: Utilisé seulement si le mail est activé. }
    when: { flag: MAIL }
  - id: api
    label: { en: API access, fr: Accès API }

vars:
  - key: TIMEZONE
    kind: text
    group: host
    default: Europe/Paris
    label: { en: Time zone, fr: Fuseau horaire }
    hint:
      en: The zone in which NetBox shows dates and times, as a tz database name.
      fr: Le fuseau dans lequel NetBox affiche dates et heures, sous forme de nom tz.
    impact:
      en: Applies to the whole instance, including the change log and scheduled jobs. Users can override it in their preferences.
      fr: S'applique à toute l'instance, journal des changements et tâches planifiées compris. Chaque utilisateur peut le surcharger dans ses préférences.
  - key: BANNER
    kind: text
    group: host
    default: Lab instance — data may be reset
    label: { en: Top banner, fr: Bandeau du haut }
    hint:
      en: A short line shown on every page. Use it to tell staging from production at a glance. Leave empty for none.
      fr: Une ligne courte affichée sur chaque page. Sert à distinguer la préprod de la prod d'un coup d'œil. Vide pour ne rien afficher.
  - key: SMTP_HOST
    kind: hostname
    group: mail
    default: smtp.example.com
    label: { en: SMTP server, fr: Serveur SMTP }
    hint:
      en: The mail relay NetBox hands its messages to. It must accept mail from this server's IP.
      fr: Le relais mail auquel NetBox confie ses messages. Il doit accepter le courrier venant de l'IP de ce serveur.
  - key: SMTP_PORT
    kind: port
    group: mail
    default: "587"
    label: { en: SMTP port, fr: Port SMTP }
    hint:
      en: 587 for submission with STARTTLS (the usual choice), 25 for a relay on the local network.
      fr: 587 pour la soumission avec STARTTLS (le choix habituel), 25 pour un relais sur le réseau local.
  - key: FROM_EMAIL
    kind: email
    group: mail
    default: netbox@example.com
    label: { en: Sender address, fr: Adresse d'expédition }
    hint:
      en: What recipients see in the From field. Some relays refuse addresses outside their own domain.
      fr: Ce que les destinataires voient dans le champ From. Certains relais refusent les adresses hors de leur domaine.
  - key: NETBOX_TOKEN
    kind: secret
    group: api
    default: ""
    label: { en: API token, fr: Jeton d'API }
    hint:
      en: A token created in NetBox (your profile → API tokens). It lets the commands on this page change settings through the REST API instead of clicking.
      fr: Un jeton créé dans NetBox (ton profil → jetons d'API). Il permet aux commandes de cette page de modifier les réglages via l'API REST plutôt qu'en cliquant.
    impact:
      en: A token carries the permissions of the user who created it. Create it with an expiry date, and revoke it when this page is done.
      fr: Un jeton porte les permissions de l'utilisateur qui l'a créé. Crée-le avec une date d'expiration, et révoque-le une fois cette page terminée.
  - key: LDAP_ADMIN_GROUP
    kind: text
    group: auth
    default: CN=netbox-admins,OU=Groups,DC=example,DC=com
    label: { en: Administrators group, fr: Groupe des administrateurs }
    hint:
      en: The directory group whose members become NetBox superusers, as a full distinguished name.
      fr: Le groupe d'annuaire dont les membres deviennent superutilisateurs NetBox, sous forme de DN complet.
    impact:
      en: Membership is checked at each login. Removing someone from the group removes their rights at their next login, not immediately.
      fr: L'appartenance est vérifiée à chaque connexion. Retirer quelqu'un du groupe lui retire ses droits à sa prochaine connexion, pas immédiatement.

choices:
  - key: MAIL
    type: boolean
    label: { en: Send email (password resets, alerts), fr: Envoyer des emails (réinitialisations, alertes) }
    default: true
  - key: PLUGIN_TOPOLOGY
    type: boolean
    label: { en: Plugin — topology views, fr: Plugin — vues de topologie }
    hint: { en: Draws your cabling as diagrams., fr: Dessine ton câblage en schémas. }
    default: true
  - key: PLUGIN_BGP
    type: boolean
    label: { en: Plugin — BGP, fr: Plugin — BGP }
    hint: { en: Models sessions, peer groups and routing policies., fr: Modélise sessions, groupes de pairs et politiques de routage. }
    default: false
````

````mdx title="content/netbox/configure-for-your-team/page-en.mdx"
{/* First pass — to be validated against docs.netbox.dev (configuration parameters, plugins, REST API) before publishing. */}

The previous page left you with a working NetBox and one administrator. Before colleagues get the URL, a handful of settings turn it from an install into *your* instance. Most of them live in `configuration.py`; the rest is done through the REST API, so you can replay it on the next instance.

<Run>

The script applies every setting of this page to **<V name="NETBOX_HOST" />** and restarts NetBox. It appends to `configuration.py` (never rewrites it) and needs a valid API token in your values.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — team configuration for ${NETBOX_HOST}
CFG=${INSTALL_DIR}/netbox/netbox/configuration.py
API=https://${NETBOX_HOST}/api
AUTH="Authorization: Token ${NETBOX_TOKEN}"

sudo tee -a "$CFG" >/dev/null <<'EOF'

# --- team settings
TIME_ZONE = '${TIMEZONE}'
BANNER_TOP = '${BANNER}'
LOGIN_REQUIRED = True
CHANGELOG_RETENTION = 180
EOF
```

<When flag="MAIL">

```bash
sudo tee -a "$CFG" >/dev/null <<'EOF'
EMAIL = {
    'SERVER': '${SMTP_HOST}',
    'PORT': ${SMTP_PORT},
    'USE_TLS': True,
    'FROM_EMAIL': '${FROM_EMAIL}',
    'TIMEOUT': 10,
}
EOF
```

</When>

<When flag="PLUGIN_TOPOLOGY">

```bash
echo netbox-topology-views | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When flag="PLUGIN_BGP">

```bash
echo netbox-bgp | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

```bash
PLUGINS=()
grep -q '^netbox-topology-views' ${INSTALL_DIR}/local_requirements.txt && PLUGINS+=("'netbox_topology_views'")
grep -q '^netbox-bgp' ${INSTALL_DIR}/local_requirements.txt && PLUGINS+=("'netbox_bgp'")
echo "PLUGINS = [$(IFS=,; echo "${PLUGINS[*]}")]" | sudo tee -a "$CFG"
sudo ${INSTALL_DIR}/upgrade.sh
sudo systemctl restart netbox netbox-rq
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "netops"}'
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "readers"}'
echo "Done. Permissions for the groups are set in the UI: Admin → Permissions."
```

</Run>

## Before you start

<Guided>You need the administrator account from the previous page and, for the API steps, a token: log in, open your profile (top right), **API tokens → Add**, give it an expiry date and copy it into your values. The commands below run on the NetBox server itself.</Guided>

<Deep>Everything on this page could be clicked through the web interface. We do it with the API on purpose: a `curl` command is reviewable, repeatable on the staging instance, and it is the first step toward keeping NetBox's configuration in code, which the last page of this series is about. Settings that only exist in `configuration.py` are edited there; NetBox reloads it on restart.</Deep>

<Check cmd="curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/status/ | python3 -m json.tool | head -3" expect='{
    "django-version": "5.x",' />

## Instance settings

<Guided>Four lines set the tone: the time zone, a banner that tells people which instance they are on, mandatory login, and how long the change log is kept.</Guided>

Append to <V name="INSTALL_DIR" />`/netbox/netbox/configuration.py`:

```python
TIME_ZONE = '${TIMEZONE}'
BANNER_TOP = '${BANNER}'
LOGIN_REQUIRED = True
CHANGELOG_RETENTION = 180
```

<Deep>`LOGIN_REQUIRED` defaults to `False`: an anonymous visitor can read everything. That is rarely what you want for a source of truth that lists every device and IP you own. `CHANGELOG_RETENTION` is in days; `0` keeps changes forever, which is fine for a small team and costly after a few years of automation writing thousands of objects a day.</Deep>

<Note>Other parameters worth a look in the same file: `LOGIN_BANNER` (a notice on the login page, useful for legal wording), `ALLOWED_URL_SCHEMES` if your custom links use `ssh://`, and `MAINTENANCE_MODE` for planned work.</Note>

<When flag="MAIL">

## Outgoing mail

<Guided>NetBox sends mail for password resets and for the notifications you will set up later. It only needs a relay.</Guided>

```python
EMAIL = {
    'SERVER': '${SMTP_HOST}',
    'PORT': ${SMTP_PORT},
    'USE_TLS': True,
    'FROM_EMAIL': '${FROM_EMAIL}',
    'TIMEOUT': 10,
}
```

<Deep>Add `USERNAME` and `PASSWORD` keys if the relay authenticates. `USE_TLS` means STARTTLS on port 587; for an implicit-TLS relay on 465 use `USE_SSL` instead. `TIMEOUT` matters: without it a dead relay makes password resets hang for minutes.</Deep>

After a restart (next section), send a test message to yourself:

<Check cmd="sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py sendtestemail you@example.com" expect="Test email sent to: you@example.com" />

</When>

## Groups and permissions

<Guided>NetBox permissions are granted to groups, per object type and per action (view, add, change, delete), optionally restricted by a filter. Two groups cover most teams: one that can change everything, one that can only read.</Guided>

```bash
API=https://${NETBOX_HOST}/api
AUTH="Authorization: Token ${NETBOX_TOKEN}"
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "netops"}'
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "readers"}'
```

<Guided>Permissions themselves are easier to set once in the interface: **Admin → Permissions → Add**. For `netops`, tick all four actions and leave object types empty (all). For `readers`, tick *view* only.</Guided>

<Deep>A permission's *constraints* field takes a JSON filter evaluated against the object. `{"site__name": "Paris"}` on a change permission lets a group edit objects of one site only. That is how you delegate a campus to a local team without giving them the rest of the network.</Deep>

<When flag="LDAP">

### Map directory groups

<Guided>With LDAP, rights should follow directory membership rather than be assigned by hand. Two settings in `ldap_config.py` do it: superuser status from one group, and NetBox group membership mirrored from the directory.</Guided>

```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

AUTH_LDAP_GROUP_SEARCH = LDAPSearch('${LDAP_BASE_DN}', ldap.SCOPE_SUBTREE, '(objectClass=group)')
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    'is_active': '${LDAP_BASE_DN}',
    'is_staff': '${LDAP_ADMIN_GROUP}',
    'is_superuser': '${LDAP_ADMIN_GROUP}',
}
AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_MIRROR_GROUPS = True
```

<Deep>`AUTH_LDAP_MIRROR_GROUPS` creates a NetBox group for each directory group the user belongs to, named after the group's CN. Create the `netops` and `readers` groups in the directory with those exact names and the permissions you set above apply automatically. For Active Directory, `NestedActiveDirectoryGroupType` resolves nested groups; it costs one extra query per login.</Deep>

</When>

<When notFlag="LDAP">

<Guided>Without a directory, invite people from **Admin → Users → Add**, and put them in a group. Tick *Staff status* only for those who administer NetBox itself.</Guided>

</When>

## Plugins

<Guided>Plugins are Python packages listed in `local_requirements.txt` (so `upgrade.sh` reinstalls them at every upgrade) and enabled in `PLUGINS`. Start with few; each one adds tables and migrations.</Guided>

<When flag="PLUGIN_TOPOLOGY">

```bash
echo netbox-topology-views | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When flag="PLUGIN_BGP">

```bash
echo netbox-bgp | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When notFlag="PLUGIN_TOPOLOGY">

<When notFlag="PLUGIN_BGP">

<Note>No plugin selected in your stack. The steps below still apply when you add one later.</Note>

</When>

</When>

Enable them in `configuration.py` (module names use underscores):

<When flag="PLUGIN_TOPOLOGY">

<When flag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_topology_views', 'netbox_bgp']
```

</When>

<When notFlag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_topology_views']
```

</When>

</When>

<When notFlag="PLUGIN_TOPOLOGY">

<When flag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_bgp']
```

</When>

</When>

Then install and migrate:

```bash
sudo ${INSTALL_DIR}/upgrade.sh
```

<Deep>`upgrade.sh` reads `local_requirements.txt`, installs into the virtual environment, runs the plugins' migrations and collects their static files. Skipping it and running `pip install` by hand works once, then breaks at the next NetBox upgrade when the venv is rebuilt. Check each plugin's compatibility matrix: a plugin lagging one minor version behind NetBox is the most common cause of a failed upgrade.</Deep>

## Custom fields

<Guided>The data model covers networks well; what it does not know is your organisation. Custom fields attach your own attributes to any object type. A classic first one: an asset tag on devices.</Guided>

```bash
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/extras/custom-fields/" -d '{
  "name": "asset_tag_internal",
  "label": "Internal asset tag",
  "type": "text",
  "object_types": ["dcim.device"],
  "required": false,
  "filter_logic": "exact"
}'
```

<Deep>Custom fields are indexed and filterable like native ones, and they appear in the API and in exports. Prefer them to free-text comments as soon as a value will be searched or automated on. Types include text, integer, boolean, date, URL, JSON, selection (with a choice set) and object (a reference to another NetBox object).</Deep>

## Restart and verify

```bash
sudo systemctl restart netbox netbox-rq
```

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

<Check
  cmd={"curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/users/groups/ | python3 -c 'import sys,json; print([g[\"name\"] for g in json.load(sys.stdin)[\"results\"]])'"}
  expect="['netops', 'readers']"
/>

<When flag="PLUGIN_TOPOLOGY">

<Check cmd={"curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/plugins/ | grep -o '\"name\": \"[^\"]*\"'"} expect='"name": "Topology views"' />

</When>

## Done

Your instance has a name on every page, sends mail, knows who may change what, and carries the first fields your data needs. Next: keeping it that way, with upgrades, backups and monitoring.
````

````mdx title="content/netbox/configure-for-your-team/page-fr.mdx"
{/* Première passe — à valider contre docs.netbox.dev (paramètres de configuration, plugins, API REST) avant publication. */}

La page précédente t'a laissé un NetBox qui tourne et un administrateur. Avant que les collègues reçoivent l'URL, une poignée de réglages transforment une installation en *ton* instance. La plupart vivent dans `configuration.py` ; le reste passe par l'API REST, pour pouvoir le rejouer sur la prochaine instance.

<Run>

Le script applique tous les réglages de cette page à **<V name="NETBOX_HOST" />** et redémarre NetBox. Il ajoute à la fin de `configuration.py` (ne le réécrit jamais) et a besoin d'un jeton d'API valide dans tes valeurs.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — configuration d'équipe pour ${NETBOX_HOST}
CFG=${INSTALL_DIR}/netbox/netbox/configuration.py
API=https://${NETBOX_HOST}/api
AUTH="Authorization: Token ${NETBOX_TOKEN}"

sudo tee -a "$CFG" >/dev/null <<'EOF'

# --- réglages d'équipe
TIME_ZONE = '${TIMEZONE}'
BANNER_TOP = '${BANNER}'
LOGIN_REQUIRED = True
CHANGELOG_RETENTION = 180
EOF
```

<When flag="MAIL">

```bash
sudo tee -a "$CFG" >/dev/null <<'EOF'
EMAIL = {
    'SERVER': '${SMTP_HOST}',
    'PORT': ${SMTP_PORT},
    'USE_TLS': True,
    'FROM_EMAIL': '${FROM_EMAIL}',
    'TIMEOUT': 10,
}
EOF
```

</When>

<When flag="PLUGIN_TOPOLOGY">

```bash
echo netbox-topology-views | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When flag="PLUGIN_BGP">

```bash
echo netbox-bgp | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

```bash
PLUGINS=()
grep -q '^netbox-topology-views' ${INSTALL_DIR}/local_requirements.txt && PLUGINS+=("'netbox_topology_views'")
grep -q '^netbox-bgp' ${INSTALL_DIR}/local_requirements.txt && PLUGINS+=("'netbox_bgp'")
echo "PLUGINS = [$(IFS=,; echo "${PLUGINS[*]}")]" | sudo tee -a "$CFG"
sudo ${INSTALL_DIR}/upgrade.sh
sudo systemctl restart netbox netbox-rq
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "netops"}'
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "readers"}'
echo "Terminé. Les permissions des groupes se règlent dans l'interface : Admin → Permissions."
```

</Run>

## Avant de commencer

<Guided>Il te faut le compte administrateur de la page précédente et, pour les étapes API, un jeton : connecte-toi, ouvre ton profil (en haut à droite), **Jetons d'API → Ajouter**, donne-lui une date d'expiration et copie-le dans tes valeurs. Les commandes ci-dessous s'exécutent sur le serveur NetBox lui-même.</Guided>

<Deep>Tout ce qui est sur cette page pourrait se faire en cliquant dans l'interface. On passe par l'API volontairement : une commande `curl` se relit, se rejoue sur l'instance de préprod, et c'est le premier pas vers une configuration de NetBox gardée dans du code, sujet de la dernière page de cette série. Les réglages qui n'existent que dans `configuration.py` s'éditent là ; NetBox le relit au redémarrage.</Deep>

<Check cmd="curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/status/ | python3 -m json.tool | head -3" expect='{
    "django-version": "5.x",' />

## Réglages de l'instance

<Guided>Quatre lignes donnent le ton : le fuseau horaire, un bandeau qui dit aux gens sur quelle instance ils sont, la connexion obligatoire, et la durée de conservation du journal des changements.</Guided>

Ajoute à la fin de <V name="INSTALL_DIR" />`/netbox/netbox/configuration.py` :

```python
TIME_ZONE = '${TIMEZONE}'
BANNER_TOP = '${BANNER}'
LOGIN_REQUIRED = True
CHANGELOG_RETENTION = 180
```

<Deep>`LOGIN_REQUIRED` vaut `False` par défaut : un visiteur anonyme peut tout lire. C'est rarement ce qu'on veut pour une source de vérité qui liste chaque équipement et chaque IP. `CHANGELOG_RETENTION` est en jours ; `0` garde tout pour toujours, ce qui convient à une petite équipe et coûte cher après quelques années d'automatisation écrivant des milliers d'objets par jour.</Deep>

<Note>D'autres paramètres méritent un coup d'œil dans le même fichier : `LOGIN_BANNER` (un avis sur la page de connexion, utile pour une mention légale), `ALLOWED_URL_SCHEMES` si tes liens personnalisés utilisent `ssh://`, et `MAINTENANCE_MODE` pour les interventions planifiées.</Note>

<When flag="MAIL">

## Envoi de mail

<Guided>NetBox envoie des mails pour les réinitialisations de mot de passe et pour les notifications que tu configureras plus tard. Il lui faut seulement un relais.</Guided>

```python
EMAIL = {
    'SERVER': '${SMTP_HOST}',
    'PORT': ${SMTP_PORT},
    'USE_TLS': True,
    'FROM_EMAIL': '${FROM_EMAIL}',
    'TIMEOUT': 10,
}
```

<Deep>Ajoute les clés `USERNAME` et `PASSWORD` si le relais authentifie. `USE_TLS` signifie STARTTLS sur le port 587 ; pour un relais en TLS implicite sur 465, utilise `USE_SSL` à la place. `TIMEOUT` compte : sans lui, un relais mort fait pendre les réinitialisations de mot de passe pendant des minutes.</Deep>

Après redémarrage (section suivante), envoie-toi un message de test :

<Check cmd="sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py sendtestemail toi@example.com" expect="Test email sent to: toi@example.com" />

</When>

## Groupes et permissions

<Guided>Les permissions NetBox s'accordent à des groupes, par type d'objet et par action (voir, ajouter, modifier, supprimer), avec un filtre optionnel. Deux groupes couvrent la plupart des équipes : un qui peut tout modifier, un qui ne fait que lire.</Guided>

```bash
API=https://${NETBOX_HOST}/api
AUTH="Authorization: Token ${NETBOX_TOKEN}"
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "netops"}'
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/users/groups/" -d '{"name": "readers"}'
```

<Guided>Les permissions elles-mêmes se règlent plus simplement une fois dans l'interface : **Admin → Permissions → Ajouter**. Pour `netops`, coche les quatre actions et laisse les types d'objets vides (tous). Pour `readers`, coche *view* seulement.</Guided>

<Deep>Le champ *constraints* d'une permission prend un filtre JSON évalué sur l'objet. `{"site__name": "Paris"}` sur une permission de modification laisse un groupe éditer les objets d'un seul site. C'est ainsi qu'on délègue un campus à une équipe locale sans lui donner le reste du réseau.</Deep>

<When flag="LDAP">

### Relier les groupes de l'annuaire

<Guided>Avec LDAP, les droits doivent suivre l'appartenance aux groupes de l'annuaire plutôt qu'être attribués à la main. Deux réglages dans `ldap_config.py` s'en chargent : le statut superutilisateur depuis un groupe, et l'appartenance aux groupes NetBox reflétée depuis l'annuaire.</Guided>

```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

AUTH_LDAP_GROUP_SEARCH = LDAPSearch('${LDAP_BASE_DN}', ldap.SCOPE_SUBTREE, '(objectClass=group)')
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    'is_active': '${LDAP_BASE_DN}',
    'is_staff': '${LDAP_ADMIN_GROUP}',
    'is_superuser': '${LDAP_ADMIN_GROUP}',
}
AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_MIRROR_GROUPS = True
```

<Deep>`AUTH_LDAP_MIRROR_GROUPS` crée un groupe NetBox pour chaque groupe d'annuaire de l'utilisateur, nommé d'après le CN du groupe. Crée les groupes `netops` et `readers` dans l'annuaire avec exactement ces noms et les permissions réglées plus haut s'appliquent automatiquement. Pour Active Directory, `NestedActiveDirectoryGroupType` résout les groupes imbriqués ; ça coûte une requête de plus par connexion.</Deep>

</When>

<When notFlag="LDAP">

<Guided>Sans annuaire, invite les gens depuis **Admin → Utilisateurs → Ajouter**, et place-les dans un groupe. Coche *Staff status* seulement pour ceux qui administrent NetBox lui-même.</Guided>

</When>

## Plugins

<Guided>Les plugins sont des paquets Python listés dans `local_requirements.txt` (pour qu'`upgrade.sh` les réinstalle à chaque mise à jour) et activés dans `PLUGINS`. Commence avec peu ; chacun ajoute des tables et des migrations.</Guided>

<When flag="PLUGIN_TOPOLOGY">

```bash
echo netbox-topology-views | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When flag="PLUGIN_BGP">

```bash
echo netbox-bgp | sudo tee -a ${INSTALL_DIR}/local_requirements.txt
```

</When>

<When notFlag="PLUGIN_TOPOLOGY">

<When notFlag="PLUGIN_BGP">

<Note>Aucun plugin sélectionné dans ta stack. Les étapes ci-dessous restent valables quand tu en ajouteras un.</Note>

</When>

</When>

Active-les dans `configuration.py` (les noms de modules utilisent des tirets bas) :

<When flag="PLUGIN_TOPOLOGY">

<When flag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_topology_views', 'netbox_bgp']
```

</When>

<When notFlag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_topology_views']
```

</When>

</When>

<When notFlag="PLUGIN_TOPOLOGY">

<When flag="PLUGIN_BGP">

```python
PLUGINS = ['netbox_bgp']
```

</When>

</When>

Puis installe et migre :

```bash
sudo ${INSTALL_DIR}/upgrade.sh
```

<Deep>`upgrade.sh` lit `local_requirements.txt`, installe dans l'environnement virtuel, exécute les migrations des plugins et collecte leurs fichiers statiques. Le sauter et faire `pip install` à la main marche une fois, puis casse à la mise à jour suivante de NetBox, quand le venv est reconstruit. Vérifie la matrice de compatibilité de chaque plugin : un plugin en retard d'une version mineure sur NetBox est la cause la plus fréquente d'une mise à jour ratée.</Deep>

## Champs personnalisés

<Guided>Le modèle de données couvre bien les réseaux ; ce qu'il ne connaît pas, c'est ton organisation. Les champs personnalisés attachent tes propres attributs à n'importe quel type d'objet. Un premier classique : un numéro d'inventaire sur les équipements.</Guided>

```bash
curl -sf -H "$AUTH" -H 'Content-Type: application/json' "$API/extras/custom-fields/" -d '{
  "name": "asset_tag_internal",
  "label": "Numéro d'\''inventaire interne",
  "type": "text",
  "object_types": ["dcim.device"],
  "required": false,
  "filter_logic": "exact"
}'
```

<Deep>Les champs personnalisés sont indexés et filtrables comme les natifs, et ils apparaissent dans l'API et les exports. Préfère-les aux commentaires libres dès qu'une valeur sera cherchée ou automatisée. Les types incluent texte, entier, booléen, date, URL, JSON, sélection (avec un jeu de choix) et objet (une référence à un autre objet NetBox).</Deep>

## Redémarrer et vérifier

```bash
sudo systemctl restart netbox netbox-rq
```

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

<Check
  cmd={"curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/users/groups/ | python3 -c 'import sys,json; print([g[\"name\"] for g in json.load(sys.stdin)[\"results\"]])'"}
  expect="['netops', 'readers']"
/>

<When flag="PLUGIN_TOPOLOGY">

<Check cmd={"curl -sf -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/plugins/ | grep -o '\"name\": \"[^\"]*\"'"} expect='"name": "Topology views"' />

</When>

## Terminé

Ton instance a un nom sur chaque page, envoie des mails, sait qui peut modifier quoi, et porte les premiers champs dont tes données ont besoin. Suite : la garder dans cet état, avec mises à jour, sauvegardes et supervision.
````

````yaml title="content/netbox/configure-for-your-team/diagram.yaml"
# Quick: you, the API, the config file and what you switch on. Guided adds
# the restart that makes configuration.py count. Deep spells out the token,
# the transports, and where the plugins land.
# `desc` is what the reader gets when hovering a box or an arrow.
title: { en: "You, the API, and what you switch on", fr: "Toi, l'API, et ce que tu actives" }
caption:
  en: "Most of this page goes through the REST API with a token, so it can be replayed on the next instance. Mail, LDAP groups and plugins are optional pieces you decide on in the margin."
  fr: "L'essentiel de cette page passe par l'API REST avec un jeton, pour pouvoir être rejoué sur la prochaine instance. Mail, groupes LDAP et plugins sont des briques optionnelles que tu décides dans la marge."

groups:
  - id: server
    label: { en: "NetBox server", fr: "Serveur NetBox" }
    desc:
      en: "The instance from the previous page. Two ways in: the API for data, SSH for the one file the API cannot change."
      fr: "L'instance de la page précédente. Deux entrées : l'API pour les données, SSH pour le seul fichier que l'API ne peut pas changer."

nodes:
  - id: you
    kind: user
    label: { en: "You (admin)", fr: "Toi (admin)" }
    sub: "curl · token"
    desc:
      en: "The superuser created at install, with an API token. Everything you do here is a command you can keep and replay."
      fr: "Le superutilisateur créé à l'installation, avec un jeton d'API. Tout ce que tu fais ici est une commande que tu peux garder et rejouer."
    deep:
      sub: "curl · Authorization: Token … · JSON"
  - id: api
    kind: net
    label: { en: "REST API", fr: "API REST" }
    sub: "https://${NETBOX_HOST}/api"
    in: server
    desc:
      en: "Same rights as the web interface, as JSON. Groups, permissions, custom fields: all of it is objects you POST."
      fr: "Les mêmes droits que l'interface web, en JSON. Groupes, permissions, champs personnalisés : tout est objets que tu POST."
    deep:
      sub: "https://${NETBOX_HOST}/api · DRF · token → user → permissions"
  - id: config
    kind: file
    label: { en: "configuration.py", fr: "configuration.py" }
    sub: "${INSTALL_DIR}/netbox/netbox/"
    in: server
    focus: true
    desc:
      en: "The one file the API cannot touch: time zone, banners, mail, LDAP, plugins. Read once at start, hence the restart."
      fr: "Le seul fichier que l'API ne peut pas toucher : fuseau, bandeaux, mail, LDAP, plugins. Lu une fois au démarrage, d'où le redémarrage."
    deep:
      sub: "${INSTALL_DIR}/netbox/netbox/configuration.py · read at start only"
  - id: restart
    kind: service
    label: { en: "Restart", fr: "Redémarrage" }
    sub: "systemctl restart netbox netbox-rq"
    in: server
    level: guided
    desc:
      en: "Both services, every time configuration.py changes. A few seconds of downtime; users just see a reload."
      fr: "Les deux services, à chaque changement de configuration.py. Quelques secondes d'indisponibilité ; les utilisateurs voient juste un rechargement."
  - id: groups
    kind: service
    label: { en: "Groups & permissions", fr: "Groupes et permissions" }
    sub: "netops · readers"
    in: server
    desc:
      en: "Who may change what. Two groups are enough to start: one that writes, one that only reads."
      fr: "Qui peut changer quoi. Deux groupes suffisent pour commencer : un qui écrit, un qui ne fait que lire."
    deep:
      sub: "netops (write) · readers (view) · object permissions"
  - id: plugins
    kind: service
    label: { en: "Plugins", fr: "Plugins" }
    sub: "local_requirements.txt"
    in: server
    desc:
      en: "Python packages installed in the venv and listed in PLUGINS. The upgrade script reinstalls them from local_requirements.txt."
      fr: "Des paquets Python installés dans le venv et listés dans PLUGINS. Le script d'upgrade les réinstalle depuis local_requirements.txt."
    deep:
      sub: "local_requirements.txt → venv · PLUGINS = […] · migrate"
  - id: smtp
    kind: cloud
    label: { en: "Mail relay", fr: "Relais mail" }
    sub: "${SMTP_HOST}:${SMTP_PORT}"
    when: { flag: MAIL }
    desc:
      en: "Where NetBox hands its mail: password resets, notifications. Any relay that accepts this server works."
      fr: "Là où NetBox dépose ses mails : réinitialisations de mot de passe, notifications. N'importe quel relais qui accepte ce serveur convient."
    deep:
      sub: "${SMTP_HOST}:${SMTP_PORT} · STARTTLS · from ${FROM_EMAIL}"
  - id: ldap
    kind: cloud
    label: { en: "LDAP / AD groups", fr: "Groupes LDAP / AD" }
    sub: "${LDAP_ADMIN_GROUP}"
    when: { flag: LDAP }
    desc:
      en: "Directory groups mirrored into NetBox at each login. Members of ${LDAP_ADMIN_GROUP} become superusers."
      fr: "Les groupes de l'annuaire recopiés dans NetBox à chaque login. Les membres de ${LDAP_ADMIN_GROUP} deviennent superutilisateurs."
    deep:
      sub: "memberOf ${LDAP_ADMIN_GROUP} → superuser · mirrored at login"

edges:
  - from: you
    to: api
    label: "HTTPS"
    deep: { label: "L7 HTTPS · Authorization: Token" }
    desc: { en: "Each request carries the token in a header; the token decides what you may do.", fr: "Chaque requête porte le jeton dans un en-tête ; le jeton décide de ce que tu peux faire." }
  - from: you
    to: config
    label: "ssh"
    dashed: true
    deep: { label: "ssh · sudo · editor" }
    desc: { en: "The file is owned by root: an SSH session and sudo to edit it.", fr: "Le fichier appartient à root : une session SSH et sudo pour l'éditer." }
  - from: api
    to: groups
    deep: { label: "POST /users/…" }
    desc: { en: "Groups and permissions are created like any other object.", fr: "Groupes et permissions se créent comme n'importe quel autre objet." }
  - from: config
    to: restart
    label: "then"
    level: guided
    desc: { en: "Nothing in configuration.py counts until both services restart.", fr: "Rien dans configuration.py ne compte tant que les deux services n'ont pas redémarré." }
  - from: config
    to: plugins
    max: quick
    desc: { en: "PLUGINS in configuration.py is what turns an installed package on.", fr: "PLUGINS dans configuration.py est ce qui active un paquet installé." }
  - from: restart
    to: plugins
    level: guided
    deep: { label: "loads PLUGINS" }
    desc: { en: "At start, NetBox imports every plugin listed and runs its migrations.", fr: "Au démarrage, NetBox importe chaque plugin listé et exécute ses migrations." }
  - from: config
    to: smtp
    dashed: true
    when: { flag: MAIL }
    max: quick
    desc: { en: "The EMAIL block in configuration.py names the relay.", fr: "Le bloc EMAIL de configuration.py désigne le relais." }
  - from: restart
    to: smtp
    dashed: true
    level: guided
    when: { flag: MAIL }
    deep: { label: "EMAIL = {…} · TCP ${SMTP_PORT}" }
    desc: { en: "After the restart, NetBox connects to the relay when it has something to send.", fr: "Après le redémarrage, NetBox se connecte au relais quand il a quelque chose à envoyer." }
  - from: groups
    to: ldap
    label: "mirror"
    dashed: true
    when: { flag: LDAP }
    deep: { label: "AUTH_LDAP_MIRROR_GROUPS" }
    desc: { en: "Group membership is copied from the directory at each login, so permissions follow the directory.", fr: "L'appartenance aux groupes est recopiée depuis l'annuaire à chaque login, donc les permissions suivent l'annuaire." }
````

---

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