# Déploiement Coolify — procédure ordonnée

> **Raccourci : `create-wp-reactor --provision` fait tout ce qui suit.**
> La procédure manuelle ci-dessous reste la référence — pour comprendre ce qui
> est créé, reprendre la main sur une étape, ou provisionner sans les jetons
> d'API. Voir [§ Provisioning automatique](#provisioning-automatique).

## Topologie

Quatre ressources Coolify par environnement :

| Ressource | Type Coolify | Pourquoi ce type |
|---|---|---|
| `__PROJECT_NAME__-mariadb` | *Database* → MariaDB 11 | Backups planifiés managés par Coolify |
| `__PROJECT_NAME__-redis` | *Database* → Redis | Cache objet WordPress |
| `__PROJECT_NAME__-wordpress` | *Docker Compose* (`apps/wordpress/docker-compose.yml`) | Labels Traefik + alias réseau **versionnés dans le repo** — rien à recopier dans l'UI |
| `__PROJECT_NAME__-webapp` | *Application* (image GHCR) | **Rolling update** zero-downtime natif via le healthcheck `/api/health` |

WordPress est **stateful** (volume partagé) : pas de rolling update — non supporté en
mode compose Coolify, et déconseillé ici de toute façon (deux conteneurs sur le même
`/var/www/html`). Un court blip admin/GraphQL au redeploy est acceptable. La webapp est
stateless, elle garde donc le rolling — c'est la raison pour laquelle elle n'est **pas**
un compose.

