# Installation du serveur cible — procédure d'exécution

> **Statut : ÉPROUVÉE — jouée DEUX FOIS le 25/08/2026**, par la session serveur, sur les
> deux machines Hetzner montées ce jour-là : **`lmsefektiv-dev` (116.203.140.63)** et
> **`lmsefektiv-prod` (188.34.196.226)**. Les écarts constatés à l'exécution sont intégrés
> **dans le corps de la procédure ci-dessous**, pas en annexe — et, quand c'est éclairant,
> la trace de ce qu'on croyait est conservée en citation.
>
> ⚠️ **Une procédure ne devient vraie qu'une fois jouée. Celle-ci l'est maintenant ; elle
> reste corrigible au fil de la prochaine exécution, dans ce fichier, pas dans une tête.**
>
> **Qui exécute** : Enguerran ou son associé, avec la session serveur.
>
> > **Ce qu'on croyait le 23/08 :** « Aucune session Claude n'atteint les serveurs (hôtes
> > hors politique de sortie réseau — mesuré : `exit=56` sur `api.efektiv-academie.com`). »
> > ⚠️ **Constat du 25/08 : la formulation absolue ne tient plus.** La session serveur a
> > atteint les deux machines Hetzner neuves *et* le serveur OVH (blocs C/D/E de #156, joués
> > le 25/08 au soir). **Ce qui a changé exactement — et pour quel type de session — n'est
> > pas établi ici.** Le [runbook §0.5](./14_RUNBOOK_BASCULE_PROD.md) porte encore la
> > formulation d'origine : écart signalé, hors périmètre de ce document.
>
> Ce document est le **livrable de l'étape 1** du §0.7 de
> [`14_RUNBOOK_BASCULE_PROD.md`](./14_RUNBOOK_BASCULE_PROD.md), qui fait foi pour la
> stratégie d'ensemble. Ici : uniquement **comment monter la machine**.
> La **migration des données** (dump, InnoDB, migrations, clés étrangères) vit dans
> `scripts/bascule/` et n'est plus un document à écrire — voir §10.

## 0. Ce que cette procédure suppose acquis

| | source |
|---|---|
| PHP **8.4** + extensions `mbstring, dom, curl, xml, bcmath, gd, zip, intl, pdo_mysql` | **décision d'Enguerran du 25/08** (#471). La liste d'extensions reste celle de `.github/workflows/tests.yml:115` — c'est ce que la CI teste, donc ce qui est éprouvé |
| MySQL **8.0** | version de la production actuelle. Ne pas prendre 8.4 ni 9.x : changer de moteur *et* de version fait deux variables |
| Apache 2 | même famille que l'existant, procédures transposables |
| **Aucun Node sur le serveur** | le front est **construit en CI** (Node 24) puis déposé par `rsync` — vérifié dans `deploy-staging.yml` du dépôt FRONT. Le serveur ne reçoit que `dist/` |
| Ubuntu **24.04 LTS, explicitement** | ⛔ **pas la LTS la plus récente** — voir §2, c'est l'écart qui a coûté une machine le 25/08. Le dépôt `ondrej/php` reste nécessaire pour épingler la version de PHP |

### PHP 8.4 — pourquoi, et jusqu'où

> **Ce qu'on croyait le 23/08 :** « PHP **8.2**, c'est ce que la CI teste. » La ligne était
> juste sur la CI, et fausse comme cible : elle faisait naître une machine neuve sur une
> version dont le support sécurité s'éteint **fin décembre 2026**.

