HTTPS local en 2026 : un seul certificat Let's Encrypt pour tous mes projets dev

HTTPS local en 2026 : un seul certificat Let's Encrypt pour tous mes projets dev

·12 min de lecture·Mis à jour le 24 mai 2026
Pour qui

Vous avez plusieurs projets dev en local (3, 10, 30+), un domaine que vous possédez, et vous en avez marre de jongler avec des ports ou des certs auto-signés. Article validé sur WSL2 Ubuntu 24.04 mais transposable à Linux natif et macOS.

Le problème

$ bun dev
error: bind EADDRINUSE 0.0.0.0:3000

Vingt-deux projets dans ~/projects/, sept ports actifs en parallèle. Et depuis 2026, OAuth Google refuse les redirect URIs en .localhost :

Origine non valide : l'URI doit se terminer par une extension de domaine public de premier niveau, telle que .com ou .org.

S'ajoutent les cookies Secure qui ne se posent pas en HTTP, et une dictée de credentials Google différents à chaque clone.

Les fausses bonnes idées

❌ Plan de ports 30xxx à la main. Mnémotechnique au début, dette technique après six mois. lsof -i :30130 rappelle à quoi correspond le numéro, mais on l'oublie dès qu'on revient sur un projet après deux semaines. Et ça ne règle ni le HTTPS, ni les cookies, ni le mobile testing.

*.localhost derrière un reverse proxy. Joli sur le papier, mais Google OAuth refuse les .localhost : ce n'est pas un vrai TLD. Bloquant pour tout ce qui touche à Sign in with Google (et probablement Microsoft, Stripe, GitHub d'ici peu).

❌ Tunnels publics type ngrok. URLs partagées, et risque d'OAuth redirect hijacking documenté par Microsoft en mars 2026 sur les sous-domaines temporaires recyclés : un attaquant qui hérite de votre ancien sous-domaine ngrok récupère vos codes OAuth tant que la redirect URI n'est pas retirée chez le provider. Le free tier ngrok rotate les sous-domaines.

⚠️ Caddy + lvh.me (ou localtest.me). Ça marche très bien : lvh.me est un domaine public dont tous les sous-domaines résolvent vers 127.0.0.1 via un wildcard DNS, et .me est un vrai TLD accepté par Google. Setup en 5 minutes. Mais le cert TLS reste auto-signé (Caddy Local CA) : root cert à installer sur chaque machine et chaque mobile, compliqué à partager avec un collègue, et dépendance à un domaine tiers. C'est le bon compromis « 5 minutes », pas le bon choix « j'ai du temps et un domaine ».

La stack retenue

Les composants :

  • Wildcard DNS *.dev.exemple.com → 127.0.0.1 sur votre domaine, hébergé par Cloudflare (free plan)
  • Caddy sur la machine de dev, recompilé avec le plugin Cloudflare DNS pour le challenge ACME DNS-01
  • Un seul cert Let's Encrypt wildcard *.dev.exemple.com, renouvelé tous les ~60 jours, trusté partout par défaut parce que signé par une CA publique
  • Ports backend générés par hash déterministe sha256(nom_projet) % 9900 + 30100, stables et opaques — on ne les voit jamais, Caddy fait l'aiguillage

Le morceau important, c'est le challenge DNS-01. Let's Encrypt ne peut pas joindre votre 127.0.0.1 depuis Internet pour un challenge HTTP-01 classique. Caddy prouve donc qu'il contrôle le domaine en posant temporairement un TXT record _acme-challenge.dev.exemple.com via l'API Cloudflare, puis le supprime. Renouvellement automatique tous les 60 jours.

Pourquoi pas rester chez son registrar

Premier réflexe avec un domaine chez Squarespace : ajouter le wildcard A record chez eux et lancer Caddy avec un challenge DNS-01. Bloqué net — Squarespace n'expose pas d'API DNS publique en 2026. Le repo communautaire caddy-dns héberge 96 providers (Cloudflare, Route53, OVH, Hetzner, Gandi…), Squarespace absent, et pas de plugin Certbot non plus. Sans API DNS automatisable, pas de cert wildcard auto-renouvelé.

La nuance qui débloque : un domaine a un registrar (qui vous le vend et gère la propriété ICANN) et un DNS host (qui répond aux requêtes DNS). Deux services indépendants. On peut garder son registrar actuel et déléguer uniquement la résolution DNS à Cloudflare via les NS records. Pratique standard depuis ~2020, et 0 € de plus (Cloudflare DNS est gratuit en plan Free, illimité en records).

Setup pas-à-pas

1. Snapshot DNS avant migration

Audit complet de la zone, nécessaire pour la recréer chez le nouveau DNS host et pour un éventuel rollback :

dig +short exemple.com NS
dig +short exemple.com A
dig +short exemple.com MX
dig +short exemple.com TXT
dig +short _dmarc.exemple.com TXT
dig +short google._domainkey.exemple.com TXT
# + tous les sous-domaines connus

