# Source of "Installer NetBox de zéro"

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/install-from-scratch/tuto.yaml"
# Contract for this tutorial: language-independent.
# Prose lives in page-en.mdx / page-fr.mdx and may only use what is declared here.

title:
  en: Install NetBox from scratch
  fr: Installer NetBox de zéro
summary:
  en: >-
    From a fresh Linux server to a NetBox instance behind HTTPS, ready for users.
    PostgreSQL, Redis, NetBox itself, gunicorn, then the web server you choose.
  fr: >-
    D'un serveur Linux vierge à une instance NetBox derrière HTTPS, prête pour les utilisateurs.
    PostgreSQL, Redis, NetBox lui-même, gunicorn, puis le serveur web de ton choix.
difficulty: intermediate
tags: [netbox, postgresql, redis, gunicorn, nginx, apache]
authors: [thudal]
created: 2026-09-24
minutes: 45
validated: NetBox 4.4 · Ubuntu 24.04
status: draft             # not yet run end to end by its author

groups:
  - id: db
    label: { en: Database, fr: Base de données }
vars:
  - key: NETBOX_REF
    kind: text
    group: host
    default: master
    label: { en: NetBox version, fr: Version de NetBox }
    hint:
      en: Which version of the code to fetch from GitHub. "master" is always the latest stable release; a tag such as v4.4.0 pins one precise version.
      fr: Quelle version du code récupérer sur GitHub. « master » est toujours la dernière version stable ; un tag comme v4.4.0 fige une version précise.
    impact:
      en: Pinning a tag makes the install reproducible and keeps upgrades a deliberate act. With master, a re-run of the clone step may fetch a newer release than the one you tested.
      fr: Figer un tag rend l'installation reproductible et garde les mises à jour volontaires. Avec master, relancer l'étape de clonage peut récupérer une version plus récente que celle testée.
  - key: ADMIN_EMAIL
    kind: email
    group: host
    default: admin@example.com
    label: { en: Admin email, fr: Email administrateur }
    hint:
      en: The address responsible for this server. Let's Encrypt writes to it before a certificate expires.
      fr: L'adresse responsable de ce serveur. Let's Encrypt lui écrit avant l'expiration d'un certificat.
    impact:
      en: Only used when the certificate comes from Let's Encrypt. It is not published and NetBox itself does not send mail with it.
      fr: Utilisé seulement si le certificat vient de Let's Encrypt. Il n'est pas publié et NetBox n'envoie pas de mail avec.
  - key: DB_PASSWORD
    kind: secret
    group: db
    default: ""
    label: { en: PostgreSQL password, fr: Mot de passe PostgreSQL }
    hint:
      en: The password of the "netbox" database account that NetBox uses to talk to PostgreSQL. Generate a long random one; nobody types it by hand.
      fr: Le mot de passe du compte de base « netbox » que NetBox utilise pour parler à PostgreSQL. Génère-en un long et aléatoire ; personne ne le tape à la main.
    impact:
      en: Set once when the database role is created, then copied into configuration.py. If the two differ, the first migration fails with "password authentication failed for user netbox". It never leaves this server.
      fr: Défini une fois à la création du rôle de base, puis recopié dans configuration.py. Si les deux diffèrent, la première migration échoue avec « password authentication failed for user netbox ». Il ne quitte jamais ce serveur.

choices:
  - key: TLS
    type: select
    label: { en: Certificate, fr: Certificat }
    default: selfsigned
    options:
      - { value: selfsigned, label: { en: Self-signed, fr: Auto-signé } }
      - { value: letsencrypt, label: { en: Let's Encrypt, fr: Let's Encrypt } }
````

````mdx title="content/netbox/install-from-scratch/page-en.mdx"
{/* First pass — to be validated step by step against docs.netbox.dev/installation before publishing. */}

NetBox is four moving parts: a PostgreSQL database, a Redis cache, the Django application served by gunicorn, and a web server in front of it for HTTPS. We install them in that order, checking each one before moving on.

<Run>

The script below does every step of this page for **<V name="NETBOX_HOST" />**, with your choices applied. Run it as a sudoer on a fresh server, then open the URL it prints.

<When is="OS" equals="ubuntu">

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — unattended install on Ubuntu. Generated for ${NETBOX_HOST}.
export DEBIAN_FRONTEND=noninteractive
sudo apt update && sudo apt install -y postgresql redis-server git \
  python3 python3-pip python3-venv python3-dev build-essential \
  libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
