Skip to content

Déploiement VPS public (domaine + HTTPS) ​

Ce guide décrit le setup initial. Pour mettre à jour le VPS existant, suivre la procédure de déploiement vérifiée le 31 octobre 2026 : la configuration active comporte plusieurs surcharges Compose et le routage OAuth. Ne pas remplacer cette configuration par les exemples initiaux ci-dessous.

Objectif : sortir le backend TCG du tailnet pour le rendre accessible publiquement, avec un vrai nom de domaine et HTTPS, sans changer la composition du stack applicatif.

C'est le setup Option A — la cible de production. Le guide jumeau vps-tailscale-deployment.md couvre l'étape intermédiaire (Tailscale, pas de domaine, HTTP). Ce guide-ci décrit l'état stable où n'importe quel téléphone, sans Tailscale, peut scanner une carte.

Ce qui change par rapport à l'Option B ​

Option B (Tailscale)Option A (public)
URL backend pour le téléphonehttp://<IP_TAILNET_VPS>:8000/scan-compathttps://tcg.votre-domaine.com/scan-compat
URL imageshttp://<IP_TAILNET_VPS>:8081/images/...https://tcg.votre-domaine.com/images/...
TLSAucun (tunnel chiffré par Tailscale)Let's Encrypt automatique via Caddy
Pré-requis téléphoneTailscale activéAucun
Surface publiqueAucune (binding tailnet uniquement)80 + 443 ouverts au monde
PostgresToujours interneToujours interne (jamais exposé)

Le stack applicatif ne change pas — postgres, api, nginx, tcg-vision continuent de tourner en l'état. On ajoute simplement Caddy devant comme reverse proxy public avec TLS automatique, et on resserre les bindings réseau et le firewall.