⚠️ Piège classique : le scan automatique de Cloudflare ne trouve que les sous-domaines courants (www, mail, api). Tous les sous-domaines custom (envs de prod, staging) doivent être ajoutés à la main. Listez-les exhaustivement depuis l'admin du registrar avant de switcher — un staging.exemple.com oublié se traduit par un 404 qu'on met des jours à comprendre.

2. Cloudflare : ajouter le domaine

dash.cloudflare.comAdd a Site → domaine → plan Free. Comparez les records détectés avec votre snapshot et ajoutez les manquants.

Sur tout ce qui pointe vers Vercel : passez en DNS only (nuage gris). Le double proxy Cloudflare → Vercel casse les preview deployments et l'image optimization.

Bonus 2026 : pour les A records pointant vers Vercel, remplacez-les par des CNAME vers cname.vercel-dns.com. Sur l'apex, Cloudflare fait du CNAME flattening natif, donc même @ CNAME cname.vercel-dns.com fonctionne — bien plus pérenne qu'une IP fixe Vercel legacy.

3. Switch des nameservers chez le registrar

Cloudflare fournit deux NS (xxx.ns.cloudflare.com, yyy.ns.cloudflare.com) à mettre chez le registrar en nameservers personnalisés.

⚠️ Copier-coller, jamais à la main. Une lettre près et la migration n'aboutit jamais.

⚠️ Avant de switcher : si DNSSEC est activé chez le registrar, désactivez-le. Il se réactive après depuis Cloudflare.

Propagation : 5 minutes à 24 h selon le TLD (30 min à 2 h en pratique pour .io). Vérification :

dig +short @1.1.1.1 exemple.com NS  # doit retourner les NS Cloudflare
dig +short @8.8.8.8 exemple.com NS  # idem

4. Wildcard *.dev → loopback

Via l'UI Cloudflare, ou en une commande avec un token API (scope Zone:DNS:Edit sur ce domaine uniquement) :

curl -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records" \
  -H "Authorization: Bearer <CLOUDFLARE_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"type":"A","name":"*.dev","content":"127.0.0.1","ttl":1,"proxied":false}'

ttl: 1 = « Auto » dans l'API. proxied: false = DNS only.

5. Caddy avec plugin Cloudflare

Caddy standard ne sait pas faire de challenge DNS-01 Cloudflare, il faut le recompiler avec xcaddy :

# Installer Go puis xcaddy
sudo apt install -y golang-go
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build avec le plugin Cloudflare DNS
~/go/bin/xcaddy build --with github.com/caddy-dns/cloudflare

# Remplacer le binaire système
sudo systemctl stop caddy
sudo cp ./caddy /usr/bin/caddy
sudo systemctl start caddy

Vérification : caddy list-modules | grep cloudflare doit afficher dns.providers.cloudflare. Si rien ne sort, le build a échoué silencieusement (xcaddy ne signale pas toujours un conflit de version) — relancez avec -v.

6. Token API Cloudflare

dash.cloudflare.com/profile/api-tokens → template Edit zone DNS → scope sur la zone uniquement. Copiez le token (visible une seule fois) et injectez-le dans systemd :

sudo systemctl edit caddy
[Service]
Environment="CLOUDFLARE_API_TOKEN=<votre-token>"
sudo systemctl daemon-reload
sudo systemctl restart caddy   # restart, pas reload

⚠️ reload ne propage pas les Environment=, seulement restart. Symptôme trompeur : Caddy crie « Cloudflare API: 401 unauthorized » alors que le token fonctionne parfaitement en curl.

7. Caddyfile

{
    email vous@exemple.com
    auto_https disable_redirects
}

(dev_app) {
    encode gzip zstd
    reverse_proxy localhost:{args[0]}
}

*.dev.exemple.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }

    @monapp1  host monapp1.dev.exemple.com
    @monapp2  host monapp2.dev.exemple.com

    handle @monapp1 {
        import dev_app 31234
    }
    handle @monapp2 {
        import dev_app 35678
    }

    handle {
        respond "Unknown dev subdomain" 404
    }
}

⚠️ Le format handle @xxx { import dev_app NNN } sur une seule ligne fait crasher Caddy. Toujours sur trois lignes minimum. Ce n'est documenté nulle part.

8. ACL POSIX (WSL2 / Linux multi-user)

Si le Caddyfile est dans votre home (typiquement ~/projects/.dev-proxy/Caddyfile), le user système caddy lancé par systemd ne peut pas le lire, /home/<user> étant en 750. Solution chirurgicale, sans relâcher les permissions générales :

sudo setfacl -m u:caddy:x  /home/<user>
sudo setfacl -m u:caddy:x  /home/<user>/projects
sudo setfacl -m u:caddy:rx /home/<user>/projects/.dev-proxy
sudo setfacl -m u:caddy:r  /home/<user>/projects/.dev-proxy/Caddyfile
sudo setfacl -d -m u:caddy:r /home/<user>/projects/.dev-proxy  # ACL par défaut pour futurs fichiers

Bien plus propre qu'un chmod 755 /home/<user> qui exposerait le home entier.

9. Lancer et tester

sudo systemctl restart caddy
sudo journalctl -u caddy -f

Au premier accès à https://monapp1.dev.exemple.com, Caddy demande le cert wildcard :

