# Serveur de production — configuration

> Relevé du 2026-07-02, en lecture seule via SSH (utilisateur d'administration non-root,
> sudo interactif requis — les identifiants ne sont PAS dans ce document).
> Hôte : `seo-insigght-99` · IP `163.172.71.169` · Ubuntu 26.04 LTS.

## Matériel & OS

| | |
|---|---|
| CPU / RAM | 12 cœurs · 30 Go |
| Disque | 20 Go (RAID md, `/dev/md1p1`) — **45 % utilisés** au relevé |
| OS | Ubuntu 26.04 LTS, kernel 7.0.0-22-generic |
| Uptime au relevé | 15 jours |

## Architecture applicative — systemd natif (PAS de Dokploy/containers pour l'app)

L'app tourne en services systemd sous l'utilisateur de service **`seoinsight`**,
code dans **`/data/seo-insight`** (symlink `/opt/seo-insight`), checkout git de
`origin/main` (`git@github.com:bastienallain/seo-insight.git`).

| Unité | Rôle | Détails |
|---|---|---|
| `seo-insight-web.service` | Next.js 16 | `bun run start -- -H 127.0.0.1 -p 3000`, `EnvironmentFile=/data/seo-insight/.env.production` (mode 600), hardening `NoNewPrivileges` + `ProtectSystem=full` + `PrivateTmp`, `Restart=on-failure` |
| `seo-insight-analytics.service` | Service analytics (Bun + Hono + DuckDB) | `bun --conditions=react-server services/analytics/src/index.ts`, PORT **8787** — ⚠️ bind toutes interfaces (voir Findings) |
| `postgresql@18-main.service` | PostgreSQL 18 natif | écoute `127.0.0.1:5432` uniquement |
| `nginx.service` | Reverse proxy + TLS | vhost unique `dash.seoinsight.app` (cible ; relevé 2026-07-02 servait encore `dashboard.` — voir F2) |
| `fail2ban.service` | Anti brute-force SSH | actif |
| `named.service` (BIND) | DNS local | présent (résolveur) |
| `docker.service` | Docker 29.5.3 | actif ; conteneurs invisibles pour l'utilisateur d'admin (pas dans le groupe docker) — l'app N'EN dépend PAS |

## nginx / TLS

- Vhost : `deploy/nginx/dash.seoinsight.app.conf` (déployé dans
  `/etc/nginx/sites-enabled/`). Proxy vers `127.0.0.1:3000`, WebSocket ok,
  `client_max_body_size 60M`, `proxy_read_timeout 300s`, gzip.
- `/_next/static/` servi DIRECTEMENT par nginx (`alias /opt/seo-insight/.next/static/`,
  cache immutable 1 an) — le node ne sert pas les statiques.
- TLS : **Certbot/Let's Encrypt**, renouvellement automatique via
  `snap.certbot.renew.timer` (vérifié actif).
- Ports publics : 80/443 seulement (+8787 bindé mais filtré en amont, voir Findings).

## Déploiement — `deploy/deploy.sh` (manuel, sudo-gated)

Le script fait, dans l'ordre :
1. `git fetch` + `git reset --hard origin/main` (branche surchargeable via `DEPLOY_BRANCH`)
2. **`wasm-pack build crates/import-core` AVANT `bun install`** — le package
   `import-core` est une dépendance `file:` dont la sortie de build est gitignorée ;
   l'ordre est documenté dans le script ET dans `SETUP.md`. ✅ Le souci
   « `next build` ne construit pas le wasm » est donc couvert par le pipeline.
3. `bun install` (racine + `services/analytics`)
4. `cargo build --release -p gsc-worker` (binaire dans `target/release/gsc-worker`)
5. `bun run db:migrate`
6. `bun run build` (Next)
7. `sudo systemctl restart seo-insight-web seo-insight-analytics`

Toolchain serveur : bun 1.3.14 (`/usr/local/bin/bun` + `~seoinsight/.bun`),
Rust/cargo/wasm-pack disponibles pour l'utilisateur `seoinsight` (le script source
`~/.cargo/env`). Logs : `journalctl -u seo-insight-web -u seo-insight-analytics -u seo-insight-gsc-worker -f`.

`deploy/bootstrap.sh` : provisioning initial. `deploy/firecrawl/` : docker-compose de
l'ex-Firecrawl — **obsolète** (Firecrawl supprimé du code le 2026-07-02 ; l'instance
`crawl.elevaseo.app` était de toute façon hébergée AILLEURS, derrière Cloudflare).