Architecture cible ​

       Internet (téléphone, navigateur)
                   │
           HTTPS  │  443
                   ▼
        ┌──────────────────────┐
        │      Caddy           │   container, exposé sur 0.0.0.0:80,443
        │  (TLS + reverse      │   - Let's Encrypt (HTTP-01)
        │   proxy unique       │   - HSTS, gzip, rate-limit
        │   point d'entrée)    │
        └─┬───────────┬────────┘
          │           │
   /scan-compat   /images/**, /api/**
          │           │
          ▼           ▼
   ┌──────────┐   ┌─────────┐    ┌──────────┐
   │ tcg-     │   │ nginx   │───►│ api      │
   │ vision   │   │ (sert   │    │ (Fastify)│
   │ :8000    │   │ images, │    │ :3000    │
   │ (interne)│   │ proxy   │    │ (interne)│
   └──────────┘   │ /api)   │    └─────┬────┘
                  │ :80     │          │
                  │(interne)│          ▼
                  └─────────┘    ┌──────────┐
                                  │ postgres │
                                  │ :5432    │
                                  │ (interne)│
                                  └──────────┘

Points clés :

  • Caddy est le seul service exposé sur l'internet public (80, 443).
  • nginx, api, tcg-vision, postgres ne bindent plus que sur le réseau Docker interne — invisibles depuis l'extérieur.
  • Le service d'images reste l'nginx interne : on ne change pas sa config, c'est lui qui connaît les bons headers de cache (Cache-Control: public, max-age=31536000, immutable) pour les visuels webp. Caddy proxie simplement /images/** vers nginx:80.
  • TLS terminé chez Caddy. Tout ce qui se passe derrière reste en HTTP cleartext sur le réseau Docker — c'est OK, le réseau Docker n'est joignable que depuis l'hôte.

Pré-requis ​

  • Le stack Option B tourne déjà sur le VPS (postgres + api + nginx + tcg-vision via docker compose).
  • Vous possédez un nom de domaine et avez accès à sa zone DNS.
  • Les ports 80 et 443 sont ouverts sur le firewall du provider (souvent OK par défaut, à vérifier dans la console du VPS).
  • Un compte email valide pour les notifications Let's Encrypt (expiration des certs, etc.).

Étape 1 — Pointer le DNS sur le VPS ​

Dans la console DNS de votre registrar, ajoutez :

Type   Nom                    Valeur                  TTL
A      tcg                    <IP_PUBLIQUE_VPS>       300
AAAA   tcg                    <IPv6_VPS si dispo>     300

Vérifier la propagation (depuis n'importe où) :

bash
dig +short tcg.votre-domaine.com
# doit renvoyer l'IP publique du VPS

Tant que dig ne renvoie pas l'IP correcte, n'allez pas plus loin — Let's Encrypt va rater le challenge HTTP-01.

Étape 2 — Resserrer les bindings réseau dans compose.yml ​

Sur le VPS, dans ~/snag-tcg/tcg-collector-backend/docker/compose.yml (ou via un docker-compose.override.yml gitignored), modifier les ports: pour qu'aucun service backend ne soit publié sur l'hôte. On les laisse uniquement sur le réseau Docker.

Avant (Option B) :

yaml
api:
  ports:
    - "<IP_TAILNET_VPS>:8080:3000"
nginx:
  ports:
    - "<IP_TAILNET_VPS>:8081:80"
tcg-vision:
  ports:
    - "<IP_TAILNET_VPS>:8000:8000"

Après (Option A) — supprimer les blocs ports: pour api, nginx, tcg-vision. Postgres aussi (s'il était sur 127.0.0.1:5432). Le réseau Docker suffit pour que Caddy les joigne par leur nom de service.

Si compose.yml est partagé entre plusieurs environnements, préférez un docker-compose.override.yml local au VPS qui re-déclare ces services sans ports:. Garder le repo générique.

Étape 3 — Ajouter Caddy au compose ​

Créer docker/Caddyfile à la racine du repo backend :

nginx
tcg.votre-domaine.com {
    encode gzip zstd

    # Headers de sécurité raisonnables pour une API
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "no-referrer"
        -Server
    }

    # Scanner de cartes
    handle /scan-compat* {
        reverse_proxy tcg-vision:8000
    }

    # Images de cartes (servies par le nginx interne, qui pose les bons cache headers)
    handle /images/* {
        reverse_proxy nginx:80
    }

    # API REST Fastify
    handle /api/* {
        reverse_proxy nginx:80   # nginx fait déjà le proxy /api → api:3000
    }

    # Tout le reste : 404 propre
    handle {
        respond "Not found" 404
    }
}

Ajouter le service Caddy dans compose.yml (ou override) :

yaml
caddy:
  image: caddy:2-alpine
  restart: unless-stopped
  ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"   # HTTP/3
  volumes:
    - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy_data:/data         # certs, clés ACME — NE PAS PERDRE
    - caddy_config:/config
  depends_on:
    - nginx
    - tcg-vision
  networks:
    - default

volumes:
  caddy_data:
  caddy_config:

Le volume caddy_data contient les certificats émis par Let's Encrypt. Sauvegardez-le (rsync hebdo vers un stockage froid suffit) — sinon en cas de wipe du VPS vous repassez par l'émission, et Let's Encrypt rate-limit à 5 certs identiques par semaine.

Étape 4 — Mettre à jour .env ​

bash
# Le téléphone va maintenant taper l'URL publique. Les images doivent
# pointer sur le même domaine pour éviter mixed-content et CORS.
LOCAL_IMAGE_BASE_URL=https://tcg.votre-domaine.com/images

# Resserrer CORS — plus de wildcard
CORS_ORIGIN=https://tcg.votre-domaine.com
# Si l'app mobile fait des requêtes depuis un schéma différent (capacitor://,
# file://) ajoutez-les ici en liste séparée par des virgules.

Étape 5 — Firewall : couper tout sauf 22/80/443 ​

Si vous aviez des règles ufw héritées de l'Option B autorisant 8000/8080/8081 sur tailscale0, supprimez-les — ces ports ne sont plus exposés sur l'hôte de toute façon, mais autant être propre :

bash
sudo ufw status numbered
sudo ufw delete <numéros des règles 8000/8080/8081>

# État cible
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp     # HTTP/3
sudo ufw enable
sudo ufw status

Tailscale peut rester installé sur le VPS pour l'admin (SSH via tailnet est plus sûr que SSH public), mais ce n'est plus dans le chemin du trafic applicatif.

Étape 6 — Démarrer ​

bash
cd ~/snag-tcg/tcg-collector-backend
docker compose -f docker/compose.yml up -d --build

# Suivre Caddy pour voir l'émission du cert Let's Encrypt
docker compose -f docker/compose.yml logs -f caddy

Premier boot, Caddy fait un challenge HTTP-01 sur :80. Vous devriez voir une ligne du genre certificate obtained successfully. Si ça rate :

  • DNS pas encore propagé (re-dig).
  • Port 80 bloqué par le firewall provider (à vérifier dans la console du VPS, pas seulement ufw).
  • Une autre app (apache résiduel ?) écoute sur 80 — sudo ss -tlnp | grep :80.

Health checks depuis n'importe où :

bash
curl -I https://tcg.votre-domaine.com/scan-compat   # doit répondre (méthode GET pas supportée → 405 ou 200, pas de TLS error)
curl -I https://tcg.votre-domaine.com/images/A1/001/high.webp
# HTTP/2 200
# content-type: image/webp
# cache-control: public, max-age=31536000, immutable

# Vérifier la note SSL
curl -sI https://tcg.votre-domaine.com | head -1
# HTTP/2 200

Pour un audit plus complet, SSL Labs doit donner A ou A+ avec la config Caddy par défaut.

Étape 7 — Repointer l'app mobile ​

Dans poc-tcg-collector :

ts
// src/scan/backendConfig.ts
export const BACKEND_SCAN_URL = 'https://tcg.votre-domaine.com/scan-compat';

Commit, PR, merge, rebuild APK, réinstaller. À partir de là, le téléphone n'a plus besoin de Tailscale pour scanner.

Étape 8 — Décommissionner Tailscale du chemin applicatif ​

Tailscale peut rester pour l'admin, mais l'app mobile n'en dépend plus. Si vous voulez le supprimer complètement :

bash
sudo tailscale down
sudo tailscale logout
# ou plus radical :
sudo apt remove --purge tailscale

Et retirez le node de la console admin Tailscale.

Maintenance ​

Les certificats Let's Encrypt renouvellent automatiquement (Caddy s'en occupe ~30 jours avant expiration). Rien à faire tant que :

  • Le DNS continue de résoudre vers le VPS.
  • Le port 80 reste ouvert (Caddy en a besoin pour les renouvellements HTTP-01).
  • Le volume caddy_data n'est pas perdu.

Mises à jour applicatives : identique à l'Option B.

bash
cd ~/snag-tcg/tcg-collector-backend
git pull
docker compose -f docker/compose.yml up -d --build

Rollback vers l'Option B ​

Si quelque chose casse en prod et que vous voulez repasser au tailnet vite fait :

  1. Remettre les ports: <IP_TAILNET_VPS>:... dans le compose (ou réactiver l'override Option B).
  2. docker compose up -d — Caddy peut rester arrêté ou tourner en parallèle.
  3. Repointer l'app mobile sur l'IP tailnet (revert du commit Étape 7).
  4. Demander aux utilisateurs de réactiver Tailscale.

Tant que caddy_data est conservé, vous pouvez basculer dans un sens ou l'autre sans repasser par l'émission de cert.

Quand passer aux étapes suivantes ​

Cette config est suffisante pour 1 à 1000 utilisateurs en lecture / scan léger. Au-delà, pensez à :

  • CDN devant Caddy (Cloudflare, Bunny) si la bande passante images devient le goulot — data/images/ est immutable, parfait pour un cache edge.
  • Postgres managé (Neon, Supabase, RDS) si vous ne voulez plus gérer les sauvegardes vous-même.
  • Authentification sur l'API si vous ouvrez le backend à des clients tiers (aujourd'hui API_WRITE_TOKEN protège les écritures, mais /scan-compat est anonyme — OK pour une app mobile mono-client, pas pour un public élargi).
  • Observabilité : logs structurés, métriques (Caddy expose Prometheus nativement via metrics).

Aucun de ces ajouts ne casse la topologie ci-dessus — ils s'empilent par-dessus.