# Déploiement prod — Eleva Speed

Runbook complet pour déployer la stack Eleva Speed en mode prod-test
auto-hébergé sur un seul host Docker. Tout ce que tu as besoin de savoir
pour `build → up → smoke test → opérations courantes`.

> **TL;DR** — `cp .env.prod.example .env.prod` (remplir), `pnpm prod:build`,
> `pnpm prod:up`, `curl http://localhost:3000/api/health`. Détails ci-dessous.

---

## 1. Vue d'ensemble

### Services déployés

| Service                | Image                          | Rôle                                 | Réseau                   |
| ---------------------- | ------------------------------ | ------------------------------------ | ------------------------ |
| `app`                  | `eleva-speed-app:latest`       | Next.js standalone (le SaaS)         | `internal-net` + `proxy` |
| `postgres`             | `postgres:17-alpine`           | Base de données primaire             | `internal-net`           |
| `redis`                | `redis:8-alpine`               | Queue jobs pour le worker sitespeed  | `internal-net`           |
| `sitespeed-worker`     | `eleva-speed-sitespeed-worker` | Mesures perf (Chrome + sitespeed.io) | `internal-net`           |
| `migrator` (on-demand) | `eleva-speed-migrator`         | One-shot drizzle-kit migrate         | `internal-net`           |

Aucun service ne bind de port à l'host. **Le TLS + l'ingress sont gérés
par le Traefik host-wide** (cf. §3.6) qui découvre `app` via les labels
Docker et le réseau partagé `proxy`.

### Modèle réseau (résumé)

- **`internal-net`** (créé par cette stack, `internal: true`) — Docker ne
  pose pas la règle MASQUERADE → postgres / redis / sitespeed-worker ne
  peuvent pas joindre internet directement. C'est la "DB tier".
- **`proxy`** (créé hors de cette stack par le Traefik host-wide,
  référencé en `external: true` dans notre compose) — réseau partagé
  avec les autres apps du host (plane, firecrawl, etc.). Traefik
  découvre les containers via le provider Docker et la label
  `traefik.enable=true`.
