
HTTPS local en 2026 : un seul certificat Let's Encrypt pour tous mes projets dev
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
.comou.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.1sur 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.com → Add 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 origins → https://monapp1.dev.exemple.com, Authorized redirect URIs → https://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 + 30100avec résolution de collision contre unports.jsonversionné - 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.comparce que TLD public et cert valide - Cookies
Secure/SameSite=Nonese 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
- Cloudflare DNS provider Caddy module
- How We Solved Local Subdomain Development with lvh.me - GreenRobot, mars 2026
- DNS Migration Best Practices - No-IP
- How to Migrate DNS Providers Without Downtime - ZoneWatcher, mars 2026
- The OAuth Tunnel Trap - DEV.to, 2026
- How Cloudflare uses Terraform to manage Cloudflare
- Caddy DNS providers - caddy-dns GitHub org (96 providers)
- Every AI Coding CLI in 2026 - DEV Community
Articles similaires
Mon VPS comme atelier de dev nomade : faire tourner mes dev servers et les ouvrir au mobile en HTTPS valide (sans nouveau certificat)
dev · infra · vps
MCP chrome-devtools depuis WSL : piloter (et auto-lancer) une Chrome Windows
claude-code · mcp · wsl
Claude Code Remote Control : reprendre ses sessions WSL depuis le téléphone
claude-code · ia · productivite