## Unités systemd — `deploy/sync-systemd.sh` (root, explicite)

**Frontière de privilèges.** `deploy.sh` tourne en `seoinsight`, dont les droits
sudo se limitent au redémarrage de deux services nommés. Écrire dans
`/etc/systemd/system` et recharger le démon est root-équivalent : on ne l'accorde
pas à l'utilisateur de service. La copie des unités est donc une opération root
séparée et explicite.

**Depuis le compte d'ADMINISTRATION, pas depuis `seoinsight`.** Le compte de
service a bien *des* droits sudo (deux `systemctl restart` en NOPASSWD), ce qui
laisse croire qu'il peut sudo le reste — il ne peut pas, il n'est pas dans
sudoers au-delà. Le `-t` est nécessaire pour saisir le mot de passe sudo.

```bash
ssh -t <admin>@163.172.71.169 'sudo /data/seo-insight/deploy/sync-systemd.sh'
# copie seulement, sans activer les minuteurs :
ssh -t <admin>@163.172.71.169 'sudo /data/seo-insight/deploy/sync-systemd.sh --no-enable'
```

Le script détecte ce cas et renvoie vers le bon compte plutôt qu'un « permission
denied » nu.

**Ordre : `deploy.sh` d'abord, ce script ensuite.** Le worker GSC interroge l'app
web en local (`APP_URL=http://127.0.0.1:3000`) : elle doit tourner, et sur la
bonne version.

Le script est idempotent — il ne recopie que ce qui diffère, ne fait
`daemon-reload` que si quelque chose a bougé, et désactive au passage l'ancien
`seo-insight-gsc-worker.service` s'il est encore activé en démon (voir F1 : la
version pré-oneshot tournait en boucle et coexisterait avec le minuteur).

⚠️ **Activer `seo-insight-gsc-worker.timer` lance une sync qui n'a jamais tourné
en prod.** Son déclencheur est `OnBootSec=10min` : sur une machine allumée depuis
longtemps l'échéance est déjà dépassée, donc le premier run part **dès
l'activation**, pas au prochain redémarrage. Pré-requis : ≥ 1 site avec
`gscProperty` mappé et l'app web debout.

`deploy.sh` détecte l'écart entre `deploy/systemd/*` et les unités installées, et
l'affiche en **dernier** message du run — le déploiement du code n'est pas annulé
pour autant, un écart d'unités ne signale qu'une planification en retard.

## Planification

- `deploy/systemd/seo-insight-gsc-worker.{service,timer}` : sync GSC quotidienne
  (`OnBootSec=10min`, `OnUnitActiveSec=24h`, `Persistent=true`, jitter 5 min).
- ⚠️ **Le timer n'est PAS installé** — voir Findings F1.

## Connecteur Mangools (BYOK par org)

Rien de spécial à déployer côté infra — juste deux points à connaître :

- **Chiffrement du token** : chaque org fournit sa propre clé API Mangools
  (BYOK). Le token est stocké **chiffré AES-GCM**, la clé de chiffrement étant
  dérivée de `BETTER_AUTH_SECRET`. Cette variable doit donc être présente dans
  l'environnement du **service analytics** au runtime (enrichissement / SERP /
  related tournent côté analytics). C'est **déjà le cas** via
  `.env.production` — aucune nouvelle variable à ajouter. Corollaire : ne pas
  faire tourner `BETTER_AUTH_SECRET`/rotation sans plan de re-chiffrement, sinon
  les tokens stockés deviennent indéchiffrables (les orgs devront re-saisir leur
  clé dans Settings → Intégrations).
- **Migrations `0067` → `0069` requises AVANT le déploiement du code** (table
  `org_connectors` + colonnes associées). Le code dégrade gracieusement une DB
  non migrée (`getMangoolsStatus` retombe sur « non connecté » si la table est
  absente), mais le flux nominal en a besoin. `deploy/deploy.sh` exécute
  `bun run db:migrate` **avant** `bun run build` (étapes 5 puis 6 ci-dessus),
  donc l'ordre est **déjà correct** — aucune manœuvre manuelle.

## Analyse sémantique SERP (S7 — rendu headless) — dette « doc ops » du merge