sudo adduser --system --group netbox
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
SECRET_KEY=$(python3 ${INSTALL_DIR}/netbox/generate_secret_key.py)
sudo tee ${INSTALL_DIR}/netbox/netbox/configuration.py >/dev/null <<EOF
ALLOWED_HOSTS = ['${NETBOX_HOST}']
DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '${DB_PASSWORD}', 'HOST': 'localhost', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {'tasks': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
         'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False}}
SECRET_KEY = '$SECRET_KEY'
EOF
sudo ${INSTALL_DIR}/upgrade.sh
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now netbox netbox-rq
echo "NetBox is up. Create the first admin: sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser"
```

</When>

<When is="OS" equals="rhel">

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — unattended install on RHEL 9. Generated for ${NETBOX_HOST}.
sudo dnf install -y postgresql-server redis git python3 python3-pip python3-devel gcc \
  libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql redis
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
sudo groupadd --system netbox && sudo adduser --system -g netbox netbox
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
SECRET_KEY=$(python3 ${INSTALL_DIR}/netbox/generate_secret_key.py)
sudo tee ${INSTALL_DIR}/netbox/netbox/configuration.py >/dev/null <<EOF
ALLOWED_HOSTS = ['${NETBOX_HOST}']
DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '${DB_PASSWORD}', 'HOST': 'localhost', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {'tasks': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
         'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False}}
SECRET_KEY = '$SECRET_KEY'
EOF
sudo ${INSTALL_DIR}/upgrade.sh
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now netbox netbox-rq
echo "NetBox is up. Create the first admin: sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser"
```

</When>

<Warn>The script stops before the web server and TLS. Those two steps depend on your DNS and certificate situation; do them from the Quick level once NetBox answers on port 8001.</Warn>

</Run>

## Before you start

<Guided>You need a server with at least 2 vCPU, 4 GB of RAM and 20 GB of disk, a sudo account, and the name **<V name="NETBOX_HOST" />** resolving to it. NetBox 4.x wants Python 3.10 or newer, PostgreSQL 14 or newer and Redis 4.0 or newer; the packages below satisfy that.</Guided>

<Deep>Everything here runs on one machine. That is the right starting point: NetBox is not resource-hungry, and splitting PostgreSQL or Redis onto their own hosts is a change to two lines of `configuration.py` later, not a re-install. The one thing to decide now is the install directory, because the systemd units, the upgrade script and the web server configuration all hard-code it.</Deep>

<When is="OS" equals="ubuntu">

```bash
sudo apt update && sudo apt upgrade -y
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf upgrade -y
```

</When>

## PostgreSQL

<Guided>NetBox stores everything in PostgreSQL, and only PostgreSQL: MySQL and MariaDB are not supported. We install it, create a dedicated database and role, and let the role own the database.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y postgresql
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y postgresql-server
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql
```

<Note>RHEL ships PostgreSQL with `ident` authentication for local connections. NetBox connects over TCP with a password, so make sure `/var/lib/pgsql/data/pg_hba.conf` has a `host … md5` or `scram-sha-256` line for `127.0.0.1/32`, then reload the service.</Note>

</When>

<Check cmd="psql --version" expect="psql (PostgreSQL) 16.x" />

Create the database and its owner:

```bash
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
```

<Deep>The last `GRANT` matters since PostgreSQL 15: the `public` schema is no longer writable by every role, so without it the first migration fails with "permission denied for schema public". Owning the database is not enough, the schema is a separate object.</Deep>

<Check cmd="psql --username netbox --password --host localhost netbox -c '\conninfo'" expect='You are connected to database "netbox" as user "netbox" on host "localhost"' />

## Redis

<Guided>Redis holds two things: the queue of background tasks (webhooks, scripts, reports) and the cache. Both use the same server, on different database numbers.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y redis-server
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y redis
sudo systemctl enable --now redis
```

</When>

<Check cmd="redis-cli ping" expect="PONG" />

<Deep>Redis listens on localhost only by default, without a password, which is fine as long as NetBox is on the same host. If you move Redis elsewhere later, set a password and fill in the `PASSWORD` keys of the `REDIS` block in `configuration.py`.</Deep>

## Install NetBox

<Guided>NetBox is a Python application. We install the build dependencies, fetch the code into <V name="INSTALL_DIR" />, create a system user to run it, and hand that user the folders it writes to.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y git python3 python3-pip python3-venv python3-dev build-essential \
  libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y git python3 python3-pip python3-devel gcc \
  libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
```

</When>

<Check cmd="python3 -V" expect="Python 3.12.x" />

```bash
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
```

<Deep>Cloning rather than downloading a release archive is what makes upgrades a `git checkout` away. The `master` branch always points at the latest stable release; a tag like `v4.4.0` pins one. Both are fine, pinning is simply more reproducible.</Deep>

<When is="OS" equals="ubuntu">

```bash
sudo adduser --system --group netbox
```

</When>

<When is="OS" equals="rhel">

```bash
sudo groupadd --system netbox
sudo adduser --system -g netbox netbox
```

</When>

```bash
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
```

<Note>Only these three folders need to be writable by the `netbox` user. The rest of the tree stays owned by root, which is what you want for an application exposed to the network.</Note>

## Configuration

<Guided>One file, <V name="INSTALL_DIR" />`/netbox/netbox/configuration.py`, holds everything specific to your instance. We start from the example and set the four required values: allowed hostnames, database, Redis, secret key.</Guided>

```bash
sudo cp ${INSTALL_DIR}/netbox/netbox/configuration_example.py ${INSTALL_DIR}/netbox/netbox/configuration.py
python3 ${INSTALL_DIR}/netbox/generate_secret_key.py
```

Edit `configuration.py` and set these values (paste the key printed above into `SECRET_KEY`):

```python
ALLOWED_HOSTS = ['${NETBOX_HOST}']

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'netbox',
        'USER': 'netbox',
        'PASSWORD': '${DB_PASSWORD}',
        'HOST': 'localhost',
        'PORT': '',
        'CONN_MAX_AGE': 300,
    }
}