**Tranché le 25/08 par Enguerran (#471), sur mesure et non sur intention** :

- `composer.json` demande `php: ^8.2` ; le **plafond réel vient d'un seul paquet**,
  `phpoffice/phpspreadsheet` → `>=7.4.0 <8.5.0`. **8.4 est le maximum autorisé.**
- `composer install` **vert en 8.4.24** (104 paquets, scripts post-install compris) et
  **suite complète verte en 8.4** — résultat identique au run de référence en 8.2.
- Une machine qui **naît** en 8.4 règle #471 sans migration ultérieure.

⚠️ **Deux suites, ni l'une ni l'autre tranchée ici :**

1. **La CI teste encore 8.2** (`.github/workflows/tests.yml:114`). L'alignement 8.2 → 8.4
   appartient à la **conversation CI/CD** — ce document le **signale**, il ne le prescrit pas.
   Tant qu'il n'est pas fait, le serveur tourne sur une version que la CI ne joue pas.
2. ⛔ **Ne pas monter en 8.5** avant que `phpoffice/phpspreadsheet` ne lève son plafond.

> **Périmé :** « Le blocant B7 du runbook annonçait *CI en PHP 8.3 contre 8.2 en prod* ; les
> deux sont en 8.2, rien à réconcilier. » **Ce constat ne tient plus** : les machines neuves
> sont en **8.4**, la CI en **8.2** — l'écart existe de nouveau, il est assumé et suivi
> en #471.

## 1. AVANT de commander — une chose à récupérer sur l'ancien serveur

> ### ✅ FAIT le 25/08 — cette section est désormais un historique, pas un reste à faire
>
> Le `.htaccess` de production **a été récupéré**, versionné **au §7.2 de ce document**, et
> **déjà déployé sur les deux fronts neufs** (`lmsefektiv-dev` et `lmsefektiv-prod`, servi
> vérifié). **Le blocant B11 (#176) est mort.** Les versions et modules Apache constatés sur
> l'ancien serveur sont au §9.
>
> La procédure ci-dessous est conservée : c'est elle qu'il faudra rejouer si un jour on
> monte une machine à partir d'un serveur dont la configuration n'est pas au dépôt.

⛔ **Le `.htaccess` du front n'est versionné dans aucun dépôt.** Vérifié : `git ls-tree` sur
le FRONT ne remonte aucun fichier `.htaccess`, et `deploy-staging.yml` l'exclut **deux fois**
du `rsync --delete` précisément parce qu'il appartient au serveur.

**Conséquence pour un serveur neuf : personne n'a ce fichier.** Il porte la réécriture d'URL
de la SPA — sans lui, tous les liens profonds tombent en 404. C'est le piège qui a fait
tomber le staging le 09/08 (#176, blocant **B11**).

**À récupérer dans la même session SSH que la mesure du disque :**

```bash
ssh ubuntu@57.129.1.122 '
  echo "=== .htaccess du FRONT de production ==="
  sudo cat /var/www/efektiv-academie.com/.htaccess 2>/dev/null \
    || sudo find /var/www -maxdepth 2 -name .htaccess -exec echo "--- {} ---" \; -exec cat {} \;
  echo
  echo "=== vhosts Apache actifs ==="
  ls -l /etc/apache2/sites-enabled/
  echo
  echo "=== modules Apache activés ==="
  ls /etc/apache2/mods-enabled/ | tr "\n" " "
  echo
  echo "=== versions en place ==="
  php -v | head -1; mysql --version; apache2 -v | head -1; composer --version
'
```

➡️ **Coller le résultat dans ce fichier**, section 9. C'est la seule copie de ces
configurations.

## 2. Commander la machine

### ⛔ Le piège qui a coûté une machine le 25/08 : l'OS présélectionné

**La console Hetzner présélectionne la LTS la plus récente — 26.04.** Elle embarque
**MySQL 8.4** et **PHP 8.5**, **tous deux interdits ici** :

| | pourquoi c'est interdit |
|---|---|
| **MySQL 8.4** | la production est en 8.0 ; changer de moteur *et* de version fait deux variables (décision 7 du [runbook §0.4](./14_RUNBOOK_BASCULE_PROD.md)) |
| **PHP 8.5** | `phpoffice/phpspreadsheet` plafonne à **< 8.5.0** — l'application ne s'installe pas (#471) |

⚠️ **Vécu le 25/08 : la première machine a dû être reconstruite.** Rien ne prévient au
moment de la commande ; l'écart n'apparaît qu'au `composer install`.

➡️ **Cocher Ubuntu 24.04 explicitement, à chaque commande.** Le contrôle se fait tout de
suite après le premier accès :
```bash
lsb_release -ds     # doit dire Ubuntu 24.04.x LTS — sinon on reconstruit MAINTENANT
```

### Le gabarit

> **Ce qu'on croyait le 23/08 :** « 3 vCPU / 4 Go / 80 Go, classe **CPX21** ». **La gamme
> Hetzner a été renouvelée : CPX21 n'existe plus.** Le nom a changé, pas le besoin.

| | valeur | pourquoi |
|---|---|---|
| Gabarit | **CPX22 — 2 vCPU / 4 Go RAM / 80 Go SSD** (constaté disponible le 25/08) | **la spec réelle, ce sont les planchers : 4 Go de RAM et 80 Go de disque. Le CPU n'a jamais été la contrainte** — d'où 2 vCPU sans état d'âme. Justification de fond en [§0.4 du runbook](./14_RUNBOOK_BASCULE_PROD.md) |
| ✅ **Disque : mesure FAITE le 25/08** | **LMS de production ≈ 1 Go** | API prod 580 Mo (storage compris) · front 420 Mo · base 1,5 Mo. Seuil de bascule au cran supérieur : ~40 Go → **on est à 2,5 % du seuil**. **CPX22 confirmé** (détail et ventilation : #156, commentaire du 25/08) |
| Cran au-dessus, si un jour la mesure dépasse ~40 Go | **CPX32** (160 Go) | l'agrandissement du disque est **probablement définitif** (on ne redescend plus de gabarit ensuite) — ⚠️ toujours non confirmé auprès du fournisseur |
| ⛔ **Plancher : 4 Go de RAM** | ne pas prendre 2 Go | pendant la bascule, la machine fait tourner `composer install`, les migrations et la restauration du dump **pendant qu'elle sert déjà** |
| Localisation | **UE** — Falkenstein, Nuremberg ou Helsinki | données personnelles d'apprenants. **Jamais hors UE** |
| OS | ⛔ **Ubuntu 24.04 LTS — décoché de la présélection** | voir l'encadré ci-dessus |
| Nom d'hôte | explicite et durable — retenus le 25/08 : **`lmsefektiv-dev`** et **`lmsefektiv-prod`** | on en aura d'autres : Microlearning, SaaS |
| Clé SSH | **la vôtre, ajoutée à la commande** | évite tout mot de passe root envoyé par mail |
| Sauvegardes | activer l'option du fournisseur **ET** prévoir un dépôt hors machine (§8) | c'est #305 |

**Tarif constaté le 25/08 : ≈ 19 €/mois par serveur, tout compris** — CPX22 + IPv4
(~0,60 €) + sauvegardes du fournisseur (+20 %).

⚠️ **Ce chiffre n'est pas une garantie : re-vérifier les tarifs ET les noms de gamme à
chaque commande.** Le 25/08 a déjà montré qu'un nom de gabarit peut disparaître entre deux
rédactions.

## 3. Premier accès et durcissement

**Trois corrections portées le 25/08** — chacune bloquait ou faussait l'exécution telle
qu'elle était écrite. Le détail de chacune suit le bloc.

```bash
ssh root@<IP>
lsb_release -ds                      # ⛔ contrôle OS AVANT tout : doit dire Ubuntu 24.04.x LTS

# — utilisateur de service, pas de travail en root
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/

# (a) ⛔ SANS CECI, deploy est dans le groupe sudo mais NE PEUT PAS sudo :
#     --disabled-password ne lui donne aucun mot de passe à taper.
echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/deploy
chmod 440 /etc/sudoers.d/deploy
visudo -c                            # contrôle de syntaxe, avant de fermer quoi que ce soit

# — horodatage et nom
timedatectl set-timezone Europe/Paris
hostnamectl set-hostname lmsefektiv-prod

# (b) — SSH : clés seulement, par DROP-IN (pas de sed sur sshd_config)
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'CONF'
PermitRootLogin no
PasswordAuthentication no
CONF
sshd -t && systemctl restart ssh     # ⛔ sshd -t AVANT restart : une erreur de syntaxe
                                     #    laisse le service mort, donc la machine injoignable

# — pare-feu
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable

# — correctifs de sécurité automatiques
apt update && apt -y upgrade
apt -y install unattended-upgrades fail2ban

# (c) — dpkg-reconfigure est INTERACTIF : on écrit le fichier directement
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'CONF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
CONF
```

**(a) `adduser --disabled-password` crée un sudoer incapable de `sudo`.** L'utilisateur est
bien dans le groupe `sudo`, mais `sudo` lui demande un mot de passe **qu'il n'a pas**. Toute
la suite de la procédure (§4 à §7) est en `sudo` : sans le fichier `sudoers.d`, elle
s'arrête à la première commande. Constaté sur la première machine du 25/08.

**(b) Un `sed` sur `sshd_config` rate les drop-ins.** Ubuntu cloud-init dépose ses propres
fichiers dans `/etc/ssh/sshd_config.d/`, et **dans `sshd`, c'est la PREMIÈRE valeur lue qui
gagne** — pas la dernière. Un `PasswordAuthentication yes` posé par cloud-init dans un
drop-in l'emporte donc sur le `no` réécrit par `sed` dans le fichier principal : le
durcissement paraît fait, et il ne l'est pas. Le nom `00-hardening.conf` place le fichier
**en tête** de l'ordre lexicographique, donc en position gagnante.

**(c) `dpkg-reconfigure -plow unattended-upgrades` ouvre un dialogue** et fige une
exécution non interactive. Le fichier écrit ci-dessus est exactement ce que ce dialogue
produit.

⚠️ **Avant de fermer cette session root, ouvrir une SECONDE session en `deploy@<IP>` et
vérifier qu'elle fonctionne — `sudo` compris.** Se verrouiller dehors est l'erreur classique.

✅ **Appliqué et prouvé le 25/08 sur les deux machines** : seconde session `deploy` ouverte
avant fermeture de root, `sudo` fonctionnel, puis **connexion root refusée** et **connexion
par mot de passe refusée** — constatées sur `lmsefektiv-dev` **et** `lmsefektiv-prod` (§9).

## 4. Socle logiciel

```bash
# — PHP 8.4 (le dépôt ondrej reste nécessaire sur Ubuntu 24.04, qui livre 8.3)
sudo add-apt-repository -y ppa:ondrej/php && sudo apt update
sudo apt -y install php8.4 php8.4-fpm php8.4-cli \
  php8.4-mbstring php8.4-dom php8.4-curl php8.4-xml php8.4-bcmath \
  php8.4-gd php8.4-zip php8.4-intl php8.4-mysql
#   ^ 8.4 = décision du 25/08 (#471) ; liste d'extensions alignée sur tests.yml:115 —
#     ne pas l'improviser. ⛔ PAS 8.5 : phpspreadsheet plafonne à < 8.5.0.

# — Apache + modules requis par la SPA et les en-têtes
sudo apt -y install apache2
sudo a2enmod rewrite headers expires proxy_fcgi setenvif
sudo a2enconf php8.4-fpm

# — MySQL 8.0
sudo apt -y install mysql-server

# — outils
sudo apt -y install git rsync unzip curl
php -r "copy('https://getcomposer.org/installer','composer-setup.php');" \
  && sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer \
  && rm composer-setup.php
```

> **Ce que disait la procédure du 23/08 :** `sudo mysql_secure_installation` juste après
> l'installation de MySQL. **Retiré du chemin nominal le 25/08** : la commande est
> **interactive** (elle fige une exécution non interactive) et **superflue sur le paquet
> Ubuntu moderne** — `root` y est déjà en `auth_socket`, et il n'y a **pas d'utilisateur
> anonyme** ni de base `test`. La lancer reste possible, en connaissance de cause, comme un
> geste **optionnel et interactif** ; elle n'est pas un prérequis de la suite.

⚠️ **Pas de Node, pas de npm** (§0). Si vous en installez « au cas où », vous créez une
divergence avec la CI que personne ne surveillera.

**Contrôle immédiat :**
```bash
php -v | head -1                      # doit dire 8.4.x
php -m | tr '\n' ' '                  # les 9 extensions doivent y être
mysql --version                       # doit dire 8.0.x
composer --version
```
✅ Constaté le 25/08 sur les deux machines : **PHP 8.4.24**, **9 extensions présentes**,
**MySQL 8.0.46**, **Composer 2.10** (§9).

## 5. Base de données

```bash
sudo mysql <<'SQL'
CREATE DATABASE efektiv_prod
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'efektiv'@'localhost' IDENTIFIED BY '<mot de passe fort et unique>';
GRANT ALL PRIVILEGES ON efektiv_prod.* TO 'efektiv'@'localhost';
FLUSH PRIVILEGES;
SQL
```

⚠️ **`utf8mb4` n'est pas optionnel** : les contenus pédagogiques contiennent des accents et
des emojis. Une base en `latin1` produit des dégâts silencieux et irréversibles.

⚠️ **Le mot de passe se génère CÔTÉ SERVEUR et ne transite par aucune session** — même
règle qu'au §7.3, où le motif complet est écrit.

⚠️ **Vérifier que le moteur par défaut est bien InnoDB** — c'est le cas depuis MySQL 5.5,
mais c'est précisément le sujet de #136 :
```bash
mysql -e "SELECT @@default_storage_engine;"   # doit dire InnoDB
```
✅ Constaté **InnoDB** sur les deux machines le 25/08 (§9), et la conversion complète du
dump de production y a été rejouée avec succès (#156 — voir §10).

## 6. Le code

⛔ **Le dépôt est PRIVÉ : le clone anonyme en `https` ne marche pas.** Corrigé le 25/08 —
la forme qui a fonctionné est une **clé de déploiement en LECTURE SEULE, générée sur la
machine** et enregistrée sur le dépôt.

```bash
# — clé de déploiement, générée SUR la machine (la partie privée n'en sort jamais)
ssh-keygen -t ed25519 -N '' -C "lmsefektiv-prod deploy key" -f ~/.ssh/id_ed25519_deploy
cat ~/.ssh/id_ed25519_deploy.pub
# ➡️ coller cette clé PUBLIQUE dans GitHub → dépôt → Settings → Deploy keys
#    ⛔ SANS cocher « Allow write access » : le serveur lit, il ne pousse jamais.
printf 'Host github.com\n  IdentityFile ~/.ssh/id_ed25519_deploy\n  IdentitiesOnly yes\n' >> ~/.ssh/config

sudo mkdir -p /var/www/api.<domaine-provisoire> && sudo chown deploy:deploy /var/www/api.<domaine-provisoire>
cd /var/www/api.<domaine-provisoire>
git clone git@github.com:AAZTEKDEV/EFEKTIVACADEMIE-BACK.git .
git checkout main          # ⛔ prod = main ; la machine de développement porte `development`

composer install --no-dev --optimize-autoloader

cp .env.example .env
# ⚠️ éditer .env : APP_ENV=production, APP_DEBUG=false, APP_URL,
#    DB_*, MAIL_* (Brevo), SENTRY_LARAVEL_DSN, PUSHER_*, FRONT_URL
php artisan key:generate
php artisan storage:link

sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
```

⚠️ **`APP_DEBUG=false` est vital** : à `true`, une erreur affiche la configuration complète,
identifiants de base inclus.

✅ **Fait le 25/08** — une clé de déploiement par machine, **lecture seule**, enregistrée sur
le dépôt : **id `161269634`** pour `lmsefektiv-dev` (branche `development`) et
**id `161273100`** pour `lmsefektiv-prod` (branche `main`). Une clé par machine : on en
révoque une sans toucher à l'autre.

⚠️ **`SENTRY_LARAVEL_DSN` : c'est #619, et ça n'est TOUJOURS PAS fait.** Le code est prêt
depuis le 16/08 ; la valeur n'a été posée **ni sur l'ancien serveur, ni sur les deux
machines neuves du 25/08** — **elles ne signalent rien**. ⛔ **Le nouveau serveur était
l'occasion de ne pas reproduire ça : elle n'a pas été saisie.** Contrôle 11 du §9 : NON.

⚠️ **Ne PAS lancer `php artisan migrate` à ce stade.** La base est vide ; les données
arrivent par restauration du dump de production, et c'est un document séparé. Migrer
maintenant créerait un schéma neuf qui entrerait en collision avec la restauration.

## 7. Apache, TLS et le domaine provisoire

### ⛔ 7.0 La correction majeure du 25/08 — où poser l'authentification

**Ce qu'on croyait :** poser `AuthType Basic … Require valid-user` **dans le bloc
`<Directory>`** des deux vhosts (c'est ce que disait le §7.3 du 23/08).

**Ce qui s'est passé :** l'API a répondu **500 — la vraie erreur Laravel, SANS demander
aucun mot de passe.** La protection n'existait pas.

**Pourquoi :** le bloc `<Directory>` porte `AllowOverride All`, indispensable au
`.htaccess` de Laravel (`public/.htaccess`) comme à celui de la SPA. Or **un `.htaccess`
est lu APRÈS la configuration `<Directory>` et l'écrase** : le `Require valid-user` du
vhost est purement et simplement remplacé par ce que dit le fichier applicatif. Une
protection qui dépend d'un fichier livré par le déploiement n'est pas une protection.

**La forme sûre — un bloc `<Location "/">`** : les sections `<Location>` sont évaluées
**après** les `.htaccess` et ne peuvent donc pas être écrasées par eux.

```apache
    # ⛔ NE PAS mettre ce bloc dans <Directory> : le .htaccess applicatif l'écrase.
    <Location "/">
        AuthType Basic
        AuthName "Verification avant bascule"
        AuthUserFile /etc/apache2/.htpasswd
        Require valid-user
    </Location>
```

**Éprouvé le 25/08, trois constats :** **401 sans identifiants** · **200 avec** ·
**`/storage/` en 403 même authentifié**. Les vhosts ci-dessous intègrent cette forme.

### 7.1 L'API

```apache
<VirtualHost *:80>
    ServerName api.<domaine-provisoire>
    DocumentRoot /var/www/api.<domaine-provisoire>/public

    <Directory /var/www/api.<domaine-provisoire>/public>
        AllowOverride All
        Require all granted
    </Directory>

    # — fermeture du domaine provisoire (§7.3) : dans <Location>, PAS dans <Directory>
    <Location "/">
        AuthType Basic
        AuthName "Verification avant bascule"
        AuthUserFile /etc/apache2/.htpasswd
        Require valid-user
    </Location>

    # ⛔ Le disque public ne doit PAS être servi tel quel — c'est #618.
    # Sur l'ancien serveur, la protection venait d'une configuration que
    # personne n'avait décidée et que rien ne testait.
    <Directory /var/www/api.<domaine-provisoire>/public/storage>
        Require all denied
    </Directory>

    ErrorLog  ${APACHE_LOG_DIR}/api-error.log
    CustomLog ${APACHE_LOG_DIR}/api-access.log combined
</VirtualHost>
```

⚠️ **Le bloc `storage` est un choix explicite, pas un réglage par défaut.** #618 a montré
que 46 certificats PDF nominatifs, au nom devinable, dépendaient d'une configuration
non écrite. **Ici, le refus est écrit, versionné, et vérifiable.** Si un usage légitime
casse (avatars, images de cours), on l'ouvrira **chemin par chemin**, en connaissance de
cause. ✅ **403 constaté le 25/08 sur les deux machines, y compris authentifié.**

### 7.2 Le front

```apache
<VirtualHost *:80>
    ServerName <domaine-provisoire>
    DocumentRoot /var/www/<domaine-provisoire>

    <Directory /var/www/<domaine-provisoire>>
        AllowOverride All
        Require all granted
    </Directory>

    # — même fermeture que l'API, et pour la même raison (§7.0)
    <Location "/">
        AuthType Basic
        AuthName "Verification avant bascule"
        AuthUserFile /etc/apache2/.htpasswd
        Require valid-user
    </Location>
</VirtualHost>
```

#### Le `.htaccess` du front — le VRAI, récupéré en production le 25/08

⛔ **Ce fichier n'existait nulle part ailleurs que sur le serveur OVH.** Le voici, à
l'identique — c'est lui qu'on dépose à la racine du `DocumentRoot` du front :

```apache
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]
```

Quatre lignes : la réécriture SPA standard, et **aucune règle de cache** — cohérent avec le
relevé Apache du [runbook §1.5](./14_RUNBOOK_BASCULE_PROD.md).

✅ **Déjà déployé sur les deux fronts neufs, servi vérifié (liens profonds OK). Le blocant
B11 (#176) est clos.**

> **Ce que le doc du 23/08 proposait « à défaut »**, et qui n'a jamais été le fichier réel :
> ```apache
> <IfModule mod_rewrite.c>
>   RewriteEngine On
>   RewriteBase /
>   RewriteRule ^index\.html$ - [L]
>   RewriteCond %{REQUEST_FILENAME} !-f
>   RewriteCond %{REQUEST_FILENAME} !-d
>   RewriteRule . /index.html [L]
> </IfModule>
> ```
> **Trois écarts avec la production** : `Options -MultiViews` absent, une condition `!-d`
> en trop, et le flag `QSA` manquant (celui-ci **perd la chaîne de requête** sur les liens
> profonds). ⚠️ **La leçon vaut au-delà de ce fichier : un « minimum fonctionnel » écrit de
> mémoire n'est pas un substitut à la configuration réelle** — c'est pour ça que le §1
> exigeait d'aller la chercher.

### 7.3 TLS et protection du domaine provisoire

```bash
sudo apt -y install certbot python3-certbot-apache
sudo certbot --apache -d <domaine-provisoire> -d api.<domaine-provisoire> \
     --register-unsafely-without-email --agree-tos
```

**Forme retenue le 25/08 : enregistrement `--register-unsafely-without-email`.** Le
renouvellement reste **automatique** (timer `certbot.timer` posé par le paquet) — ce que
l'option supprime, ce sont **les alertes d'expiration par courriel**.

⚠️ **Ce n'est donc pas neutre : plus personne n'est prévenu si un renouvellement échoue.**
Ça tombe exactement dans le trou « **exploitation courante** » du
[runbook §0.5](./14_RUNBOOK_BASCULE_PROD.md) — **le choix de l'adresse à déclarer est un
arbitrage en attente**, pas un oubli. (Une adresse se déclare après coup :
`certbot update_account --email <adresse>`.)

⛔ **Le domaine provisoire portera des données réelles d'apprenants. Il doit être fermé :**

```bash
sudo apt -y install apache2-utils

# — le mot de passe est GÉNÉRÉ SUR LA MACHINE, il ne transite par aucune session
MDP="$(openssl rand -base64 24)"
sudo htpasswd -bc /etc/apache2/.htpasswd efektiv "$MDP"

# — et il est déposé chez deploy, lisible par lui seul
printf 'utilisateur : efektiv\nmot de passe : %s\n' "$MDP" > ~/acces-prod-provisoire.txt
chmod 600 ~/acces-prod-provisoire.txt
unset MDP
```

⛔ **Le motif compte autant que la commande :**

- **généré côté serveur** (`openssl rand`) — aucune session ne le choisit ;
- **jamais transmis par une session, jamais collé dans une issue, jamais versionné** ;
- **déposé sur la machine** dans `~/acces-prod-provisoire.txt` chez `deploy`, en `chmod 600` ;
- utilisateur **`efektiv`**.

➡️ **Le bloc `<Location "/">` du §7.0 est ce qui rend cette protection effective.** Posée
dans `<Directory>`, elle est écrasée par le `.htaccess` applicatif — c'est exactement ce qui
s'est produit le 25/08.

Et un `robots.txt` en `Disallow: /` sur les deux hôtes.

⚠️ **Sans ça, on publie sur l'internet ouvert une copie complète de la base d'apprenants.**
Ce n'est pas une précaution de confort.

## 8. Sauvegardes — hors de la machine

⛔ **C'est #305, et c'est la seule chose qui ne se rattrape pas.** Une sauvegarde qui vit sur
le serveur qu'elle protège ne protège de rien.

- [ ] Un `mysqldump` quotidien **envoyé hors de la machine** (Storage Box, S3, autre serveur).
- [ ] Les fichiers déposés (`storage/app/public`) inclus — **ce sont les certificats
      QUALIOPI**, ils ne se régénèrent pas tous.
- [ ] ⚠️ **Une restauration testée.** Une sauvegarde jamais restaurée est une hypothèse.
- [ ] Rétention décidée et écrite.

## 9. Contrôles de fin d'installation

À remplir **au fur et à mesure**, dans ce fichier. **Colonne « constaté » = relevé du
25/08 sur `lmsefektiv-dev` ET `lmsefektiv-prod`** (valeur identique sur les deux sauf
mention) :

| # | contrôle | attendu | constaté — 25/08 |
|---|---|---|---|
| 1 | `php -v` | 8.4.x | ✅ **8.4.24** |
| 2 | `php -m` | les 9 extensions de `tests.yml:115` | ✅ **9/9 présentes** |
| 3 | `mysql --version` | 8.0.x | ✅ **8.0.46** |
| 4 | `SELECT @@default_storage_engine` | `InnoDB` | ✅ **InnoDB** |
| 5 | `curl -I https://api.<provisoire>` | 200 ou 401, **jamais 500** | ✅ **200 / 401** — jamais 500 côté fermé. ⚠️ voir la note ci-dessous |
| 6 | `curl -I https://api.<provisoire>/storage/` | **403 ou 404** (#618) | ✅ **403**, y compris authentifié |
| 7 | lien profond du front (`/courses/xxx`) | l'application, **pas un 404** | ✅ **OK** — avec le `.htaccess` réel de production (§7.2) |
| 8 | `APP_DEBUG` | `false` | ✅ **false** |
| 9 | `.env` hors git | `git status` propre | ✅ **hors git** |
| 10 | connexion SSH par mot de passe | **refusée** | ✅ **refusée** |
| 10 bis | connexion SSH en `root` | **refusée** | ✅ **refusée** (les deux machines) |
| 11 | Sentry reçoit un événement de test | oui (#619) | ⛔ **NON — le DSN n'a pas été posé.** #619 reste ouverte (§6) |
| 12 | sauvegarde jouée **et restaurée** | oui | 🟡 **partiel** — voir ci-dessous |

⚠️ **Note sur le contrôle 5 — `api-prod` répond 500 UNE FOIS AUTHENTIFIÉ.** C'est
**attendu** : sa base est vide tant que la restauration du dump de production n'a pas eu
lieu. Ce qui compte ici est ce que voit un visiteur non authentifié : **401, jamais 500**.
Le 500 disparaît avec la restauration.

🟡 **Contrôle 12, l'état exact — deux moitiés, une seule faite :**

| | état |
|---|---|
| Sauvegardes du fournisseur (Hetzner) | ✅ **actives sur les deux machines** |
| Restauration d'un dump **éprouvée** | ✅ **oui** — la répétition générale #156 a rejoué la séquence complète **sur les données réelles** le 25/08, deux passes d'affilée, la seconde en no-op |
| **Dépôt quotidien HORS de la machine** | ⛔ **TOUJOURS DÛ — c'est #305, et c'est le §8.** Une sauvegarde qui vit sur le serveur qu'elle protège ne protège de rien |

### Configurations récupérées de l'ancien serveur (§1)

**Le `.htaccess` du front de production** — 4 lignes, seule copie au monde jusqu'au 25/08,
maintenant versionnée. Elle vit **au §7.2**, à sa place d'usage ; la voici pour mémoire :

```apache
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]
```

**Apache sur l'ancien serveur OVH** (pour mémoire) : **mpm_prefork + mod_php8**, modules
`headers`, `rewrite`, `deflate`, `ssl` actifs, **pas de `mod_expires`** (cohérent avec
l'absence de règle de cache dans le `.htaccess`).

⚠️ **Écart assumé** : **les machines neuves sont en `php-fpm` + mpm `event`**, pas en
`mod_php`. C'est l'alignement sur la CI et sur la procédure du §4 — l'écart est **choisi**,
il est noté ici pour que personne ne le redécouvre en cherchant une panne.

**Versions constatées sur les machines neuves** : PHP **8.4.24** · MySQL **8.0.46** ·
Composer **2.10** · Ubuntu **24.04 LTS**.

**Clé Stripe de la production (#600)** : mesurée en lecture seule le 25/08, **préfixes
seuls** — `pk_test_…` / `sk_test_…` → **la production tourne sur des clés de TEST**. Aucun
paiement réel ne peut être encaissé. **Le verdict est porté sur #600** ; l'arbitrage
restant y est produit, pas technique.

⛔ **Aucune valeur de `.env` n'est reproduite ici, ni aucun mot de passe** — seulement des
pointeurs. Le `.env` de production a été sauvegardé hors serveur le 25/08 (hors dépôt, réf.
#156).

## 10. Ce que cette procédure ne couvre PAS

- **La migration des données** — dump, conversion InnoDB, migrations, clés étrangères,
  balayage des orphelins. **Elle reste hors de ce document, mais elle n'est plus un
  manque** : elle est **outillée** (`scripts/bascule/`, versionnée et idempotente) et
  **éprouvée sur les données réelles** le 25/08 — dump réel de `e-learning-prod`, sur la
  machine cible, code `main`, **deux passes d'affilée, la seconde en no-op, empreintes de
  schéma identiques** (58 tables MyISAM → 67 tables InnoDB, 69 FK). **Le blocant B14 de
  #156 est levé** ; il reste le geste du jour J (dump frais du gel).
  > **Ce que ce document disait le 23/08 :** « ⛔ le script de conversion InnoDB n'est
  > toujours ni versionné ni testé (blocant **B14**) ». **Plus vrai depuis le 25/08.**

  ⚠️ **Deux chiffres à ne plus citer figés** : ni « 63 clés étrangères » (mesuré **69** le
  24/08 — le nombre bouge à chaque sprint, la séquence le dérive à l'exécution et ne
  l'écrit nulle part), ni « **Composer 2.2.6** » : cette précision datait du plan de
  conversion **EN PLACE sur le serveur OVH**, plan qui n'existe plus. **Les machines neuves
  portent Composer 2.10**, et le `composer install` y est déjà joué et vert (#303).
- **La bascule DNS** — §0.7 étape 4 du runbook.
- **Le déploiement continu vers ce serveur** — la décision « qui déploie la prod » reste
  ouverte (#307), et le `deploy.yml` de la branche `pipeline` est à neutraliser, pas à
  activer.
- **Microlearning et le SaaS** — machines séparées, décision du 23/08 (runbook §0.4).
- **L'exploitation courante** — supervision, astreinte, renouvellement TLS surveillé.
  ⚠️ **Trou identifié et non comblé** (runbook §0.5).

---

_Toute correction constatée pendant l'exécution se porte **dans ce fichier**. Une procédure
qu'on corrige de mémoire redevient fausse au passage suivant._