- **`app` est dual-homed** : il joint à la fois `internal-net` (pour
  parler à postgres / redis / sitespeed-worker en service-name) ET
  `proxy` (pour que Traefik puisse l'atteindre sur `app:3000`).
- Aucun de nos containers ne bind de port à l'host. Le seul process
  écoutant sur `0.0.0.0:80/443` est le Traefik host-wide.

### Volumes persistants

Tous les volumes sont des **bind mounts** sur la partition data du host
(`/data` par défaut, override via `ELEVA_DATA_ROOT` dans `.env.prod`).
Permet de garder la SSD système petite et de mettre la grosse data sur
le RAID array de 200+ GB.

| Bind mount                   | Contenu                                 | UID owner |
| ---------------------------- | --------------------------------------- | --------- |
| `${ELEVA_DATA_ROOT}/pg`      | Postgres data dir                       | `999`     |
| `${ELEVA_DATA_ROOT}/redis`   | Redis AOF                               | `999`     |
| `${ELEVA_DATA_ROOT}/results` | Artefacts sitespeed (HAR, screenshots…) | `1001`    |

Les 3 dirs doivent exister AVANT `prod:up` et être chownées au bon UID :

```bash
sudo mkdir -p /data/eleva-speed-prod/{pg,redis,results}
sudo chown -R 999:999  /data/eleva-speed-prod/pg /data/eleva-speed-prod/redis
sudo chown -R 1001:1001 /data/eleva-speed-prod/results
```

---

## 2. Prérequis

- **Docker** ≥ 24 avec un daemon Linux. Sur macOS : Colima ou Docker Desktop.
- **pnpm** ≥ 10.33 (déjà épinglé dans `package.json#packageManager`).
- Au moins **4 GB de RAM libre** pour la VM Docker (sitespeed + Chrome sont
  gourmands — 3 GB rien que pour le worker).
- Aucune dépendance externe au démarrage : la stack est complètement
  self-contained.

### Plugin buildx (optionnel)

Le `Dockerfile` est rédigé pour fonctionner **sans** le plugin `docker
buildx` (Colima n'en livre pas par défaut). On garde quand même
`DOCKER_BUILDKIT=1` dans les scripts `prod:build` / `prod:up` pour
profiter de BuildKit dès qu'il est dispo.

Pour vérifier :

```bash
docker buildx version          # → "unknown command" si absent (OK)
docker version --format '{{.Server.Version}}'  # devrait être ≥ 24
```

---

## 3. Premier déploiement

### 3.0 Préparer les bind mounts host

Voir §1 "Volumes persistants" : créer `${ELEVA_DATA_ROOT}/{pg,redis,results}`

- chown au bon UID. Sans ça, `prod:up` échoue (postgres ne peut pas
  écrire dans son data dir).

### 3.1 Préparer `.env.prod`

```bash
cp .env.prod.example .env.prod
# édite .env.prod et remplis chaque __change_me__
```

Génère les secrets random :

```bash
# BETTER_AUTH_SECRET (32+ bytes)
openssl rand -hex 32

# CRON_SECRET (32+ bytes)
openssl rand -hex 32

# ELEVASPEED_INTEGRATION_API_KEY (32+ bytes)
openssl rand -hex 32

# POSTGRES_PASSWORD (16+ bytes lisibles)
openssl rand -base64 24 | tr -d '/+=' | head -c 24
```

> **`.env.prod` est gitignored.** Ne le commit jamais. Si tu déploies sur
> un host distant, transfère-le via `scp -o StrictHostKeyChecking=no` ou
> intègre-le à un secrets manager (Vault, SOPS, age, etc.).

### 3.2 Build des images

```bash
pnpm prod:build
```

Trois images sont buildées en cascade :

1. `eleva-speed-egress-proxy:latest` (~15 MB, alpine + tinyproxy)
2. `eleva-speed-sitespeed-worker:latest` (~7 GB, sitespeed + Chrome) —
   long la première fois (~5-10 min), instantané ensuite grâce au cache.
3. `eleva-speed-app:latest` (~310 MB, Next.js standalone) — 2-5 min.

> Le premier build seed le cache de layers Docker. Les builds suivants
> profitent du cache tant que `pnpm-lock.yaml` et `package.json` ne
> changent pas → ~30 sec si seul le code applicatif bouge.

### 3.3 Démarrer la stack

```bash
pnpm prod:up
```

Compose démarre les services dans l'ordre des `depends_on` :

```
postgres ─┬─→ app
redis    ─┴─→ sitespeed-worker ──→ app
              egress-proxy ─────→ app
```

Vérifier que tout est `healthy` :

```bash
pnpm prod:ps
```

Attendre que `app` passe en `running (healthy)` — typiquement 30-60 sec
après que les autres soient healthy.

### 3.4 Migrations DB

Les migrations **ne sont pas** lancées automatiquement par `prod:up` (path
de moindre privilège : tu dois explicitement les appliquer contre la
vraie volume).

```bash
pnpm prod:migrate
```

Ce script lance un container `migrator` one-shot (profil `migrate` dans
le compose) qui réutilise le stage `builder` du Dockerfile — il a `node`

- tout `node_modules` (incl. `drizzle-kit`) + le code source + les
  migrations sous `drizzle/`. Il rejoint `internal-net`, se connecte à
  `postgres:5432`, run `pnpm exec drizzle-kit migrate`, et exit.

> Le container `app` (stage `runner`) n'embarque PAS `drizzle-kit` (c'est
> une devDependency, le standalone output ne la copie pas). Tenter
> `docker compose exec app drizzle-kit ...` échoue avec "Cannot find
> module" — utilise `pnpm prod:migrate` à la place.
>
> Note : `pnpm exec` (et pas `node node_modules/.bin/drizzle-kit`) parce
> que le bin est un cmd-shim shell-script, pas un fichier JS — l'invoquer
> via `node` direct throw un `SyntaxError`.

### 3.5 Smoke test

```bash
# 1. Health (via Caddy, en HTTPS — accepter le cert self-signé local)
curl -kfsS https://localhost/api/health
# → {"status":"ok"}

# 2. Page d'accueil
curl -kfsS -o /dev/null -w "%{http_code}\n" https://localhost/
# → 200 (ou 308 si redirect i18n vers /fr)

# 3. L'egress est bien filtré (depuis l'app, via le proxy)
docker compose -f docker-compose.prod.yml exec app \
  wget -qO- --timeout=5 https://elevaseo.com/ | head -c 80
# → premières lignes de elevaseo.com (autorisé)

docker compose -f docker-compose.prod.yml exec app \
  wget -qO- --timeout=5 https://api.stripe.com/v1/charges 2>&1 \
  | grep -i "Forbidden\|connection refused" || echo "leak — à investiguer"
# → "Forbidden" attendu (tinyproxy renvoie 403)
```

---

## 3.5b Bootstrap du catalogue Stripe

Le code suppose que **Stripe a déjà des Products + Prices** matchant
`lib/billing/plans.ts` (starter / pro / business, en `month` + `year`,
chacun avec metadata `app=eleva-speed`). Sur un nouveau compte Stripe,
il faut les créer.

`scripts/stripe-bootstrap.ts` est idempotent (dedup via metadata) et
imprime un bloc env à coller. La clé `STRIPE_SECRET_KEY` détermine le
mode : `sk_test_*` → test mode, `sk_live_*` → live.

### Pour le mode test (local)

1. Dans `.env.local`, mettre `STRIPE_SECRET_KEY=sk_test_<nouveau_compte>`.
2. `pnpm stripe:bootstrap`.
3. Copier les 6 lignes `STRIPE_PRICE_*_MONTHLY` / `STRIPE_PRICE_*_YEARLY`
   imprimées dans `.env.local`.

### Pour le mode live (serveur prod)

Plus délicat parce que la clé live ne doit pas quitter le serveur. On
réutilise le container `migrator` qui lit déjà `.env.prod` :

```bash
# Sur le serveur, .env.prod doit avoir STRIPE_SECRET_KEY=sk_live_<key>
pnpm prod:stripe-bootstrap
```

Le script imprime les `STRIPE_PRICE_*` IDs (live). Copier ces lignes
dans `.env.prod` sur le serveur (gitignored — ne JAMAIS le commit), puis :

```bash
docker compose -f docker-compose.prod.yml --env-file .env.prod \
  up -d --no-deps --force-recreate app
```

### Idempotence

`pnpm stripe:bootstrap` / `pnpm prod:stripe-bootstrap` est safe à
re-run — il dédoublonne via `metadata.app` + `metadata.tier`. Re-run
après chaque édition de `lib/billing/plans.ts` qui ajoute un tier (les
prix existants sont immuables côté Stripe — pour changer un montant,
archiver l'ancien Price puis re-run).

---

## 3.6 Cloudflare + Traefik TLS

Le host fait tourner un **Traefik en frontal** (container `traefik`, sur
le réseau `proxy`) qui termine le TLS pour toutes les apps du host
(plane, firecrawl, eleva-speed, etc.). Sa config :

- **Provider Docker** avec `--providers.docker.exposedbydefault=false` →
  un container n'est routé que s'il déclare `traefik.enable=true`.
- **Entrypoints** : `web` (:80, redirect auto vers HTTPS) + `websecure`
  (:443).
- **Default certificate** : un **Cloudflare Origin Certificate** est déjà
  monté en `defaultCertificate` via `/etc/traefik/config/tls.yaml`. Toute
  app routée par Traefik hérite de ce cert pour le TLS — aucune action
  côté eleva-speed.

Notre `app` se connecte à Traefik via 2 mécanismes (cf.
`docker-compose.prod.yml`) :

1. **Réseau partagé `proxy`** (déclaré `external: true`) → app rejoint le
   bridge où Traefik écoute.
2. **Labels Docker** sur le service `app` :
   ```yaml
   - traefik.enable=true
   - traefik.docker.network=proxy
   - traefik.http.routers.eleva-speed.rule=Host(`${ELEVA_PUBLIC_HOST}`)
   - traefik.http.routers.eleva-speed.entrypoints=websecure
   - traefik.http.routers.eleva-speed.tls=true
   - traefik.http.services.eleva-speed.loadbalancer.server.port=3000
   ```

`ELEVA_PUBLIC_HOST` (dans `.env.prod`) doit matcher le hostname DNS que
Cloudflare pointe vers ce host (ex. `speed.elevaseo.com`).

**Cloudflare SSL/TLS mode requis** : **Full (strict)** — le cert présenté
par Traefik est un Origin Certificate signé par la CA Cloudflare, donc
CF le trust nativement. Pas de bricolage.

> Si tu vois une **erreur 526** côté CF, vérifie : le label
> `traefik.enable=true` est bien posé, le container est sur `proxy`, et
> Traefik le voit (`docker logs traefik | grep -i eleva-speed`).

---

## 4. Egress allowlist — élargir le filtre

Tant que la prod-test reste isolée, ces upstream **échouent par design** :

| Upstream                 | Conséquence si bloqué                                     |
| ------------------------ | --------------------------------------------------------- |
| `api.stripe.com`         | Checkout / webhooks / customer portal → 5xx au runtime    |
| `api.resend.com`         | OTP / invitations / emails transac → "failed to send"     |
| `accounts.google.com`    | Login Google (si activé) → erreur OAuth                   |
| `*.googleapis.com`       | PageSpeed Insights, Maps, … → bloqués                     |
| Cible des audits clients | Le worker sitespeed ne peut pas crawler hors elevaseo.com |

Pour autoriser un upstream :

1. Édite `services/egress-proxy/filter`, ajoute une regex étendue par
   ligne. Exemples :

   ```
   ^elevaseo\.com$
   ^.+\.elevaseo\.com$
   ^api\.stripe\.com$
   ^api\.resend\.com$
   ^.+\.googleapis\.com$
   ```

2. Rebuild + recrée le proxy :

   ```bash
   pnpm prod:build egress-proxy
   docker compose -f docker-compose.prod.yml --env-file .env.prod \
     up -d --force-recreate egress-proxy
   ```

3. Vérifie : `docker compose -f docker-compose.prod.yml logs egress-proxy`
   — `Connect` = autorisé, `Filtered` = bloqué.

> **Audit** : chaque ligne ajoutée à `filter` élargit la surface d'attaque
> du test. Garde le diff de ce fichier dans le commit qui l'introduit.

---

## 5. Opérations courantes

### Logs

```bash
pnpm prod:logs                                       # tail tout
pnpm prod:logs app                                   # un seul service
pnpm prod:logs --since=10m egress-proxy              # filtré par durée
```

### Restart d'un service

```bash
docker compose -f docker-compose.prod.yml restart app
```

### Mise à jour du code applicatif

```bash
git pull
pnpm prod:build app                                  # rebuild seulement l'app
docker compose -f docker-compose.prod.yml --env-file .env.prod \
  up -d --no-deps --force-recreate app               # recrée app sans toucher postgres/redis
```

### Arrêt / clean

```bash
pnpm prod:down                                       # stop + remove containers, volumes conservés
pnpm prod:down --volumes                             # ⚠ supprime aussi les volumes (data perdue)
```

### Reset complet de la DB

```bash
pnpm prod:down
docker volume rm \
  eleva-speed-prod_eleva-speed-prod-pg-data \
  eleva-speed-prod_eleva-speed-prod-redis-data \
  eleva-speed-prod_eleva-speed-prod-results
pnpm prod:up
# puis 3.4 (migrations)
```

---

## 6. Sauvegarde / restauration Postgres

### Backup à chaud

```bash
docker compose -f docker-compose.prod.yml exec postgres \
  pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" \
  > "backups/eleva-$(date -u +%Y%m%d-%H%M%S).dump"
```

(Le `$POSTGRES_USER` / `$POSTGRES_DB` viennent de `.env.prod` ; remplace
ou source le fichier avant la commande.)

### Restore

```bash
docker compose -f docker-compose.prod.yml exec -T postgres \
  pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \
  < backups/eleva-YYYYMMDD-HHMMSS.dump
```

> Pour des snapshots déclenchés régulièrement, mets `pg_dump` dans un
> cron host (pas dans la stack — la stack n'a pas d'egress vers du
> stockage objet).

---

## 7. Rotation des secrets

1. Édite `.env.prod` avec la nouvelle valeur.
2. `pnpm prod:down && pnpm prod:up` (le container app relit `.env.prod`
   au démarrage).
3. Cas particuliers :
   - **`BETTER_AUTH_SECRET`** — invalide toutes les sessions actives.
     Les utilisateurs devront se reconnecter.
   - **`POSTGRES_PASSWORD`** — pas suffisant d'éditer `.env.prod`,
     Postgres ne change pas le mot de passe du user existant. Soit
     `ALTER USER … WITH PASSWORD '…'` via `psql`, soit reset complet
     du volume (§5 reset DB).
   - **`STRIPE_SECRET_KEY`** — à roll côté dashboard Stripe d'abord, puis
     ici. Vérifie que les webhooks reçus jusqu'à la rotation sont bien
     traités avant de couper l'ancienne clé.

---

## 8. Troubleshooting

### Build : `the --mount option requires BuildKit`

**Cause** : Compose tombe sur le legacy builder parce que le plugin
`docker buildx` n'est pas installé (cas par défaut sur Colima).

**Fix appliqué** : le `Dockerfile` n'utilise plus `--mount=type=cache`.
Si tu réintroduis cette directive, installe `brew install
docker-buildx` ou tu re-rencontreras le problème.

### Build : `Invalid environment variables: DATABASE_URL`

**Cause** : `lib/env.ts` lance la validation Zod à l'import time, ce qui
se déclenche pendant `next build` quand Next collecte les modules de
routes. `.env.prod` n'est **pas** lu au build (les `env_file` Compose
sont runtime-only).

**Fix appliqué** : le stage `builder` du `Dockerfile` pose 3
placeholders syntaxiquement valides :

```dockerfile
ENV DATABASE_URL=postgres://build:build@build-time-placeholder:5432/build
ENV BETTER_AUTH_SECRET=build-time-placeholder-not-used-at-runtime-xxxxx
ENV ELEVASPEED_INTEGRATION_API_KEY=build-time-placeholder-not-used-at-runtime-xxxxx
```

Ces valeurs ne sont **jamais** au runtime (stage `runner` repart d'un
`FROM` neuf) ni inlinées dans le bundle (lectures `process.env`
server-side).

Si tu ajoutes une variable d'env **required en production** dans
`lib/env.ts`, pense à ajouter le placeholder correspondant ici aussi.

### `app` reste `unhealthy`

```bash
docker compose -f docker-compose.prod.yml logs --tail=200 app
```

Causes fréquentes :

- **DATABASE_URL pointe vers `localhost`** au lieu de `postgres:5432`.
  Le `docker-compose.prod.yml` override pourtant cette var ; vérifie
  qu'aucun script n'export `DATABASE_URL` avant `pnpm prod:up`.
- **Migrations pas encore lancées** → l'app boot mais throw à la
  première query. Voir §3.4.
- **`BETTER_AUTH_SECRET` < 32 chars** ou absent → l'app crash au boot.

### `egress-proxy` log "Filtered" sur un upstream légitime

Ajoute l'host à `services/egress-proxy/filter` et recrée le proxy (§4).

### Port 3000 déjà pris sur l'host

Édite la ligne `ports:` dans `docker-compose.prod.yml` :

```yaml
ports:
  - "0.0.0.0:8080:3000" # 8080 host → 3000 container
```

### `sitespeed-worker` healthcheck en échec ou Chrome crash

Souvent un manque de RAM. Augmente la limite mémoire dans le compose
(`deploy.resources.limits.memory`) ou réduit `MAX_CONCURRENT=1` dans
`.env.prod`.

---

## 9. Rollback

### Vers un build précédent

L'image `eleva-speed-app:latest` est écrasée à chaque build. Pour pouvoir
rollback :

```bash
# avant un déploiement
docker tag eleva-speed-app:latest eleva-speed-app:$(git rev-parse --short HEAD)

# en cas de pépin
docker tag eleva-speed-app:<sha-précédent> eleva-speed-app:latest
docker compose -f docker-compose.prod.yml --env-file .env.prod \
  up -d --no-deps --force-recreate app
```

### Vers un dump DB précédent

Voir §6 (restore). Le DB et les migrations ne sont **pas** auto-revert :
si une migration cassante est partie, il faut soit écrire une migration
inverse, soit restore + skip la migration cassante dans la table
`drizzle_migrations`.

---

## 10. Limites connues / dette

- Le worker sitespeed est mono-région (FR seulement). Plan multi-région
  parqué dans `docs/planning/pre-prod-checklist.md`.
- ~~Pas de reverse proxy (TLS / certs) devant `app:3000`~~ → Caddy
  intégré depuis 2026-05-26 (cf. §3.6).
- Pas de monitoring centralisé. Les logs vivent dans le json-driver
  Docker (rotation 10 MB × 5). Pour de la prod sérieuse, brancher
  Loki / Vector / equivalent.
- Pas de protection brute-force au niveau plateforme. Better Auth fait
  son rate-limit applicatif ; à compléter avec Crowdsec / Fail2ban
  côté host.
- Les SDK externes (Stripe, Resend, …) initialisent leur client au boot
  mais throw quand ils appellent réellement leur API. C'est intentionnel
  (la stack est isolée) mais ça surface comme des 5xx runtime au lieu
  d'erreurs explicites. Voir §4 pour les débloquer un par un.
