Guide pour faire tourner, déployer, arrêter et dépanner le portfolio vocal publié sur https://mathiscapart.xyz. Toutes les commandes se lancent depuis la racine du dépôt, sous PowerShell sauf mention contraire.
┌──────── serveur front (Debian 13, toujours allumé) ────────┐
Visiteur ──HTTPS/WSS──> Cloudflare ──tunnel──> cloudflared ─┐
v
Traefik (conteneur, port 80 interne)
│ │
/ (front) v v /api (préfixe retiré), réseau local
nginx (conteneur) ─────────┼──── PC du GPU (Windows, allumé à la demande) ───
frontend/out/ API FastAPI (NATIF, 192.168.1.75:8000)
│ │ │ │
│ │ │ └─> TTS Pocket (NATIF, 127.0.0.1:8001)
│ │ └─> Ollama (NATIF, :11434) : qwen3:8b + embeddings
│ └─> Qdrant (conteneur, 127.0.0.1:6333)
└─> STT Kyutai (dans le process de l'API, GPU ROCm)
| Brique | Où | Démarrage automatique au reboot |
|---|---|---|
| nginx, Traefik, cloudflared | serveur front, conteneurs portfolio-* (projet portfolio) |
oui (unless-stopped) |
| Qdrant | PC du GPU, conteneur qdrant (projet compose lobster) |
oui (unless-stopped) |
| Ollama | application Windows | oui (dossier Démarrage de la session) |
| API (STT + RAG + LLM) | process Python natif, .venv-rocm |
non |
| TTS (voix clonée) | process Python natif, .venv-tts |
non |
Sur le PC, les conteneurs ne redémarrent que si Docker Desktop est lancé, et Ollama qu'à l'ouverture de la session Windows.
PC éteint : le serveur front continue de servir le site. /api/health répond 502
(Traefik abandonne la connexion au PC après 2 s), le front désactive le bouton
Parler et annonce l'assistant hors ligne avec un lien LinkedIn. Qdrant reste sur
le PC : une recherche a de toute façon besoin d'Ollama pour les embeddings.
- Le GPU. La carte est une AMD RX 7700 XT sous Windows. Docker Desktop fait
tourner des conteneurs Linux dans une VM WSL2 ; ROCm dans WSL2 n'est proposé
que pour certaines cartes (support de la 7700 XT non vérifié). Ce qui marche
ici, ce sont les wheels ROCm Windows (
cp312-win_amd64,repo.radeon.com), qui ne s'installent pas dans un conteneur Linux. Le STT est donc natif. - L'API suit le STT : elle le charge dans son propre process.
- Ollama accède directement au GPU sous Windows.
- Le TTS tourne sur CPU : rien ne l'empêche d'être conteneurisé. Il est natif parce qu'il est arrivé après, pas par contrainte.
Coût de ce choix : démarrage manuel, pas d'isolation, venvs fragiles. Pistes pour conteneuriser davantage :
- Sortir le STT en service natif seul sur le GPU, passer API et TTS en Docker.
- Vérifier si ROCm fonctionne dans WSL2 avec cette carte : si oui, le STT peut aussi passer en Docker.
- Serveur sous Linux : ROCm y fonctionne en Docker, tout devient conteneurisable.
| venv | Python | Contenu | Règle |
|---|---|---|---|
.venv-rocm |
3.12 | torch 2.9.1+rocmsdk, moshi (STT), FastAPI, clients Qdrant/Ollama | ne jamais y installer un paquet PyPI qui tire torch : il écraserait le build ROCm |
.venv-tts |
3.12 | pocket-tts, torch CPU, FastAPI | Pocket TTS y tourne 40x plus vite que sous torch ROCm |
- Docker Desktop lancé.
- Ollama installé, avec les modèles :
ollama pull qwen3:8b ollama pull qwen3-embedding:0.6b
- Fichier
.envà la racine, copié de.env.exampleet complété (TRAEFIK_DOMAIN,CLOUDFLARE_TUNNEL_TOKEN). Il n'est pas versionné. - Les deux venvs :
.venv-rocmdepuisbackend/stt/requirements.rocm.txtet les requirements debackend/ragetbackend/api;.venv-ttsavecpocket-tts,fastapi,uvicorn. - Voix clonée (facultatif, sinon voix
estelle) :- accepter les conditions de https://huggingface.co/kyutai/pocket-tts puis
.venv-tts/Scripts/hf auth login; - déposer l'enregistrement dans
backend/tts/voix/mathis.wav(10 à 30 s, pièce calme ; ce dossier n'est jamais versionné) ; - exporter l'état de la voix :
.venv-tts\Scripts\python.exe -m pocket_tts export-voice backend/tts/voix/mathis.wav backend/tts/voix/mathis.safetensors --language french_24l
- accepter les conditions de https://huggingface.co/kyutai/pocket-tts puis
- Front :
npm cidansfrontend/.
Dans l'ordre :
# 1. Qdrant (lié à 127.0.0.1)
docker compose up -d qdrant
# 2. API + TTS natifs (tue d'abord ce qui écoute sur 8000/8001)
# API_HOST : l'API écoute sur l'IP locale pour que le serveur front la joigne
$env:API_HOST = "192.168.1.75"
powershell -ExecutionPolicy Bypass -File deploy\start-natif.ps1
# 3. Exposition, SUR LE SERVEUR FRONT (bash) : nginx, Traefik, cloudflared
# --env-file obligatoire : sinon compose cherche deploy/.env
docker compose --env-file .env -f deploy/docker-compose.expose.yml up -dstart-natif.ps1 fixe ce que l'API attend :
| Variable | Valeur | Rôle |
|---|---|---|
API_HOST |
127.0.0.1 (production : 192.168.1.75) |
interface d'écoute de l'API |
CORS_ORIGINS |
https://mathiscapart.xyz |
seule origine acceptée sur /voice |
TTS_VOIX |
backend\tts\voix\mathis.safetensors si présent |
voix clonée, sinon estelle |
TORCHDYNAMO_DISABLE |
1 |
Triton absent des wheels ROCm Windows |
Chaque variable peut être surchargée en la définissant avant l'appel du script.
L'API met environ 30 s à démarrer (chargement du STT). Elle est prête quand
deploy\logs\api.err.log contient Application startup complete.
# Arrêter l'API et le TTS natifs (le site statique reste en ligne, le vocal tombe)
powershell -ExecutionPolicy Bypass -File deploy\stop-natif.ps1
# Voir ce qui serait arrêté, sans rien arrêter
powershell -ExecutionPolicy Bypass -File deploy\stop-natif.ps1 -WhatIf
# Couper le site public (le tunnel tombe, le site renvoie une erreur Cloudflare)
docker compose --env-file .env -f deploy/docker-compose.expose.yml stop
# Arrêter Qdrant (les données restent dans le volume lobster_qdrant_data)
docker compose stop qdrant
# Libérer la VRAM prise par le LLM sans quitter Ollama
ollama stop qwen3:8bPour tout redémarrer, reprendre la section 3. stop conserve les conteneurs ;
down les supprime (les données Qdrant restent, elles sont dans un volume).
| Ce qui change | Action | Coupure |
|---|---|---|
frontend/** |
rebuild du front | aucune |
backend/api/**, backend/stt/** |
redémarrer l'API | ~30-40 s sur le vocal |
backend/tts/** ou la voix |
redémarrer le TTS | quelques secondes sur la voix |
backend/rag/corpus/*.md |
réindexer et rebuilder le front | aucune |
deploy/traefik/config/*.yml |
redémarrer Traefik | quelques secondes |
deploy/docker-compose.expose.yml |
up -d de la stack d'exposition |
quelques secondes |
docker-compose.yml (Qdrant) |
docker compose up -d qdrant |
quelques secondes |
nginx (sur le serveur front) sert directement frontend/out/ (montage) : le build se
fait sur le PC, la copie sur le serveur front est la mise en ligne, sans redémarrage.
cd frontend
$env:NEXT_PUBLIC_API_URL = "https://mathiscapart.xyz/api"
npm run build
scp -r out <utilisateur>@<serveur front>:~/PortFolio-AI/frontend/scp écrase les fichiers mais ne supprime pas ceux qui ont disparu du build.
Sans NEXT_PUBLIC_API_URL, le build échoue exprès (garde-fou de VoiceChat.tsx).
powershell -ExecutionPolicy Bypass -File deploy\start-natif.ps1Le script redémarre les deux. Pour ne redémarrer que le TTS :
Get-NetTCPConnection -LocalPort 8001 -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
$env:PYTHONIOENCODING = "utf-8"
$env:TTS_VOIX = "$PWD\backend\tts\voix\mathis.safetensors"
Start-Process -WindowStyle Hidden -WorkingDirectory $PWD -FilePath ".venv-tts\Scripts\python.exe" `
-ArgumentList "-m uvicorn backend.tts.server:app --host 127.0.0.1 --port 8001" `
-RedirectStandardOutput deploy\logs\tts.out.log -RedirectStandardError deploy\logs\tts.err.log- Modifier
backend/rag/corpus/*.md. Chaque fait doit être vrai et sourcé. Isoler un rôle ou un point précis dans une sous-section courte : un nom propre noyé dans un long paragraphe remonte mal à la recherche. - Réindexer (remplace les passages de chaque fichier, sans doublon) :
L'ingestion refuse tout fichier contenant encore
$env:PYTHONIOENCODING = "utf-8" .venv-rocm\Scripts\python.exe -c "from backend.rag.main import EmbeddingModel, QdrantVectorStore, Settings, ingest_directory; s=Settings(); q=QdrantVectorStore(host=s.qdrant_host, port=s.qdrant_port); e=EmbeddingModel(model_name=s.embedding_model, host=s.ollama_host, port=s.ollama_port); ingest_directory('backend/rag/corpus', q, e, s.qdrant_collection); print(q.client.count(s.qdrant_collection).count, 'passages')"
À REMPLIR. - Rebuilder le front : la page parcours est générée à partir du même corpus.
Limite connue : un fichier supprimé du corpus n'est pas purgé de l'index. Pour retirer un fichier, supprimer ses points à la main ou recréer la collection.
Le watch du provider file ne fonctionne pas sur le montage Windows : toute
modification de deploy/traefik/config/portfolio.yml exige un redémarrage.
docker restart portfolio-traefik-1- Branche → PR → relecture humaine → fusion. Jamais de commit direct sur
main. - La CI (
.github/workflows/ci.yml) tourne sur chaque PR et surmain: tests backend et front, typecheck, build, build Docker, gitleaks, pip-audit, npm audit. - Fusionner ne déploie rien : il n'y a pas de déploiement automatique. La prod tourne depuis le dossier local ; appliquer ensuite la section 5.
# Site et API publics
curl.exe -s -o NUL -w "site %{http_code}`n" https://mathiscapart.xyz/
curl.exe -s https://mathiscapart.xyz/api/health # {"status":"ok"}
# Services natifs : 8000 sur 192.168.1.75, 8001 et 6333 sur 127.0.0.1
netstat -ano | findstr LISTENING | findstr ":8000 :8001 :6333 :11434"
# Conteneurs
docker ps --format "{{.Names}} {{.Status}}"
# Modèles chargés et VRAM
ollama psContrôles de sécurité attendus :
| Contrôle | Résultat attendu |
|---|---|
https://mathiscapart.xyz/api/docs |
404 |
en-tête content-security-policy sur / |
présent |
WebSocket /voice depuis une autre origine |
refusé (403) |
| 6333 depuis une autre machine du réseau | connexion refusée |
| 8000 depuis une machine du réseau autre que le serveur front | connexion refusée (pare-feu) |
| site avec le PC éteint | page servie, message « hors ligne » |
| Service | Emplacement |
|---|---|
| API | deploy\logs\api.err.log (uvicorn écrit sur stderr) |
| TTS | deploy\logs\tts.err.log |
| Traefik | docker logs portfolio-traefik-1 |
| cloudflared | docker logs portfolio-cloudflared-1 |
| Qdrant | docker logs qdrant |
Les journaux natifs sont écrasés à chaque lancement de start-natif.ps1.
| Symptôme | Cause probable | Correction |
|---|---|---|
| Erreur Cloudflare 502/1033 sur tout le site | conteneurs arrêtés ou Docker Desktop fermé | section 3, étape 3 |
| Le site s'affiche mais « L'assistant vocal ne répond pas » | API arrêtée ou en cours de démarrage | start-natif.ps1, attendre startup complete |
| « Une session vocale est déjà en cours. » | une seule conversation à la fois (un seul GPU) | attendre la fin de l'autre session ; elle est coupée après 10 s de silence réseau |
| Premier son très lent (~10 s) après une période calme | Ollama a déchargé le LLM (5 min d'inactivité) | normal ; ollama ps montre le modèle rechargé |
| Réponse vocale très lente, LLM à ~10 t/s | VRAM saturée : LLM chargé avec un contexte trop grand | vérifier OPTIONS_LLM = {"num_ctx": 8192} dans l'API ; ollama ps doit montrer ~6,3 Go |
| Voix muette mais texte affiché | service TTS arrêté | vérifier que le port 8001 écoute ; relancer le TTS |
| Voix d'Estelle au lieu de la voix clonée | mathis.safetensors absent au lancement |
exporter la voix (section 2), relancer le TTS |
| WebSocket refusé (403) depuis le vrai site | CORS_ORIGINS ne contient pas le domaine |
lancer via start-natif.ps1 ou corriger la variable |
| Modification Traefik sans effet | watch inopérant sous Windows |
docker restart portfolio-traefik-1 |
| Un nouveau fichier statique répond 404 juste après le build | 404 gardé en cache par Cloudflare | attendre l'expiration (~quelques minutes) |
docker compose -f deploy/... : TRAEFIK_DOMAIN manquant |
.env cherché dans deploy/ |
ajouter --env-file .env |
| L'assistant invente un souhait, un poste recherché | information absente du corpus | l'ajouter au corpus ; le prompt interdit déjà de l'inventer |
- TTS (8001) et Qdrant (6333) écoutent sur
127.0.0.1. L'API (8000) écoute sur192.168.1.75pour que Traefik la joigne depuis le serveur front : le pare-feu Windows doit n'y laisser entrer que le serveur front, sinon une machine du réseau local contournerait le rate-limit. Ne jamais passer Qdrant à0.0.0.0: il n'a pas de clé API. - Attention aux règles « Python » du pare-feu : Windows en a créé qui
autorisent
python.exeen entrée sur tous les ports depuis n'importe où. Tant qu'elles existent, une règle limitée au serveur front ne sert à rien (une règle d'autorisation suffit à laisser passer). Cf. section 10. - Ollama écoute encore sur
0.0.0.0(OLLAMA_HOSTau niveau machine) : point ouvert de l'audit. Le passer à127.0.0.1le fermerait au réseau local sans gêner les conteneurs. - Protections côté Traefik : rate-limit 30 req/min par IP visiteur
(
Cf-Connecting-Ip), HSTS, CSP, Permissions-Policy (micro réservé au site). - Protections côté API :
/docsfermé, contrôle d'Originsur/voice, une session vocale à la fois, question limitée à 30 s, socket coupé après 10 s de silence, erreurs génériques. - Secrets et données personnelles :
.env(jeton du tunnel) etbackend/tts/voix/(ta voix) ne sont jamais versionnés. Gitleaks vérifie l'historique en CI. - Dépendances : Dependabot chaque semaine ;
pip-auditetnpm auditen CI. Pour auditer les venvs de prod :pip-audit --path .venv-rocm/Lib/site-packages.
Ordre à respecter : le tunnel ne doit jamais tourner sur les deux machines à la fois avec le même jeton. Cloudflare répartirait les visiteurs entre les deux connecteurs.
- Pare-feu du PC (PowerShell administrateur). Désactiver les règles
génériques créées par Windows pour Python, puis n'autoriser que le serveur front :
Get-NetFirewallApplicationFilter | Where-Object Program -match 'python' | Get-NetFirewallRule | Disable-NetFirewallRule New-NetFirewallRule -DisplayName "PortFolio-AI API depuis le serveur front" -Direction Inbound ` -Protocol TCP -LocalPort 8000 -LocalAddress 192.168.1.75 -RemoteAddress <IP du serveur front> -Action Allow
- API sur l'IP locale :
$env:API_HOST = "192.168.1.75"puisstart-natif.ps1. Vérifier depuis le serveur front :curl -s http://192.168.1.75:8000/health. - Sur le serveur front : cloner le dépôt, créer
.envà la racine (TRAEFIK_DOMAIN,CLOUDFLARE_TUNNEL_TOKEN, repris du PC), copierfrontend/out/(section 5). - Couper l'ancienne stack sur le PC :
docker compose --env-file .env -f deploy/docker-compose.expose.yml down. - Démarrer la stack sur le serveur front :
docker compose --env-file .env -f deploy/docker-compose.expose.yml up -d. - Vérifier (section 6), puis éteindre l'API sur le PC et vérifier que le site reste en ligne avec le message « hors ligne ».
Sur Debian, le watch du provider file de Traefik fonctionne probablement (la
limite constatée venait du montage Windows) : non vérifié, redémarrer Traefik
reste la méthode sûre.