"trying to solve challenge","identifier":"*.dev.exemple.com","challenge_type":"dns-01"
"certificate obtained successfully","issuer":"acme-v02.api.letsencrypt.org-directory"

Une quinzaine de secondes. Le cert couvre désormais tous les sous-domaines *.dev.exemple.com, présents et futurs.

10. Côté projets

Trois modifs minimales par projet. package.json, pour fixer le port déterministe :

"dev": "next dev -p 31234"

.env, pour passer les URLs sur le domaine dev :

BETTER_AUTH_URL=https://monapp1.dev.exemple.com
NEXT_PUBLIC_APP_URL=https://monapp1.dev.exemple.com

next.config.ts, pour autoriser l'origine en dev (sinon HMR WebSocket refusé) :

const nextConfig: NextConfig = {
  allowedDevOrigins: ['monapp1.dev.exemple.com'],
}

Pour Vite/TanStack Start : server.allowedHosts: ['monapp1.dev.exemple.com'] dans vite.config.ts.

11. OAuth Google (si concerné)

Dans Google Cloud Console, sur chaque client OAuth : Authorized JavaScript originshttps://monapp1.dev.exemple.com, Authorized redirect URIshttps://monapp1.dev.exemple.com/api/auth/callback/google. Gardez les anciennes URLs http://localhost:XXXX/... en parallèle pendant la transition, pour pouvoir rollback.

L'automatisation : une skill Claude Code

Onze étapes par projet, c'est trop pour une procédure répétée à chaque nouveau repo. Le tout est encodé dans une skill Claude Code (add-dev-subdomain) qui orchestre :

  • Audit auto du projet : framework (Next.js / TanStack / Vite / monorepo Turbo), env files (avec résolution des symlinks .vscode/.env.local), présence d'OAuth Google
  • Port déterministe : sha256(nom) % 9900 + 30100 avec résolution de collision contre un ports.json versionné
  • Modifications atomiques : package.json, .env*, config framework, Caddyfile, ports.json
  • Reload Caddy + test de validation (dig + curl)
  • Récap final avec l'URL exacte à ajouter dans Google Console si OAuth détecté

Pattern hybride agent IA orchestrateur + script Python helper pour les opérations déterministes (calcul de port, parsing JSON robuste).

Vous : "ajoute le projet monapp3 au dev"
Claude : [audit → port 38291 généré → 6 fichiers modifiés → Caddy reload → tests OK]
Action manuelle restante : ajouter
          https://monapp3.dev.exemple.com/api/auth/callback/google
          dans Google Cloud Console
Pour démarrer : cd ~/projects/monapp3 && bun dev

Ajouter un projet passe de dix minutes, avec les oublis classiques du type allowedDevOrigins manquant, à trente secondes avec validation automatique.

Ce qu'il faut retenir

La best practice DNS migration 2026 (No-IP, ZoneWatcher) :

inventaire → baisser TTLs 48-72h avant → ajouter les records chez le nouveau provider
→ vérifier avec dig → switch des nameservers → garder l'ancien zone live ≥ 1 semaine
PUIS cleanup

Le « garder ≥ 1 semaine » est un filet de sécurité borné, pas éternel. Une fois la migration stable, supprimer les records archivés chez l'ancien DNS host évite la dérive et la confusion future.

Ne jamais coller un token API dans un chat IA. Évidence facile à oublier sous pression : un token qui apparaît dans une conversation est potentiellement loggé par les systèmes intermédiaires. Procédure post-incident : révoquer immédiatement, en créer un nouveau, l'éditer directement dans le fichier systemd avec sudo systemctl edit caddy.

Cloudflare via l'UI vs IaC. La best practice 2026 enterprise serait du Terraform Cloudflare provider (ou OctoDNS, que Cloudflare utilise en interne — détails), avec dashboard en read-only, single source of truth dans Git, terraform plan sur PR. Overkill pour un setup solo avec un seul domaine, pertinent dès qu'il y a une équipe et plusieurs zones.

lvh.me reste le bon choix pour le scénario 5 minutes, si vous n'avez pas de domaine : wildcard DNS public vers 127.0.0.1, TLD .me accepté par Google OAuth, rien à configurer. Au prix d'un cert Local CA à truster manuellement et d'une dépendance à un service tiers gratuit.

Le résultat

  • Plus de EADDRINUSE : chaque projet a un port stable, opaque, généré déterministiquement
  • Cadenas vert natif dans tous les navigateurs, aucune installation de root cert nulle part
  • OAuth providers (Google, GitHub, Stripe) acceptent les URLs *.dev.exemple.com parce que TLD public et cert valide
  • Cookies Secure / SameSite=None se comportent comme en prod
  • Mobile testing direct depuis l'iPhone sur le LAN, sans installer de CA sur iOS
  • Renouvellement de cert automatique tous les 60 jours
  • Une commande pour ajouter un nouveau projet

Le tout pour 0 € de plus : plan Free Cloudflare, Let's Encrypt gratuit, Caddy open source. Trois mois après la migration, aucun cert expiré, aucun projet planté à cause du proxy, aucun OAuth invalidé.

Sources

PartagerLinkedInXBluesky

Articles similaires