- **Rendu headless Chrome/Chromium** — le fallback de rendu JS (`src/lib/
  semantic/render.ts`, tenté quand le fetch mobile-first échoue ou produit une
  coquille thin-HTML, dans la limite de `RENDER_MAX_POSITION`) pilote un Chrome
  **local** en CDP brut (spawn `--headless=new`, zéro dépendance npm). Il exige
  un binaire Chrome/Chromium sur la machine ; sinon `resolveRenderProvider`
  renvoie `null` et le pipeline **dégrade proprement** (comportement
  pré-S7 : coquille/échec flaggé `js_rendered_suspected`/absent, jamais
  d'erreur bloquante). Env `services/analytics` :
  - `CHROME_PATH` — chemin explicite du binaire (prioritaire ; si le chemin
    donné n'existe pas, PAS de repli auto-détection — dégradation directe).
    Sans cette variable, auto-détection par plateforme (`/usr/bin/google-chrome`,
    `/usr/bin/chromium`, `/usr/bin/chromium-browser` sous Linux).
  - `RENDER_MAX_POSITION` — position SERP max éligible au rendu (entier
    parsé + borné à `[1, 10]` côté service ; défaut **5** hors limite/absent).
  - **À faire sur le serveur** : installer Chromium (`apt install chromium` ou
    équivalent Ubuntu 26.04) et positionner `CHROME_PATH` dans
    `.env.production`, OU accepter la dégradation (aucun rendu JS, coquilles
    thin-HTML jamais enrichies) tant qu'aucun provider API n'est branché
    (alternative envisagée plus tard — `RenderProvider` est interchangeable,
    design D3).
  - Un sémaphore GLOBAL process (`MAX_CONCURRENT_RENDERS = 2`,
    `services/analytics/src/render-limiter.ts`) borne le nombre de Chrome
    simultanés tous jobs confondus — pas de réglage serveur requis, mais à
    garder en tête en cas de pics de charge/RAM (spawn Chrome ≈ coûteux).
- **Lifecycle R2 `serp-semantics-staging/`** (dette notée à la revue S3,
  toujours due) — le staging HTML brut/rendu (`serp-semantics-staging/
  ${siteId}/${jobId}/pos-${n}[-rendered].html.gz`) est supprimé app-level en
  fin de job (best-effort, `deleteObject` en `finally`), mais un job qui
  crashe avant ce nettoyage laisse des objets orphelins. **À créer** : une
  règle lifecycle R2 dédiée au préfixe `serp-semantics-staging/`, expiration
  **1 jour** (garde-fou minimal — R2 n'offre pas de granularité plus fine),
  indépendante du delete app-level qui reste la voie normale.
- **Runbook migration 0066** (`drizzle/0066_optimal_lionheart.sql` —
  `analytics_jobs.payload` + `keywords.semantic` jsonb, lz4 TOAST guardé
  `feature_not_supported`) — même doctrine que 0050/0055 : à jouer **hors
  fenêtre de charge** (jobs `serp-semantics`/GSC en cours), puis :
  ```sql
  ANALYZE keywords;
  ANALYZE analytics_jobs;
  ```
  La colonne `semantic` n'est PAS rétro-compressée par la migration (DDL de
  compression seule, futures écritures uniquement) — pas d'action de plus à
  prévoir au-delà de l'`ANALYZE`.

## DNS

| Domaine | Pointe vers | Servi ? |
|---|---|---|
| `dash.seoinsight.app` | 163.172.71.169 | ⬅️ **cible canonique** — vhost + cert à déployer (voir F2) |
| `dashboard.seoinsight.app` | 163.172.71.169 | vhost + cert actuels ; à basculer en 301 → `dash.` |
| `crawl.elevaseo.app` | Cloudflare (autre machine) | ex-Firecrawl, HTTP 526, obsolète |

## Findings (relevé 2026-07-02)

- **F1 — RÉSOLU le 2026-07-02** (timer installé + unité oneshot à jour + re-consent
  Google de l'owner après révocation du refresh token — changement de mot de passe
  Google). Premier run réel : `ok=2 failed=1 total_rows=988`. Reste un site
  chroniquement en 404 (`11eebee5-…`, échouait déjà le 17 juin) : son org n'a
  probablement pas d'owner avec compte Google lié — requête SQL de diagnostic
  ci-dessous, et le logging broker (`58fc5ca`, sur dev) donnera la raison exacte
  au prochain deploy. Constat d'origine : Le `.timer` n'est pas installé
  (« Unit could not be found ») ET le `.service` installé est une VIEILLE version
  daemon (`Type=simple`, « pulls on a loop », Restart=on-failure) antérieure au
  passage oneshot+timer du repo — et elle est inactive. Résultat : aucune donnée
  `gsc_*_snapshots` en prod, l'intégration Search Console est inerte côté stats.
  Fix (root) :
  ```bash
  sudo cp /data/seo-insight/deploy/systemd/seo-insight-gsc-worker.service \
          /data/seo-insight/deploy/systemd/seo-insight-gsc-worker.timer \
          /etc/systemd/system/
  sudo systemctl disable seo-insight-gsc-worker.service 2>/dev/null || true
  sudo systemctl daemon-reload
  sudo systemctl enable --now seo-insight-gsc-worker.timer
  sudo systemctl start seo-insight-gsc-worker.service   # premier run immédiat
  journalctl -u seo-insight-gsc-worker -f               # suivre la sync
  ```
  Pré-requis du premier run : ≥ 1 site prod avec `gscProperty` mappé, app web up
  (le worker passe par son token-broker `POST /api/worker/gsc-token`).
  Diagnostic du site restant (psql serveur, base de `.env.production`) :
  ```sql
  select s.id, s.name, s.gsc_property, m.user_id as owner_user,
         (a.id is not null) as google_linked
  from sites s
  left join member m on m.organization_id = s.org_id and m.role = 'owner'
  left join account a on a.user_id = m.user_id and a.provider_id = 'google'
  where s.gsc_property is not null;
  ```
  ⚠️ `deploy.sh` ne copie PAS les unités systemd, et **c'est délibéré** : donner à
  `seoinsight` le droit d'écrire dans `/etc/systemd/system` + `daemon-reload`
  reviendrait à lui donner root (toute unité déposée là s'exécute avec les pleins
  droits). Depuis le 2026-08-07 la copie a son script root dédié — voir
  « Unités systemd » ci-dessous — et `deploy.sh` se contente de **détecter**
  l'écart et de l'afficher en fin de run.
- **F2 — Domaine canonique = `dash.seoinsight.app`** (décidé 2026-07-09). Côté
  code : `resolveTrustedOrigins` prod truste désormais `https://dash.seoinsight.app`
  et le vhost repo est renommé `deploy/nginx/dash.seoinsight.app.conf` (branche
  `chore/dash-canonical-domain`). Reste à faire côté **prod** (aucun client encore,
  donc pas de fenêtre de casse) : (1) déployer le vhost `dash.` + `certbot -d
  dash.seoinsight.app` ; (2) Google OAuth redirect URI → `dash.` ; (3) endpoint
  webhook Stripe → `dash.` ; (4) env prod `BETTER_AUTH_URL` + `EMAIL_FROM` → `dash.` ;
  (5) basculer l'ancien vhost `dashboard.` en 301 → `dash.`. Le code doit être
  déployé EN MÊME TEMPS que (1) : sans le vhost `dash.`, l'auth prod renverrait 403.
- **F3 — Analytics 8787 bindé sur toutes les interfaces** (`*:8787`). Filtré en
  amont (sonde externe : fermé) — probablement le firewall du provider — mais un
  changement de security group l'exposerait. Recommandé : bind `127.0.0.1`
  (`Environment=HOST=127.0.0.1` ou équivalent dans l'unité) puisque seul le
  process web local le consomme.
- **F4 — Disque 20 Go à 45 %** : surveiller — les partitions GSC (migration 0050)
  et la croissance des snapshots consommeront ; `.next` + `target/` + node_modules
  pèsent déjà. Prévoir extension ou nettoyage `cargo clean` périodique.
- **F5 — Prod en retard** : `main` serveur = `0c68b8b` (19 juin). Tout ce qui a été
  mergé sur `dev` depuis (God Mode, checklist activation, Phase 0, crawler Rust…)
  n'est PAS en prod. Normal (promotion manuelle sur demande) — mais le prochain
  deploy embarquera les migrations 0043→0050 d'un coup : suivre le runbook Phase 0
  (migration 0050 hors fenêtre GSC — sans objet tant que F1 n'est pas réglé — puis
  `ANALYZE gsc_query_snapshots; ANALYZE gsc_page_snapshots;`).

## Sécurité — posture constatée

- Utilisateur d'admin : non-root, sudo **interactif uniquement** (pas de NOPASSWD),
  pas dans le groupe docker. Auth SSH par clé.
- Services applicatifs : user dédié `seoinsight`, hardening systemd, secrets dans
  `.env.production` (600, propriété seoinsight) — jamais lus ni copiés dans cette doc.
- fail2ban actif ; Postgres et Next bindés localhost ; TLS auto-renouvelé.