REDIS = {
    'tasks':   {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
    'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False},
}

SECRET_KEY = 'paste-the-generated-key-here'
```

<Deep>`SECRET_KEY` signs sessions and password reset tokens. It must be at least 50 characters, unique to this instance, and identical on every node if you ever run several. It is *not* a password you type anywhere, and changing it logs everyone out. `ALLOWED_HOSTS` is Django's defence against Host-header attacks: any name not in the list gets a 400.</Deep>

<When flag="LDAP">

### LDAP authentication

<Guided>NetBox delegates authentication to your directory through `django-auth-ldap`. It is an extra package, listed in `local_requirements.txt` so upgrades keep it, plus a dedicated config file.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y openldap-devel
```

</When>

```bash
sudo sh -c "echo 'django-auth-ldap' >> ${INSTALL_DIR}/local_requirements.txt"
```

Add to `configuration.py`:

```python
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```

Then create <V name="INSTALL_DIR" />`/netbox/netbox/ldap_config.py`:

```python
import ldap
from django_auth_ldap.config import LDAPSearch

AUTH_LDAP_SERVER_URI = '${LDAP_URI}'
AUTH_LDAP_BIND_DN = '${LDAP_BIND_DN}'
AUTH_LDAP_BIND_PASSWORD = 'bind-account-password'
AUTH_LDAP_USER_SEARCH = LDAPSearch('${LDAP_BASE_DN}', ldap.SCOPE_SUBTREE, '(sAMAccountName=%(user)s)')
AUTH_LDAP_USER_ATTR_MAP = {'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'}
```

<Deep>The filter above is Active Directory's. For OpenLDAP use `(uid=%(user)s)`. Group-to-permission mapping (`AUTH_LDAP_USER_FLAGS_BY_GROUP`) is worth setting up too, otherwise every directory user lands in NetBox with no rights; that is a later tutorial in this series.</Deep>

</When>

## Run the upgrade script

<Guided>`upgrade.sh` is NetBox's own installer: it creates the Python virtual environment, installs the dependencies, applies the database migrations and collects the static files. The same script runs every future upgrade.</Guided>

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

<Check cmd="sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py check" expect="System check identified no issues" />

Create the first administrator:

```bash
sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser
```

<Guided>Schedule the housekeeping job, which prunes old change records and expired sessions once a day:</Guided>

```bash
sudo ln -s ${INSTALL_DIR}/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```

## gunicorn and systemd

<Guided>Django does not serve HTTP in production; gunicorn does, as a systemd service. A second service, `netbox-rq`, runs the background workers.</Guided>

```bash
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now netbox netbox-rq
```

<Deep>The shipped `gunicorn.py` binds `127.0.0.1:8001` with five workers, which is right for a few hundred users. If you changed the install directory, open both unit files and fix the paths: they assume `/opt/netbox`.</Deep>

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

<Check cmd="curl -sI http://127.0.0.1:8001/ | head -1" expect="HTTP/1.1 200 OK" />

## Web server and HTTPS

<Guided>gunicorn only listens on localhost. The web server takes the HTTPS connection from the user, serves the static files itself and proxies the rest to gunicorn.</Guided>

<When is="TLS" equals="selfsigned">

```bash
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/ssl/private/netbox.key -out /etc/ssl/certs/netbox.crt \
  -subj "/CN=${NETBOX_HOST}"
```

<Note>A self-signed certificate is fine for a lab; browsers will warn once. For anything users touch, switch the Certificate choice to Let's Encrypt.</Note>

</When>

<When is="TLS" equals="letsencrypt">

<Guided>Let's Encrypt needs port 80 reachable from the internet and <V name="NETBOX_HOST" /> resolving to this server. Certbot takes care of issuance and renewal.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y certbot
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y certbot
```

</When>

```bash
sudo certbot certonly --standalone -d ${NETBOX_HOST} -m ${ADMIN_EMAIL} --agree-tos --non-interactive
sudo ln -sf /etc/letsencrypt/live/${NETBOX_HOST}/fullchain.pem /etc/ssl/certs/netbox.crt
sudo ln -sf /etc/letsencrypt/live/${NETBOX_HOST}/privkey.pem /etc/ssl/private/netbox.key
```

</When>

<When is="WEB" equals="nginx">

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y nginx
sudo cp ${INSTALL_DIR}/contrib/nginx.conf /etc/nginx/sites-available/netbox
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/nginx/sites-available/netbox
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
sudo nginx -t && sudo systemctl restart nginx
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y nginx
sudo cp ${INSTALL_DIR}/contrib/nginx.conf /etc/nginx/conf.d/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/nginx/conf.d/netbox.conf
sudo nginx -t && sudo systemctl enable --now nginx
```

</When>

<Deep>The shipped configuration redirects HTTP to HTTPS, serves <V name="INSTALL_DIR" />`/netbox/static/` directly and proxies everything else to `127.0.0.1:8001`. It also sets `client_max_body_size 25m`, which is what allows image attachments.</Deep>

</When>

<When is="WEB" equals="apache">

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y apache2
sudo cp ${INSTALL_DIR}/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/apache2/sites-available/netbox.conf
sudo a2enmod ssl proxy proxy_http headers rewrite
sudo a2ensite netbox
sudo systemctl restart apache2
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y httpd mod_ssl
sudo cp ${INSTALL_DIR}/contrib/apache.conf /etc/httpd/conf.d/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/httpd/conf.d/netbox.conf
sudo systemctl enable --now httpd
```

</When>

<Deep>The Apache configuration relies on `mod_proxy` for gunicorn and `mod_headers` to forward the original `Host`. Without the latter Django sees `127.0.0.1` as the host and rejects the request with a 400, since it is not in `ALLOWED_HOSTS`.</Deep>

</When>

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

## Done

Open **https://<V name="NETBOX_HOST" />/** and log in with the administrator you created. The next page of this series adds users and permissions, plugins and the settings you will want before opening NetBox to your team.
````

````mdx title="content/netbox/install-from-scratch/page-fr.mdx"
{/* Première passe — à valider étape par étape contre docs.netbox.dev/installation avant publication. */}

NetBox, c'est quatre pièces : une base PostgreSQL, un cache Redis, l'application Django servie par gunicorn, et un serveur web devant pour le HTTPS. On les installe dans cet ordre, en vérifiant chacune avant de passer à la suivante.

<Run>

Le script ci-dessous fait toutes les étapes de cette page pour **<V name="NETBOX_HOST" />**, avec tes choix appliqués. Lance-le en sudoer sur un serveur vierge, puis ouvre l'URL qu'il affiche.

<When is="OS" equals="ubuntu">

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — installation sans intervention sur Ubuntu. Généré pour ${NETBOX_HOST}.
export DEBIAN_FRONTEND=noninteractive
sudo apt update && sudo apt install -y postgresql redis-server git \
  python3 python3-pip python3-venv python3-dev build-essential \
  libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
sudo adduser --system --group netbox
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
SECRET_KEY=$(python3 ${INSTALL_DIR}/netbox/generate_secret_key.py)
sudo tee ${INSTALL_DIR}/netbox/netbox/configuration.py >/dev/null <<EOF
ALLOWED_HOSTS = ['${NETBOX_HOST}']
DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '${DB_PASSWORD}', 'HOST': 'localhost', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {'tasks': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
         'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False}}
SECRET_KEY = '$SECRET_KEY'
EOF
sudo ${INSTALL_DIR}/upgrade.sh
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now netbox netbox-rq
echo "NetBox tourne. Crée le premier admin : sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser"
```

</When>

<When is="OS" equals="rhel">

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — installation sans intervention sur RHEL 9. Généré pour ${NETBOX_HOST}.
sudo dnf install -y postgresql-server redis git python3 python3-pip python3-devel gcc \
  libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql redis
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
sudo groupadd --system netbox && sudo adduser --system -g netbox netbox
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
SECRET_KEY=$(python3 ${INSTALL_DIR}/netbox/generate_secret_key.py)
sudo tee ${INSTALL_DIR}/netbox/netbox/configuration.py >/dev/null <<EOF
ALLOWED_HOSTS = ['${NETBOX_HOST}']
DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '${DB_PASSWORD}', 'HOST': 'localhost', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {'tasks': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
         'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False}}
SECRET_KEY = '$SECRET_KEY'
EOF
sudo ${INSTALL_DIR}/upgrade.sh
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now netbox netbox-rq
echo "NetBox tourne. Crée le premier admin : sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser"
```

</When>

<Warn>Le script s'arrête avant le serveur web et le TLS. Ces deux étapes dépendent de ton DNS et de ta situation côté certificat ; fais-les depuis le niveau Express une fois que NetBox répond sur le port 8001.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut un serveur avec au moins 2 vCPU, 4 Go de RAM et 20 Go de disque, un compte sudo, et le nom **<V name="NETBOX_HOST" />** qui pointe dessus. NetBox 4.x demande Python 3.10 ou plus, PostgreSQL 14 ou plus et Redis 4.0 ou plus ; les paquets ci-dessous conviennent.</Guided>

<Deep>Tout tourne ici sur une seule machine. C'est le bon point de départ : NetBox est peu gourmand, et sortir PostgreSQL ou Redis sur leurs propres hôtes plus tard, c'est deux lignes de `configuration.py`, pas une réinstallation. La seule chose à décider maintenant, c'est le répertoire d'installation, parce que les unités systemd, le script d'upgrade et la configuration du serveur web le codent en dur.</Deep>

<When is="OS" equals="ubuntu">

```bash
sudo apt update && sudo apt upgrade -y
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf upgrade -y
```

</When>

## PostgreSQL

<Guided>NetBox stocke tout dans PostgreSQL, et uniquement PostgreSQL : MySQL et MariaDB ne sont pas supportés. On l'installe, on crée une base et un rôle dédiés, et on donne la base au rôle.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y postgresql
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y postgresql-server
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql
```

<Note>RHEL livre PostgreSQL avec une authentification `ident` pour les connexions locales. NetBox se connecte en TCP avec un mot de passe : vérifie que `/var/lib/pgsql/data/pg_hba.conf` contient une ligne `host … md5` ou `scram-sha-256` pour `127.0.0.1/32`, puis recharge le service.</Note>

</When>

<Check cmd="psql --version" expect="psql (PostgreSQL) 16.x" />

Crée la base et son propriétaire :

```bash
sudo -u postgres psql -c "CREATE DATABASE netbox;"
sudo -u postgres psql -c "CREATE USER netbox WITH PASSWORD '${DB_PASSWORD}';"
sudo -u postgres psql -c "ALTER DATABASE netbox OWNER TO netbox;"
sudo -u postgres psql -d netbox -c "GRANT CREATE ON SCHEMA public TO netbox;"
```

<Deep>Le dernier `GRANT` compte depuis PostgreSQL 15 : le schéma `public` n'est plus inscriptible par tous les rôles, donc sans lui la première migration échoue avec « permission denied for schema public ». Posséder la base ne suffit pas, le schéma est un objet à part.</Deep>

<Check cmd="psql --username netbox --password --host localhost netbox -c '\conninfo'" expect='You are connected to database "netbox" as user "netbox" on host "localhost"' />

## Redis

<Guided>Redis tient deux choses : la file des tâches de fond (webhooks, scripts, rapports) et le cache. Les deux utilisent le même serveur, sur des numéros de base différents.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y redis-server
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y redis
sudo systemctl enable --now redis
```

</When>

<Check cmd="redis-cli ping" expect="PONG" />

<Deep>Redis n'écoute que sur localhost par défaut, sans mot de passe, ce qui convient tant que NetBox est sur le même hôte. Si tu déplaces Redis plus tard, mets un mot de passe et remplis les clés `PASSWORD` du bloc `REDIS` de `configuration.py`.</Deep>

## Installer NetBox

<Guided>NetBox est une application Python. On installe les dépendances de compilation, on récupère le code dans <V name="INSTALL_DIR" />, on crée un utilisateur système pour le faire tourner, et on lui confie les dossiers dans lesquels il écrit.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y git python3 python3-pip python3-venv python3-dev build-essential \
  libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y git python3 python3-pip python3-devel gcc \
  libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
```

</When>

<Check cmd="python3 -V" expect="Python 3.12.x" />

```bash
sudo mkdir -p ${INSTALL_DIR}
sudo git clone -b ${NETBOX_REF} --depth 1 https://github.com/netbox-community/netbox.git ${INSTALL_DIR}
```

<Deep>Cloner plutôt que télécharger une archive de release, c'est ce qui fait qu'une mise à jour tient en un `git checkout`. La branche `master` pointe toujours sur la dernière version stable ; un tag comme `v4.4.0` en fige une. Les deux conviennent, figer est simplement plus reproductible.</Deep>

<When is="OS" equals="ubuntu">

```bash
sudo adduser --system --group netbox
```

</When>

<When is="OS" equals="rhel">

```bash
sudo groupadd --system netbox
sudo adduser --system -g netbox netbox
```

</When>

```bash
sudo chown --recursive netbox ${INSTALL_DIR}/netbox/media/ ${INSTALL_DIR}/netbox/reports/ ${INSTALL_DIR}/netbox/scripts/
```

<Note>Seuls ces trois dossiers doivent être inscriptibles par l'utilisateur `netbox`. Le reste de l'arborescence reste à root, ce qu'on veut pour une application exposée au réseau.</Note>

## Configuration

<Guided>Un seul fichier, <V name="INSTALL_DIR" />`/netbox/netbox/configuration.py`, contient tout ce qui est propre à ton instance. On part de l'exemple et on renseigne les quatre valeurs obligatoires : noms d'hôte autorisés, base de données, Redis, clé secrète.</Guided>

```bash
sudo cp ${INSTALL_DIR}/netbox/netbox/configuration_example.py ${INSTALL_DIR}/netbox/netbox/configuration.py
python3 ${INSTALL_DIR}/netbox/generate_secret_key.py
```

Édite `configuration.py` et renseigne ces valeurs (colle la clé affichée ci-dessus dans `SECRET_KEY`) :

```python
ALLOWED_HOSTS = ['${NETBOX_HOST}']

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'netbox',
        'USER': 'netbox',
        'PASSWORD': '${DB_PASSWORD}',
        'HOST': 'localhost',
        'PORT': '',
        'CONN_MAX_AGE': 300,
    }
}

REDIS = {
    'tasks':   {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
    'caching': {'HOST': 'localhost', 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, 'SSL': False},
}

SECRET_KEY = 'colle-la-cle-generee-ici'
```

<Deep>`SECRET_KEY` signe les sessions et les jetons de réinitialisation de mot de passe. Elle doit faire au moins 50 caractères, être propre à cette instance, et identique sur chaque nœud si tu en fais tourner plusieurs un jour. Ce n'est *pas* un mot de passe que tu tapes quelque part, et la changer déconnecte tout le monde. `ALLOWED_HOSTS` est la défense de Django contre les attaques par en-tête Host : tout nom absent de la liste reçoit un 400.</Deep>

<When flag="LDAP">

### Authentification LDAP

<Guided>NetBox délègue l'authentification à ton annuaire via `django-auth-ldap`. C'est un paquet supplémentaire, listé dans `local_requirements.txt` pour que les mises à jour le conservent, plus un fichier de configuration dédié.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y openldap-devel
```

</When>

```bash
sudo sh -c "echo 'django-auth-ldap' >> ${INSTALL_DIR}/local_requirements.txt"
```

Ajoute dans `configuration.py` :

```python
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```

Puis crée <V name="INSTALL_DIR" />`/netbox/netbox/ldap_config.py` :

```python
import ldap
from django_auth_ldap.config import LDAPSearch

AUTH_LDAP_SERVER_URI = '${LDAP_URI}'
AUTH_LDAP_BIND_DN = '${LDAP_BIND_DN}'
AUTH_LDAP_BIND_PASSWORD = 'mot-de-passe-du-compte-de-bind'
AUTH_LDAP_USER_SEARCH = LDAPSearch('${LDAP_BASE_DN}', ldap.SCOPE_SUBTREE, '(sAMAccountName=%(user)s)')
AUTH_LDAP_USER_ATTR_MAP = {'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'}
```

<Deep>Le filtre ci-dessus est celui d'Active Directory. Pour OpenLDAP, utilise `(uid=%(user)s)`. Le mapping groupes → permissions (`AUTH_LDAP_USER_FLAGS_BY_GROUP`) vaut aussi le coup, sinon chaque utilisateur de l'annuaire arrive dans NetBox sans aucun droit ; c'est un tuto ultérieur de cette série.</Deep>

</When>

## Lancer le script d'upgrade

<Guided>`upgrade.sh` est l'installeur de NetBox lui-même : il crée l'environnement virtuel Python, installe les dépendances, applique les migrations de base et collecte les fichiers statiques. Le même script sert à chaque mise à jour future.</Guided>

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

<Check cmd="sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py check" expect="System check identified no issues" />

Crée le premier administrateur :

```bash
sudo ${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/netbox/manage.py createsuperuser
```

<Guided>Planifie la tâche d'entretien, qui purge une fois par jour les anciens enregistrements de changement et les sessions expirées :</Guided>

```bash
sudo ln -s ${INSTALL_DIR}/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```

## gunicorn et systemd

<Guided>Django ne sert pas le HTTP en production ; c'est gunicorn qui s'en charge, comme service systemd. Un second service, `netbox-rq`, fait tourner les workers de fond.</Guided>

```bash
sudo cp ${INSTALL_DIR}/contrib/gunicorn.py ${INSTALL_DIR}/gunicorn.py
sudo cp -v ${INSTALL_DIR}/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now netbox netbox-rq
```

<Deep>Le `gunicorn.py` livré écoute sur `127.0.0.1:8001` avec cinq workers, ce qui convient pour quelques centaines d'utilisateurs. Si tu as changé le répertoire d'installation, ouvre les deux fichiers d'unité et corrige les chemins : ils supposent `/opt/netbox`.</Deep>

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

<Check cmd="curl -sI http://127.0.0.1:8001/ | head -1" expect="HTTP/1.1 200 OK" />

## Serveur web et HTTPS

<Guided>gunicorn n'écoute que sur localhost. Le serveur web prend la connexion HTTPS de l'utilisateur, sert lui-même les fichiers statiques et relaie le reste à gunicorn.</Guided>

<When is="TLS" equals="selfsigned">

```bash
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/ssl/private/netbox.key -out /etc/ssl/certs/netbox.crt \
  -subj "/CN=${NETBOX_HOST}"
```

<Note>Un certificat auto-signé suffit pour un lab ; les navigateurs avertiront une fois. Pour tout ce que des utilisateurs touchent, passe le choix Certificat sur Let's Encrypt.</Note>

</When>

<When is="TLS" equals="letsencrypt">

<Guided>Let's Encrypt a besoin du port 80 joignable depuis internet et de <V name="NETBOX_HOST" /> qui pointe vers ce serveur. Certbot s'occupe de l'émission et du renouvellement.</Guided>

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y certbot
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y certbot
```

</When>

```bash
sudo certbot certonly --standalone -d ${NETBOX_HOST} -m ${ADMIN_EMAIL} --agree-tos --non-interactive
sudo ln -sf /etc/letsencrypt/live/${NETBOX_HOST}/fullchain.pem /etc/ssl/certs/netbox.crt
sudo ln -sf /etc/letsencrypt/live/${NETBOX_HOST}/privkey.pem /etc/ssl/private/netbox.key
```

</When>

<When is="WEB" equals="nginx">

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y nginx
sudo cp ${INSTALL_DIR}/contrib/nginx.conf /etc/nginx/sites-available/netbox
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/nginx/sites-available/netbox
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
sudo nginx -t && sudo systemctl restart nginx
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y nginx
sudo cp ${INSTALL_DIR}/contrib/nginx.conf /etc/nginx/conf.d/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/nginx/conf.d/netbox.conf
sudo nginx -t && sudo systemctl enable --now nginx
```

</When>

<Deep>La configuration livrée redirige HTTP vers HTTPS, sert <V name="INSTALL_DIR" />`/netbox/static/` directement et relaie tout le reste à `127.0.0.1:8001`. Elle fixe aussi `client_max_body_size 25m`, ce qui permet les pièces jointes images.</Deep>

</When>

<When is="WEB" equals="apache">

<When is="OS" equals="ubuntu">

```bash
sudo apt install -y apache2
sudo cp ${INSTALL_DIR}/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/apache2/sites-available/netbox.conf
sudo a2enmod ssl proxy proxy_http headers rewrite
sudo a2ensite netbox
sudo systemctl restart apache2
```

</When>

<When is="OS" equals="rhel">

```bash
sudo dnf install -y httpd mod_ssl
sudo cp ${INSTALL_DIR}/contrib/apache.conf /etc/httpd/conf.d/netbox.conf
sudo sed -i 's/netbox.example.com/${NETBOX_HOST}/' /etc/httpd/conf.d/netbox.conf
sudo systemctl enable --now httpd
```

</When>

<Deep>La configuration Apache s'appuie sur `mod_proxy` pour gunicorn et `mod_headers` pour transmettre le `Host` d'origine. Sans ce dernier, Django voit `127.0.0.1` comme hôte et rejette la requête avec un 400, puisqu'il n'est pas dans `ALLOWED_HOSTS`.</Deep>

</When>

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

## Terminé

Ouvre **https://<V name="NETBOX_HOST" />/** et connecte-toi avec l'administrateur créé. La page suivante de cette série ajoute utilisateurs et permissions, plugins et les réglages que tu voudras avant d'ouvrir NetBox à ton équipe.
````

````yaml title="content/netbox/install-from-scratch/diagram.yaml"
# The gist: what this page builds. Follows the reader's stack and level:
# Quick shows four pieces, Guided adds the firewall and the worker, Deep
# shows every layer (DNS, TLS, ports, sockets, units, databases).
# `desc` is what the reader gets when hovering a box or an arrow.
title: { en: "One server, four pieces", fr: "Un serveur, quatre briques" }
caption:
  en: "The web server takes HTTPS from users and hands requests to gunicorn, which runs NetBox. NetBox keeps everything in PostgreSQL and uses Redis for its queue and cache. With LDAP, logins are checked against your directory."
  fr: "Le serveur web reçoit le HTTPS des utilisateurs et passe les requêtes à gunicorn, qui fait tourner NetBox. NetBox garde tout dans PostgreSQL et utilise Redis pour sa file et son cache. Avec LDAP, les connexions sont vérifiées auprès de ton annuaire."

groups:
  - id: server
    label: { en: "Your server", fr: "Ton serveur" }
    desc:
      en: "One Linux machine runs everything on this page. Every piece listens on localhost except the web server, the only one exposed."
      fr: "Une seule machine Linux fait tourner tout ce qui est sur cette page. Chaque brique écoute sur localhost, sauf le serveur web, le seul exposé."
  - id: directory
    label: { en: "Your directory", fr: "Ton annuaire" }
    when: { flag: LDAP }
    desc:
      en: "Your existing LDAP or Active Directory. NetBox never stores passwords when it is on: it asks the directory at each login."
      fr: "Ton LDAP ou Active Directory existant. NetBox ne stocke aucun mot de passe quand il est activé : il interroge l'annuaire à chaque connexion."

nodes:
  - id: users
    kind: user
    label: { en: "Your team", fr: "Ton équipe" }
    sub: "https://${NETBOX_HOST}"
    desc:
      en: "Anyone with a browser and the URL. They only ever talk to the web server, over HTTPS."
      fr: "Toute personne avec un navigateur et l'URL. Elle ne parle jamais qu'au serveur web, en HTTPS."
    deep:
      sub: "browser · L7 HTTPS → ${NETBOX_HOST}"
  - id: dns
    kind: cloud
    label: { en: "DNS", fr: "DNS" }
    sub: "${NETBOX_HOST} → A / AAAA"
    level: deep
    desc:
      en: "The name must resolve to this server before anything else works: the browser, the certificate and ALLOWED_HOSTS all rely on it."
      fr: "Le nom doit résoudre vers ce serveur avant que quoi que ce soit fonctionne : le navigateur, le certificat et ALLOWED_HOSTS en dépendent."
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    sub: "in: 22, 80, 443/tcp"
    in: server
    level: guided
    desc:
      en: "ufw drops everything inbound except SSH and the two web ports. PostgreSQL, Redis and gunicorn are not reachable from outside."
      fr: "ufw rejette tout ce qui entre sauf SSH et les deux ports web. PostgreSQL, Redis et gunicorn ne sont pas joignables de l'extérieur."
    deep:
      sub: "ufw · L3/L4 · allow 22, 80, 443/tcp · deny the rest"
  - id: cert
    kind: file
    label: { en: "TLS certificate", fr: "Certificat TLS" }
    sub: "self-signed · /etc/ssl/… · CN ${NETBOX_HOST}"
    in: server
    level: deep
    when: { is: TLS, equals: selfsigned }
    desc:
      en: "A certificate you sign yourself. Browsers warn once; fine for a lab or an internal tool, not for something users must trust blindly."
      fr: "Un certificat que tu signes toi-même. Les navigateurs avertissent une fois ; bien pour un lab ou un outil interne, pas pour un service que les utilisateurs doivent croire les yeux fermés."
  - id: le
    kind: file
    label: { en: "TLS certificate", fr: "Certificat TLS" }
    sub: "Let's Encrypt · certbot · renews itself"
    in: server
    level: deep
    when: { is: TLS, equals: letsencrypt }
    desc:
      en: "Issued by Let's Encrypt through certbot, trusted by every browser, renewed automatically. Needs the name to resolve publicly."
      fr: "Émis par Let's Encrypt via certbot, reconnu par tous les navigateurs, renouvelé tout seul. Le nom doit résoudre publiquement."
  - id: nginx
    kind: net
    label: { en: "nginx", fr: "nginx" }
    sub: "443 → 8001"
    in: server
    when: { is: WEB, equals: nginx }
    desc:
      en: "The front door. Terminates TLS, serves static files, and forwards everything else to gunicorn on the loopback."
      fr: "La porte d'entrée. Termine le TLS, sert les fichiers statiques, et transmet tout le reste à gunicorn sur la boucle locale."
    guided:
      sub: ":443 → 127.0.0.1:8001"
    deep:
      sub: "TLS ends here · :443 → 127.0.0.1:8001 · HTTP/1.1"
  - id: apache
    kind: net
    label: { en: "Apache", fr: "Apache" }
    sub: "443 → 8001"
    in: server
    when: { is: WEB, equals: apache }
    desc:
      en: "The front door. Terminates TLS, serves static files, and proxies everything else to gunicorn on the loopback."
      fr: "La porte d'entrée. Termine le TLS, sert les fichiers statiques, et relaie tout le reste vers gunicorn sur la boucle locale."
    guided:
      sub: ":443 → 127.0.0.1:8001"
    deep:
      sub: "TLS ends here · :443 → 127.0.0.1:8001 · HTTP/1.1"
  - id: gunicorn
    kind: service
    label: { en: "gunicorn", fr: "gunicorn" }
    sub: "netbox.service · 127.0.0.1:8001 · 5 workers"
    in: server
    level: deep
    desc:
      en: "The Python application server: a few worker processes that run Django. Managed by systemd as netbox.service."
      fr: "Le serveur d'application Python : quelques processus workers qui exécutent Django. Géré par systemd sous netbox.service."
  - id: netbox
    kind: server
    label: { en: "NetBox (gunicorn)", fr: "NetBox (gunicorn)" }
    sub: "${INSTALL_DIR}"
    in: server
    focus: true
    desc:
      en: "The application itself: the code in ${INSTALL_DIR}, its virtualenv, and configuration.py, the one file you edit."
      fr: "L'application elle-même : le code dans ${INSTALL_DIR}, son virtualenv, et configuration.py, le seul fichier que tu édites."
    deep:
      label: { en: "NetBox (Django)", fr: "NetBox (Django)" }
      sub: "${INSTALL_DIR} · venv · configuration.py"
  - id: rq
    kind: service
    label: { en: "RQ worker", fr: "Worker RQ" }
    sub: "netbox-rq.service"
    in: server
    level: guided
    desc:
      en: "A second process that runs background jobs pulled from Redis: webhooks, custom scripts, reports. Without it those silently queue up."
      fr: "Un second processus qui exécute les tâches de fond tirées de Redis : webhooks, scripts, rapports. Sans lui, elles s'empilent en silence."
    deep:
      sub: "netbox-rq.service · webhooks, scripts, reports"
  - id: postgres
    kind: store
    label: { en: "PostgreSQL", fr: "PostgreSQL" }
    sub: "netbox / netbox"
    in: server
    desc:
      en: "The only thing that matters in a backup: every device, IP, cable and change lives in this database."
      fr: "La seule chose qui compte dans une sauvegarde : chaque équipement, IP, câble et changement vit dans cette base."
    guided:
      sub: "localhost:5432 · netbox / netbox"
    deep:
      sub: "L4 TCP 5432 · scram-sha-256 · db netbox, role netbox"
  - id: redis
    kind: store
    label: { en: "Redis", fr: "Redis" }
    sub: "db 0 tasks · db 1 cache"
    in: server
    desc:
      en: "In-memory store used twice: database 0 is the job queue, database 1 the cache. Losing it loses nothing durable."
      fr: "Stockage en mémoire utilisé deux fois : la base 0 est la file de tâches, la base 1 le cache. Le perdre ne perd rien de durable."
    deep:
      sub: "L4 TCP 127.0.0.1:6379 · db 0 tasks · db 1 cache"
  - id: ldap
    kind: cloud
    label: { en: "LDAP / AD", fr: "LDAP / AD" }
    sub: "${LDAP_URI}"
    in: directory
    when: { flag: LDAP }
    desc:
      en: "The directory NetBox binds to at each login to check the password and read group membership."
      fr: "L'annuaire auquel NetBox se connecte à chaque login pour vérifier le mot de passe et lire les groupes."
    deep:
      sub: "${LDAP_URI} · 636 LDAPS or 389 + STARTTLS"

edges:
  # Quick: users reach the web server directly.
  - from: users
    to: nginx
    label: "HTTPS"
    max: quick
    desc: { en: "Encrypted web traffic on port 443, the only port users need.", fr: "Trafic web chiffré sur le port 443, le seul dont les utilisateurs ont besoin." }
  - from: users
    to: apache
    label: "HTTPS"
    max: quick
    desc: { en: "Encrypted web traffic on port 443, the only port users need.", fr: "Trafic web chiffré sur le port 443, le seul dont les utilisateurs ont besoin." }
  # Guided and up: through the firewall, with the layers spelled out.
  - from: users
    to: dns
    label: "L7 DNS · L4 UDP 53"
    dashed: true
    level: deep
    desc: { en: "Before the first request, the browser asks DNS which address ${NETBOX_HOST} points to.", fr: "Avant la première requête, le navigateur demande au DNS vers quelle adresse pointe ${NETBOX_HOST}." }
  - from: users
    to: firewall
    label: "HTTPS · TCP 443"
    level: guided
    deep: { label: "L7 HTTPS · L4 TCP 443 · L3 IP" }
    desc: { en: "The TCP connection to port 443 hits the firewall first; 80 is only there to redirect to HTTPS.", fr: "La connexion TCP vers le port 443 rencontre d'abord le pare-feu ; le 80 ne sert qu'à rediriger vers HTTPS." }
  - from: firewall
    to: nginx
    level: guided
    deep: { label: "accept" }
    desc: { en: "Allowed through: the port is in the rules.", fr: "Laissé passer : le port est dans les règles." }
  - from: firewall
    to: apache
    level: guided
    deep: { label: "accept" }
    desc: { en: "Allowed through: the port is in the rules.", fr: "Laissé passer : le port est dans les règles." }
  - from: cert
    to: nginx
    label: "loads"
    dashed: true
    level: deep
    desc: { en: "The web server reads the certificate and key at start; renewals need a reload.", fr: "Le serveur web lit le certificat et la clé au démarrage ; un renouvellement demande un reload." }
  - from: cert
    to: apache
    label: "loads"
    dashed: true
    level: deep
    desc: { en: "The web server reads the certificate and key at start; renewals need a reload.", fr: "Le serveur web lit le certificat et la clé au démarrage ; un renouvellement demande un reload." }
  - from: le
    to: nginx
    label: "loads"
    dashed: true
    level: deep
    desc: { en: "certbot writes the certificate and reloads the web server after each renewal.", fr: "certbot écrit le certificat et recharge le serveur web après chaque renouvellement." }
  - from: le
    to: apache
    label: "loads"
    dashed: true
    level: deep
    desc: { en: "certbot writes the certificate and reloads the web server after each renewal.", fr: "certbot écrit le certificat et recharge le serveur web après chaque renouvellement." }
  # Up to Guided, gunicorn is folded into the NetBox box; Deep separates them.
  - from: nginx
    to: netbox
    max: guided
    guided: { label: "proxy_pass" }
    desc: { en: "Plain HTTP on the loopback: nginx hands each request to gunicorn on port 8001 and waits for the answer.", fr: "HTTP en clair sur la boucle locale : nginx passe chaque requête à gunicorn sur le port 8001 et attend la réponse." }
  - from: apache
    to: netbox
    max: guided
    guided: { label: "ProxyPass" }
    desc: { en: "Plain HTTP on the loopback: Apache hands each request to gunicorn on port 8001 and waits for the answer.", fr: "HTTP en clair sur la boucle locale : Apache passe chaque requête à gunicorn sur le port 8001 et attend la réponse." }
  - from: nginx
    to: gunicorn
    label: "proxy_pass · HTTP/1.1 · loopback"
    level: deep
    desc: { en: "Unencrypted, but never leaves the machine. The X-Forwarded-* headers tell NetBox the original scheme and client.", fr: "Non chiffré, mais ne quitte jamais la machine. Les en-têtes X-Forwarded-* disent à NetBox le schéma et le client d'origine." }
  - from: apache
    to: gunicorn
    label: "ProxyPass · HTTP/1.1 · loopback"
    level: deep
    desc: { en: "Unencrypted, but never leaves the machine. The X-Forwarded-* headers tell NetBox the original scheme and client.", fr: "Non chiffré, mais ne quitte jamais la machine. Les en-têtes X-Forwarded-* disent à NetBox le schéma et le client d'origine." }
  - from: gunicorn
    to: netbox
    label: "WSGI"
    level: deep
    desc: { en: "The Python interface between a web server and an application: gunicorn calls Django with each request.", fr: "L'interface Python entre un serveur web et une application : gunicorn appelle Django à chaque requête." }
  - from: netbox
    to: postgres
    deep: { label: "psycopg · TCP 5432" }
    desc: { en: "Every read and write. The credentials are in configuration.py, DATABASE section.", fr: "Chaque lecture et écriture. Les identifiants sont dans configuration.py, section DATABASE." }
  - from: netbox
    to: redis
    deep: { label: "TCP 6379" }
    desc: { en: "Jobs pushed to the queue, pages and API results kept in the cache.", fr: "Tâches poussées dans la file, pages et résultats d'API gardés en cache." }
  - from: redis
    to: rq
    label: "jobs"
    dashed: true
    level: guided
    deep: { label: "queue db 0 → jobs" }
    desc: { en: "The worker blocks on the queue and runs each job as it arrives.", fr: "Le worker attend sur la file et exécute chaque tâche à son arrivée." }
  - from: netbox
    to: ldap
    label: "bind"
    dashed: true
    when: { flag: LDAP }
    deep: { label: "bind · TLS · then search" }
    desc: { en: "At login: bind with a service account, find the user, bind again with their password, read their groups.", fr: "Au login : bind avec un compte de service, recherche de l'utilisateur, second bind avec son mot de passe, lecture de ses groupes." }
````

---

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