Appearance
Session 2026-05-05 — Déploiement VPS Option B (Tailscale) end-to-end
Récit complet de la session : ce qu'on a fait, ce qui a foiré, ce qu'on a appris. À lire de haut en bas pour comprendre l'enchaînement, ou en piochant si tu cherches une décision spécifique.
Contexte de départ
VPS Hetzner Ubuntu Noble (ubuntu-8gb-nbg1-1, IP publique 188.245.104.87). Setup déjà bootstrappé (Docker, Go, gh installés ; repos snag-tcg/* clonés). fail2ban appliqué dans une session précédente. Durcissement SSH key-only reporté.
Objectif de la session : exécuter vps-tailscale-deployment.md Étapes 1-6 (côté VPS), puis prolonger avec un debug complet quand le téléphone n'a pas réussi à charger les images de cartes scannées.
Phase 1 — Déploiement Tailscale (Étapes 1-6 du guide)
Étape 1 — Install Tailscale + auth
curl -fsSL https://tailscale.com/install.sh -o /tmp/tailscale-install.sh && bash /tmp/tailscale-install.sh(préféré au pipe direct pour éviter les pièges RTK + sudo)sudo tailscale uplancé en background, URL d'auth lue depuis stdout, validée côté navigateur laptop- IP tailnet du VPS :
100.108.38.24
Étape 2 — Pull du backend
Déjà fait au début de la session : checkout main + pull sur les 4 repos (routines, tcg-collector-backend, poc-tcg-collector, documentation). Le repo documentation n'avait pas les guides Tailscale en local — ils étaient sur des branches distantes (origin/docs/vps-tailscale-deployment, origin/docs/vps-public-deployment). Pull de main les a ramenés (déjà mergées).
Étape 3 — Configuration .env
Génération de 2 secrets via openssl rand -hex 32, affichage en clair UNE FOIS pour sauvegarde dans le password manager, puis écriture dans .env après GO utilisateur :
| Var | Valeur |
|---|---|
PGPASSWORD | secret hex 64 |
API_WRITE_TOKEN | secret hex 64 |
LOCAL_IMAGE_BASE_URL | http://100.108.38.24:8081/images |
chmod 600 .env, gitignored par .gitignore:15.
Étape 4 — ufw
Choisi (vs binding ports) car aligné avec l'Option A future. Règles :
ALLOW IN on tailscale0 # tout via tailnet
DENY 8000, 8080, 8081, 5432 # défense en profondeur
ALLOW 22/tcp # SSH avant enable, sinon coupure session
ufw --force enable--force essentiel pour éviter le prompt "are you sure?" qui hangue sans TTY.
Note réelle : Docker bypass ufw via les chains DOCKER-USER/PREROUTING. Donc les DENY 8000/8080/8081 ne bloquent pas vraiment les ports Docker — c'est le Hetzner Cloud Firewall en amont qui fait le vrai filtrage de surface publique. ufw reste utile pour 5432 (Postgres direct) et la lisibilité.
Étape 5 — Docker stack — premier essai partiel
bash
sg docker -c "docker compose -f docker/compose.yml up -d --build postgres api nginx tcg-vision"sg docker -c parce que la session SSH/tmux/Claude a hérité de groupes UNIX figés à fork() d'avant usermod -aG docker kael. tmux attach ne respawn aucun processus → pas la peine de se reconnecter pour cette session. Détaillé dans vps-troubleshooting §1.
Résultat : 3 services up sur 4. nginx fail au démarrage :
mkdirat .../usr/share/nginx/html/images: read-only file systemBug n°1 — nginx mountpoint manquant
Le docker/compose.yml empile 2 bind mounts :
yaml
volumes:
- ../app:/usr/share/nginx/html:ro # mount #1
- ../data/images:/usr/share/nginx/html/images:ro # mount #2 — DANS le mount #1Mount #2 a besoin d'un mountpoint images/ à l'intérieur de mount #1. Le source app/images/ n'existe pas (git ignore les dossiers vides). Sur Linux/runc strict, créer le mountpoint dans une overlay :ro est refusé. Sur Docker Desktop Windows c'était laxiste → ne s'est jamais manifesté avant.
Fix appliqué : mkdir app/images côté VPS. PR upstream ouverte et mergée : tcg-collector-backend#93 — ajoute app/images/.gitkeep pour que le mountpoint soit checkout au clone.
Après le mkdir : recreate nginx → 4/4 services up.
Étape 6 — Premier ingest
bash
sg docker -c "docker compose -f docker/compose.yml run --rm ingest"Avec INGEST_SETS=base1,sv01 : 8.3 secondes (vs 30 min annoncés par le guide). 360 cartes, 720 images (low + high), 27.7 MB sur disque, 0 fail.
Phase 2 — Durcissement SSH key-only
Reporté de la session précédente, pris en charge sur demande utilisateur. Procédure :
bash
echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/99-disable-password.conf
sudo sshd -t # validation
sudo systemctl reload sshNote Ubuntu Noble : ssh.service est disabled mais ssh.socket est enabled → socket activation. systemctl reload ssh fonctionne quand même.
Verification :
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication no ← appliqué
kbdinteractiveauthentication noTest confirmé côté laptop : ssh kael@vps marche (clé), ssh -o PreferredAuthentications=password ... rejeté.
Phase 3 — Documentation (vps-troubleshooting.md)
Après plusieurs pièges rencontrés en cours de session, doc bilingue créée pour capturer les patterns récurrents :
- Docker
permission deniedpost-usermod -aG docker(groupes Unix figés au fork, tmux attach n'aide pas) apt upgradequi hangue sur whiptail (DEBIAN_FRONTEND=noninteractive+NEEDRESTART_MODE=a)gpg --dearmor+ pipe curl → "invalid radix64" (sauver d'abord)gpg --dearmor+ no-TTY → "cannot open /dev/tty" (--batch --yes)- RTK + sudo → "rtk: command not found" (
/usr/bin/sudo /usr/bin/...) gh auth logindevice code (Claude doit lire la stdout, pas refuser)
PR mergée : documentation#7.
Phase 4 — Étape 7 (laptop) — repointer l'app mobile
Côté utilisateur, sur le laptop : poc-tcg-collector/src/scan/backendConfig.ts:45 modifié pour pointer sur 100.108.38.24:8000 au lieu de 100.76.15.57:8000 (laptop). PR #107 mergée. APK rebuildée, installée sur le téléphone OnePlus Nord 4.
Premier test depuis le téléphone : Network request failed.
Phase 5 — Debug « Network request failed »
Bug n°2 — network_security_config.xml whitelist hardcodée
L'utilisateur a inspecté poc-tcg-collector/android/app/src/main/res/xml/network_security_config.xml et trouvé une whitelist d'IPs cleartext hardcodée sur l'ancien laptop :
xml
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">100.76.15.57</domain> ← seulement laptop
</domain-config>Android API 28+ bloque le HTTP cleartext par défaut, sauf pour les hôtes whitelistés ici. La nouvelle IP VPS 100.108.38.24 n'y était pas → <Image> (Fresco) refuse le fetch.
Fix : ajout de 100.108.38.24 à la whitelist. PR #126 mergée. Rebuild APK + reinstall.
Phase 6 — Debug « Card details OK mais image ne charge pas »
Le scan POST passe (visible dans les logs tcg-vision), les détails de carte s'affichent côté app, mais l'image reste blanche et aucun GET /images/... n'apparaît dans les logs nginx.
Bug n°3 — Docker Compose ne charge pas le .env
Inspection de l'env effective dans le container tcg-vision :
LOCAL_IMAGE_BASE_URL=Vide. Mais le .env de la racine du repo contient bien LOCAL_IMAGE_BASE_URL=http://100.108.38.24:8081/images.
Cause : Docker Compose v2 cherche .env dans le project directory, qui par défaut est le dossier du compose.yml (donc docker/). Pas dans le cwd. docker/.env n'existe pas → aucune var chargée → ${LOCAL_IMAGE_BASE_URL:-} se résout en chaîne vide. Idem pour ${PGPASSWORD:-tcg} qui retombe sur le default tcg.
Conséquences :
- Postgres tournait avec password par défaut
tcg(alors qu'il est exposé sur le tailnet — risque sécu) - tcg-vision ne réécrivait PAS les URLs d'image (renvoyait l'URL TCGdex CDN par défaut, pas notre nginx)
Fix :
bash
# 1. Aligner Postgres live sur le secret du .env
PGPASSWORD_REAL=$(grep ^PGPASSWORD= .env | cut -d= -f2)
sg docker -c "docker exec tcg-collector-postgres-1 psql -U tcg -c \"ALTER USER tcg WITH PASSWORD '$PGPASSWORD_REAL';\""
# 2. Toujours passer --env-file explicitement
sg docker -c "docker compose --env-file .env -f docker/compose.yml up -d --force-recreate"Vérification post-recreate :
LOCAL_IMAGE_BASE_URL=http://100.108.38.24:8081/images ✅
POSTGRES_PASSWORD=492c…b5fe ✅À retenir : toujours --env-file .env quand on lance docker compose depuis la racine du repo. À documenter upstream dans tcg-collector-backend/CLAUDE.md, ou à fixer en virant les défauts dangereux du compose (${PGPASSWORD:?missing} au lieu de ${PGPASSWORD:-tcg}), ou en déplaçant .env à docker/.env.
Bug n°4 — Couverture FAISS vs disque local
Après le fix env, l'app reçoit bien des URLs réécrites vers nginx. Mais toujours pas d'image qui charge.
Test côté laptop avec curl -I http://100.108.38.24:8081/images/base1/2/high.webp → 200 OK (image disponible). Test dans Chrome du téléphone même URL → image affichée. Donc réseau OK + serveur OK + cleartext config OK.
Reste donc l'app. Inspection du code poc-tcg-collector/src/scan/ScanResultCard.tsx:21 :
tsx
{details.image ? (
<Image source={{ uri: details.image }} ... />
) : null}Si details.image est null/empty → <Image> jamais rendu → 0 GET, cohérent avec les logs nginx vides.
Test exhaustif côté VPS :
bash
curl -X POST -F "photo=@data/images/base1/25/high.webp" http://100.108.38.24:8000/scan-compat
# → match: xy12-29 Lamantine
# → image: http://100.108.38.24:8081/images/xy12/29/high.webpJ'ai uploadé une carte base1/25 (Dewgong) et FAISS m'a renvoyé un autre Lamantine, du set xy12. xy12 n'est pas sur disque → l'URL d'image renvoyée 404.
| Source | Cartes |
|---|---|
Index FAISS dans tcg-vision | 30 370 |
Sets ingérés en local (base1 + sv01) | 360 |
| Couverture | 1.1 % |
→ 98.9 % des scans avec une vraie photo (bruit, perspective, reflets) vont retomber sur une carte qu'on n'a pas en local. URL → 404. Et <Image> Android dropt silencieusement (soit avant la requête, soit après réception du 404).
Fix retenu (Option A) — ré-ingest tous les sets ✅
bash
sed -i 's/^INGEST_SETS=.*/INGEST_SETS=all/' .env
sg docker -c "docker compose --env-file .env -f docker/compose.yml run --rm ingest"Résultat réel : 7 min 36 s (pas les heures annoncées), 208 sets, 23 159 cartes, 43 078 images, 1,84 GB sur disque. Couverture FAISS passée de 1,1 % à 76 %.
Fix long terme (Option C) — fallback TCGdex côté tcg-vision ✅
Patché et mergé : tcg-collector-backend#94. _build_card_image_url vérifie maintenant os.path.exists() avant de réécrire l'URL ; fichier absent → fallback TCGdex CDN au lieu d'un 404 garanti. Le compose.yml monte désormais data/images:ro pour tcg-vision, et la nouvelle env var LOCAL_IMAGE_DATA_DIR (défaut /app/data/images) active le check. Couvre les ~24 % résiduels de cartes hors disque (Pokémon JP, One Piece, Lorcana, etc.).
Bilan
Ce qui marche aujourd'hui
- VPS up, sécurisé (fail2ban + ufw + Hetzner FW + SSH key-only)
- Tailscale node
100.108.38.24joignable par le téléphone - Stack Docker 4/4 services healthy (postgres, api, nginx, tcg-vision)
.envcorrectement chargé via--env-file- App identifie les cartes (POST scan-compat → 200, détails affichés)
Ce qui reste à finir
Tout ce qui était identifié pendant la session a été clôturé avant la fin :
Ingest tous les sets✅ fait (7 min, 23 k cartes, 76 % couverture FAISS)PR upstream✅ mergée (backend#94)fix/tcg-vision-image-fallbackNote upstream✅ fixé en dur via backend#95 (compose hard-fail si--env-filePGPASSWORDest absent +CLAUDE.mdmis à jour + entête dans.env.example)- Étape 9 (optionnel, côté laptop) :
docker compose downpour libérer les ressources Docker du laptop — toujours en attente du choix utilisateur
Phase 7 — Hardening sécurité tardif (ajouté en fin de session)
Après la doc initiale, fix sécu pour empêcher le piège n°3 (.env non chargé silencieusement → Postgres en password par défaut) de se reproduire :
${PGPASSWORD:-tcg}× 4 services →${PGPASSWORD:?missing PGPASSWORD — pass --env-file .env or export it}. Compose erre out hard siPGPASSWORDn'est pas chargé, au lieu de booter avec une valeur par défaut connue.CLAUDE.mddocumente--env-file .envdans toutes les commandesdocker composedu repo.- Header dans
.env.examplerappelle la contrainte.
PR mergée : tcg-collector-backend#95. Vérifié des deux côtés :
- Avec
--env-file .env→ password fort résolu, tout marche - Sans → fail explicite à la résolution du compose, message qui pointe vers le fix
Pièges rencontrés (résumé)
tmux attachne recharge pas les groupes Unix (groupes figés au fork des processus enfants)app/images/mountpoint manquant côté Linux strict — fixé par backend#93network_security_config.xmlAndroid cleartext whitelist par IP — fixé par poc#126- Docker Compose v2 cherche
.envdans le project dir = dossier du compose.yml, pas le cwd — fixé par backend#95 (fail-fast au lieu de fallback silencieux) - tcg-vision blanket-rewrite vers nginx sans vérifier si le fichier existe → 404 silent côté Android Fresco — fixé par backend#94
Mesures sécurité prises
- Postgres
PGPASSWORDrandom hex 64 - API
API_WRITE_TOKENrandom hex 64 - SSH key-only, password désactivé
- ufw active, default deny incoming
.envchmod 600, gitignored
Références internes
- Déploiement VPS via Tailscale — guide qu'on a exécuté
- Déploiement VPS public (Caddy + HTTPS) — étape suivante quand on quittera Tailscale
- Troubleshooting VPS / déploiement — pièges récurrents
~/POST_DEPLOYMENT.md(sur le VPS) — runbook ops post-déploiement