Les deux images sont construites par `.github/workflows/deploy.yml` (push sur `main`)
et poussées sur GHCR (Option 2 : build en CI, jamais sur l'hôte). Variables d'env :
voir [`environment.md`](./environment.md) ; labels Traefik webapp :
[`traefik-labels.md`](./traefik-labels.md).

## Provisioning automatique

```bash
npx create-wp-reactor@latest --provision
```

Au premier appel, la commande **génère** son fichier de configuration
(`.env.provision`, `chmod 600`), liste les clés à remplir et attend. Tu le
remplis, tu appuies sur Entrée : il est relu et revalidé. Une fois complet, un
récapitulatif s'affiche et la confirmation est demandée **avant le moindre
appel** — le provisioning n'est pas idempotent et n'a pas de rollback.

Le fichier porte tout : paramètres (`WPR_PROJECT_NAME`, `WPR_DOMAIN`,
`WPR_GH_OWNER`, `WPR_COOLIFY_SERVER`) et jetons (`GITHUB_TOKEN`, `COOLIFY_URL`,
`COOLIFY_TOKEN`, `NODE_AUTH_TOKEN`, et la paire optionnelle `CF_API_TOKEN` /
`CF_ZONE_ID`). Précédence : flag CLI > variable du shell > fichier.

Enchaîne, dans cet ordre : scaffolding → repo GitHub (+ variables et secrets
Actions) → enregistrements DNS → projet Coolify, MariaDB, Redis, ressource
Compose WordPress, Application webapp → secrets `COOLIFY_*_UUID` → premier push.

L'ordre n'est pas cosmétique, il dénoue un cycle : Coolify a besoin d'images sur
GHCR, ces images viennent de la CI, et la CI a besoin des UUID Coolify pour
déclencher le déploiement. Les ressources sont donc créées **sans déployer**, et
c'est le push final qui lance la CI, laquelle construit les images puis appelle
elle-même `/api/v1/deploy`.

Tous les secrets sont générés (`SESSION_SECRET`, mots de passe DB/Redis/admin) et
ne sont écrits **que** dans l'environnement Coolify — jamais dans le repo. Le mot
de passe admin WordPress est affiché une seule fois, en fin de commande.

Ajouter `--dry-run` pour imprimer le plan d'appels sans en émettre aucun. Un
dry-run ne valide aucun jeton, puisqu'il ne contacte aucun serveur, et saute la
confirmation — il n'a rien à créer.

En CI (ou stdin redirigé), la commande ne bloque jamais : `--yes` saute la
confirmation, et une configuration incomplète échoue en listant les clés
manquantes. La forme à flags reste disponible pour scripter sans fichier
(`--domain`, `--gh-owner`, `--coolify-server`, le nom du projet en argument).

Le fichier de configuration ne concerne que le **provisioning** : l'environnement
runtime des applications, lui, vit dans Coolify (cf. [`environment.md`](./environment.md)).

**Ce que `--provision` ne peut pas faire** : le `docker login ghcr.io` sur l'hôte
(l'API Coolify n'expose pas les credentials de registry) et la Cache Rule
Cloudflare. Voir ci-dessous.

## Prérequis (une fois)

1. **GHCR accessible par le serveur** : les images sont privées → `docker login` en
   **root** sur l'hôte Coolify (procédure ci-dessous). **Seule étape que
   `--provision` ne peut pas automatiser.**
2. **DNS** chez Cloudflare : `__PROJECT_NAME__.example.com` (front) et
   `wp.__PROJECT_NAME__.example.com` (WordPress) → IP du serveur Coolify, **proxy activé**
   (nuage orange) pour le front, **DNS-only** (nuage gris) pour WordPress
   (l'admin/GraphQL ne doit pas passer par le cache edge).
3. **Secrets GitHub Actions** renseignés (cf [`environment.md`](./environment.md) §2).

### Login GHCR sur l'hôte (SSH, en root)

Les images `webapp` et `wordpress` sont **privées** sur GHCR. Coolify tire les images
**en root** sur l'hôte (`docker compose pull`, `docker pull`) : ce sont donc les
credentials de **root** qui comptent. Or le store Docker est *par utilisateur*
(`$HOME/.docker/config.json`) — un `docker login` lancé avec ton user SSH écrit dans
`/home/<user>/.docker/config.json`, que Coolify ne lit jamais. Symptôme :
`denied` / `unauthorized` au pull, alors que le login « a marché ».

```bash
ssh <user>@<serveur-coolify>

# PAT GitHub *classique*, scope read:packages — et autorisé SSO si l'org l'exige.
# --password-stdin : le token ne passe ni par l'historique shell ni par `ps`.
printf '%s' '<PAT>' | sudo docker login ghcr.io -u <github-user> --password-stdin

# Vérifier que la config a bien atterri chez ROOT (et nulle part ailleurs) :
sudo ls -l /root/.docker/config.json
```

> Si le fichier n'est pas là, c'est que ta distro conserve `HOME` à travers `sudo`
> (`env_keep`) : la config est partie dans ton home, possédée par root. Rejouer avec
> `sudo -H docker login …` (ou `sudo -i`), puis supprimer le fichier parasite.
>
> Le token y est stocké en **base64, non chiffré** → `sudo chmod 600 /root/.docker/config.json`.

Un PAT `read:packages` suffit : l'hôte ne fait que **tirer**, jamais pousser (les images
sont buildées et poussées par la CI). En complément, l'UI Coolify (*Sources / Registries*)
permet d'attacher le même registre à une ressource de type Application ; le login hôte,
lui, vaut pour **toutes** les ressources du serveur, compose inclus.

## Ordre de déploiement (1er provisioning)

> L'ordre compte : la base doit exister avant WordPress, et WordPress doit répondre
> avant que la webapp (SSR) ne l'interroge.

### 1. MariaDB — créer la base et son utilisateur

Coolify → projet → *+ New* → *Databases* → **MariaDB** (version 11, comme en dev).

**Personne ne crée la base à ta place** : l'entrypoint WordPress n'écrit que
`wp-config.php`, il ne fait aucun `CREATE DATABASE`. Et l'image MariaDB n'honore
`MARIADB_DATABASE` / `MARIADB_USER` / `MARIADB_PASSWORD` **qu'au tout premier
démarrage**, quand le datadir est vide. Renseigne-les donc dans l'onglet *Environment
Variables* de la ressource **avant son premier déploiement** :

| Variable (ressource MariaDB) | Valeur |
|---|---|
| `MARIADB_DATABASE` | `wordpress` |
| `MARIADB_USER` | `wordpress` |
| `MARIADB_PASSWORD` | mot de passe applicatif |
| `MARIADB_ROOT_PASSWORD` | mot de passe root (généré par Coolify) |

Puis déployer la ressource.

> **Ressource déjà déployée avec d'autres valeurs ?** Le volume n'est plus vide → les
> variables ci-dessus sont ignorées au redémarrage. Créer la base à la main, une fois,
> via *Terminal* sur la ressource MariaDB :
>
> ```sh
> mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" <<'SQL'
> CREATE DATABASE IF NOT EXISTS wordpress
>   CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER IF NOT EXISTS 'wordpress'@'%' IDENTIFIED BY 'mot-de-passe';
> GRANT ALL PRIVILEGES ON wordpress.* TO 'wordpress'@'%';
> FLUSH PRIVILEGES;
> SQL
> ```
>
> `utf8mb4` est requis (emoji, caractères 4 octets). L'hôte `'%'` est nécessaire :
> WordPress se connecte depuis **un autre conteneur**, pas depuis `localhost`.

Ensuite, trois réglages à ne pas rater :

- **Hostname interne** → Coolify affiche une *Internal URL* du type
  `mysql://wordpress:…@ig8ok4sgw0oc:3306/wordpress`. C'est **l'hôte de cette URL**
  qu'il faut reporter dans `WORDPRESS_DB_HOST` côté ressource WordPress. Le port
  `3306` est implicite.
  Cet hôte est **l'UUID de la ressource** (≠ son nom d'affichage) — celui de
  l'URL de la page Coolify. C'est pour ça que `--provision` n'a rien à relire :
  le POST de création le lui renvoie déjà.
