Skip to content

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 up lancé 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 :

VarValeur
PGPASSWORDsecret hex 64
API_WRITE_TOKENsecret hex 64
LOCAL_IMAGE_BASE_URLhttp://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 system

Bug 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 #1

Mount #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 ssh

Note 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 no

Test 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 :

  1. Docker permission denied post-usermod -aG docker (groupes Unix figés au fork, tmux attach n'aide pas)
  2. apt upgrade qui hangue sur whiptail (DEBIAN_FRONTEND=noninteractive + NEEDRESTART_MODE=a)
  3. gpg --dearmor + pipe curl → "invalid radix64" (sauver d'abord)
  4. gpg --dearmor + no-TTY → "cannot open /dev/tty" (--batch --yes)
  5. RTK + sudo → "rtk: command not found" (/usr/bin/sudo /usr/bin/...)
  6. gh auth login device 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.webp

J'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.

SourceCartes
Index FAISS dans tcg-vision30 370
Sets ingérés en local (base1 + sv01)360
Couverture1.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.24 joignable par le téléphone
  • Stack Docker 4/4 services healthy (postgres, api, nginx, tcg-vision)
  • .env correctement 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 fix/tcg-vision-image-fallback ✅ mergée (backend#94)
  • Note upstream --env-file ✅ fixé en dur via backend#95 (compose hard-fail si PGPASSWORD est absent + CLAUDE.md mis à jour + entête dans .env.example)
  • Étape 9 (optionnel, côté laptop) : docker compose down pour 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 si PGPASSWORD n'est pas chargé, au lieu de booter avec une valeur par défaut connue.
  • CLAUDE.md documente --env-file .env dans toutes les commandes docker compose du repo.
  • Header dans .env.example rappelle 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é) ​

  1. tmux attach ne recharge pas les groupes Unix (groupes figés au fork des processus enfants)
  2. app/images/ mountpoint manquant côté Linux strict — fixé par backend#93
  3. network_security_config.xml Android cleartext whitelist par IP — fixé par poc#126
  4. Docker Compose v2 cherche .env dans le project dir = dossier du compose.yml, pas le cwd — fixé par backend#95 (fail-fast au lieu de fallback silencieux)
  5. 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 PGPASSWORD random hex 64
  • API API_WRITE_TOKEN random hex 64
  • SSH key-only, password désactivé
  • ufw active, default deny incoming
  • .env chmod 600, gitignored

Références internes ​