- **Réseau** → WordPress résout la base via le réseau `coolify`. Si la base n'est pas
  dans le même projet, active *Connect To Predefined Network* sur la ressource MariaDB.
- **Public Port** → laisser **désactivé**. L'accès admin passe par le *Terminal* Coolify
  ou un tunnel SSH, jamais par un port ouvert sur Internet.

Enfin, onglet *Backups* : planifier un dump (+ destination S3 si disponible). C'est le
filet du §[Rollback](#rollback) — un rollback d'image ne rattrape **pas** une migration
de schéma jouée par un plugin.

### 2. Redis

Même chemin : *+ New* → *Databases* → **Redis**. Reporter l'hôte de l'*Internal URL*
dans `WP_REDIS_HOST` et le mot de passe dans `WP_REDIS_PASSWORD`.

> **Alternative — stack 100 % autonome** : le bloc commenté en bas de
> `apps/wordpress/docker-compose.yml` embarque les services `db` (mariadb:11) et `redis`
> dans le compose lui-même. Même règle de création : les `MARIADB_*` du service `db`
> créent la base au premier boot du volume `db_data`. Pointer alors
> `WORDPRESS_DB_HOST=db` et `WP_REDIS_HOST=redis`. Les backups sont à ta charge.

### 3. Ressource WordPress (Docker Compose)

1. Coolify → *New Resource* → *Docker Compose* → dépôt du repo client, chemin du
   compose : `apps/wordpress/docker-compose.yml`.
2. **Environment Variables** : section *App WordPress* de [`environment.md`](./environment.md).
   Dont **`WORDPRESS_IMAGE`** (`ghcr.io/<owner>/<repo>/wordpress:main`), les
   **`WORDPRESS_DB_*`** / **`WP_REDIS_*`** des étapes 1–2, et **`WP_HOST`** / **`APP_HOST`**
   (lus par les labels Traefik du compose).
3. **Labels & alias réseau** : rien à faire — déjà dans le compose (bloc `labels:`
   du service `wordpress` + alias `__PROJECT_NAME__-wordpress-internal` dans `networks:`).
4. **Persistent Storage** : marquer le volume `wp_data` (`/var/www/html`) comme
   *persistant* dans l'UI (conserve core, uploads, plugins tiers).
5. Déployer. Le conteneur doit passer *healthy* ; s'il boucle sur une erreur de
   connexion DB, revoir l'hostname et le réseau de l'étape 1.

### 4. Provisioning WordPress (wp-cli, une fois)

La base est vide : il reste à installer le core, les plugins (GraphQL + SEO) et à
activer le thème enfant. wp-cli et le script de provisioning
(`apps/wordpress/docker/bootstrap.sh`, le même qu'en dev, idempotent) sont **bakés dans
l'image**.

> **Automatique avec `WP_AUTO_BOOTSTRAP=1`** (posé par `--provision`) :
> `child-entrypoint.sh` lance le script en tâche de fond au démarrage du
> conteneur, sous l'uid 33 (www-data). Il attend l'arrivée du core puis de la
> base, et ne refait rien aux démarrages suivants. Il faut alors fournir
> `WP_SITE_TITLE` et les `WP_ADMIN_*` dans l'env de la ressource — le reste
> (`WORDPRESS_DB_*`, `WP_HOME`, `CHILD_THEME`) y est déjà.
>
> Le premier boot est plus long : le script télécharge les plugins depuis
> wordpress.org et des releases GitHub. Suivre `docker logs` de la ressource.

Sinon (`WP_AUTO_BOOTSTRAP` absent ou à `0`), un `docker exec` dans le conteneur :

```bash
# sur l'hôte Coolify — aucune copie du repo nécessaire
docker exec -u 33:33 \
  -e WP_SITE_TITLE='__PROJECT_TITLE__' \
  -e WP_ADMIN_USER=<admin> \
  -e WP_ADMIN_PASSWORD=<mot-de-passe-long> \
  -e WP_ADMIN_EMAIL=<email> \
  <conteneur-wordpress> __PROJECT_NAME__-bootstrap.sh
```

> `WORDPRESS_DB_*`, `WP_HOME` et `CHILD_THEME` n'ont pas à être repassés : `docker exec`
> hérite de l'environnement du conteneur (env Coolify + `ENV` de l'image). Seules les
> valeurs d'admin, qui n'existent nulle part ailleurs, sont fournies ici.
>
> `-u 33:33` (www-data) est **important** : les plugins installés par wp-cli doivent
> appartenir à www-data pour rester gérables depuis l'admin WordPress.

Vérifier ensuite `https://wp.__PROJECT_NAME__.example.com/wp-admin` et `/graphql`.

Le même `docker exec` sert pour toute la maintenance (`wp plugin update --all`,
`wp search-replace`, `wp cache flush`…) : tout passe par mysqli/PHP.

**Exception : les sous-commandes `wp db`.** Elles shellent vers un binaire client MySQL,
volontairement absent de l'image (+85 Mo pour un besoin ponctuel). Pour un dump, un
conteneur jetable — dont le client est aligné sur la version du serveur, ce que l'image
ne garantirait pas :

```bash
docker run --rm --network coolify mariadb:11 \
  mariadb-dump -h <hostname-mariadb> -u <user> -p<mot-de-passe> <base> > dump.sql
```

### 5. App Webapp (Application — zero-downtime)

1. Coolify → *New Resource* → *Docker Image* → `ghcr.io/<owner>/<repo>/webapp:main`.
2. **Network** : réseau `coolify` (pour résoudre `__PROJECT_NAME__-wordpress-internal`).
3. **Environment Variables** : section *App Webapp* de [`environment.md`](./environment.md)
   (dont `WORDPRESS_INTERNAL_URL=http://__PROJECT_NAME__-wordpress-internal`, `SESSION_SECRET`,
   `CF_ZONE_ID`, `CF_API_TOKEN`).
4. **Labels** Traefik : bloc Webapp de [`traefik-labels.md`](./traefik-labels.md).
   (`--provision` les pose par API — en deux temps, voir la note de ce document.)
5. **Health Check** : activer, chemin `/api/health`, port `3000`. **Requis** pour le
   *rolling update* : Coolify démarre le nouveau conteneur, attend qu'il soit *healthy*,
   puis coupe l'ancien → **aucune coupure** entre deux déploiements du front.
6. **Post-deployment command** : `node scripts/clear-cache.mjs` (purge Cloudflare, cf §Cloudflare).
7. Déployer. Vérifier `https://__PROJECT_NAME__.example.com`.

## Déploiements suivants (automatiques)

Push sur `main` → `deploy.yml` build+push les images puis appelle l'API Coolify
(`COOLIFY_*`) pour redéployer. La **post-deployment command** purge le cache edge.
Rien à faire manuellement.

### Seules les images concernées sont reconstruites

`deploy.yml` compare les fichiers modifiés et saute le job dont rien n'a bougé :

| Chemins touchés | Images reconstruites |
|---|---|
| `apps/webapp/**`, `pages/**` | webapp seule |
| `apps/wordpress/**` | wordpress seule |
| `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `.github/workflows/**` | les deux |
| premier push, `workflow_dispatch`, force push | les deux (base de comparaison inconnue) |

Ce n'est pas une optimisation de minutes de CI : WordPress tourne en Compose,
donc **sans rolling update**, et chaque redéploiement coupe l'admin et `/graphql`
une dizaine de secondes. Un restyle du front ne doit pas infliger ce blip — le
front rend les blocs depuis `apps/webapp`, le thème enfant ne sert que l'éditeur.

Pour forcer la reconstruction des deux images : *Run workflow* (`workflow_dispatch`).

## Cache HTML (Redis) — pourquoi une page ne peut plus répondre 502

Deux étages de cache, dans cet ordre : **Redis côté origine**, Cloudflare côté
edge. Le second est optionnel ; le premier ne l'est pas, parce que c'est lui qui
tient pendant un redéploiement.

Le middleware `htmlCache` (kernel, monté dans `apps/webapp/src/start.ts`) garde le
HTML SSR dans Redis — la MÊME instance que l'object cache WordPress, isolée par un
préfixe de clé. Un HIT répond sans rendu et sans le moindre appel WPGraphQL.

Chaque page y vit sous **deux clés** :

| Clé | TTL | Rôle |
|---|---|---|
| `<prefix><path>` | `HTML_CACHE_STALE_TTL` (24 h) | le HTML |
| `<prefix>fresh:<path>` | `HTML_CACHE_TTL` (1 h) | marqueur de fraîcheur |

Marqueur présent ⇒ page servie telle quelle (`x-html-cache: HIT`). Marqueur
expiré mais HTML encore là ⇒ un rendu est tenté, et **s'il échoue** (WordPress qui
redémarre, 5xx) l'ancien HTML est servi (`x-html-cache: STALE`) au lieu d'une 502.
Une page vue au moins une fois dans les dernières 24 h est donc toujours servable,
CDN ou pas.

Conséquence : **purger, c'est périmer**. Le webhook `/api/cache/purge` ne supprime
que les marqueurs — appelé par WordPress à chaque publication (plugin de base
`html-cache-purge.php`, ou le bouton *Purger le cache HTML* de la barre d'admin) et
au déploiement (`scripts/clear-cache.mjs`). Supprimer les corps rouvrirait la
fenêtre de 502 exactement quand les conteneurs redémarrent.

Env : `REDIS_URL` et `CACHE_PURGE_SECRET` côté webapp, `CACHE_PURGE_SECRET`
(identique) côté WordPress — posés par `--provision`. `REDIS_URL` absent ⇒ cache
inactif, l'app se comporte comme avant.

## Cloudflare

Le HTML SSR public est **mutualisé pour tous les visiteurs** (l'auth et le panier
s'hydratent côté client, jamais au SSR) — il est donc cachable à l'edge. Le middleware
`cacheControl` (framework) émet `s-maxage` + `stale-while-revalidate` sur les pages
publiques et `private, no-cache` sur les routes perso (`/mon-compte`, `/cart`, `/login`,
`/preview`) ou toute réponse posant un `Set-Cookie`.

### Cache Rule (zone du front)

Crée **une Cache Rule** Cloudflare sur la zone `__PROJECT_NAME__.example.com` :

- **Si** : `Method eq GET` **et** l'URI ne commence pas par `/mon-compte`, `/cart`,
  `/login`, `/preview`, `/api`.
- **Alors** : *Eligible for cache* + *Respect origin TTL* (honore le `s-maxage` envoyé
  par l'origine — ne PAS forcer un Edge TTL fixe).

> ⚠️ Ne **pas** bypasser le cache sur présence de cookie : le HTML public ne porte
> jamais de `Set-Cookie` et reste cachable même si la requête a un cookie de session.
> Bypasser sur cookie casserait le cache dès le 1er ajout panier.

### Token de purge (post-deploy)

`scripts/clear-cache.mjs` purge la zone après chaque deploy (sinon l'ancien HTML
caché référence des assets hashés disparus → 404 CSS/JS). Crée un **API Token**
Cloudflare scopé `Zone → Cache Purge → Purge` sur la zone du front, puis renseigne
dans l'app webapp Coolify :

- `CF_ZONE_ID` — Zone ID de `__PROJECT_NAME__.example.com`
- `CF_API_TOKEN` — le token ci-dessus

Si l'un manque, la purge est ignorée (le déploiement n'échoue pas).

### WordPress (host dédié)

Le host `wp.__PROJECT_NAME__.example.com` reste **DNS-only** (pas de proxy edge) :
l'admin, GraphQL (POST) et `/wp-json` ne doivent jamais être mis en cache partagé.
Les assets/uploads WordPress sont cachés via les labels Traefik du compose
(`apps/wordpress/docker-compose.yml`), pas par Cloudflare.

## Rollback

Les images sont taguées par SHA (`...:<sha>`).

- **Webapp** (Application) : pointer l'app sur le tag SHA précédent et redéployer.
- **WordPress** (Compose) : le compose lit `image: ${WORDPRESS_IMAGE}`. Passer la var
  d'env `WORDPRESS_IMAGE` à `ghcr.io/<owner>/<repo>/wordpress:<sha>` puis redéployer
  (Coolify fait `docker compose pull && up -d`).

> Le rollback d'image ne défait **pas** une migration de schéma : avant un déploiement
> prod à risque, déclencher un backup manuel depuis l'onglet *Backups* de la ressource
> MariaDB (cf. étape 1).
