
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.
Vendredi soir, énième EADDRINUSE
$ bun dev
error: bind EADDRINUSE 0.0.0.0:3000
Encore. Troisième de la journée. Je tue un autre projet pour libérer le port, je relance, ça repart, et là OAuth Google me sort une erreur jamais vue :
Origine non valide : l'URI doit se terminer par une extension de domaine public de premier niveau, telle que
.comou.org.
J'avais ajouté https://monapp.localhost/api/auth/callback/google comme redirect URI. Par optimisme. Google n'accepte plus. Pas en 2026.
22 projets dans ~/projects/. 7 ports actifs en parallèle. Cookies Secure qui ne se posent pas en HTTP. Et la corvée d'une dictée de credentials Google différents à chaque clone. Une heure plus tard, je me dis qu'il y a forcément mieux.
Il y a mieux. Beaucoup mieux. Et c'est plus propre que tout ce que j'avais bidouillé avant.
Les fausses bonnes idées (que j'ai éliminées)
❌ Plan de ports 30xxx à la main
Mnémotechnique au début. Dette technique après six mois. lsof -i :30130 me rappelle à quoi correspond le numéro, mais je l'oublie chaque fois que je reviens sur un projet après deux semaines. Et ça règle ni le HTTPS, ni les cookies, ni le mobile testing.
❌ *.localhost derrière un reverse proxy
Sur le papier : https://monapp.localhost. Joli. En pratique : 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. 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 vous n'avez pas retiré la redirect URI chez le provider. Le free tier ngrok rotate les sous-domaines. Donc à fuir pour de l'OAuth.
⚠️ 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. Accepté par Google puisque .me est un vrai TLD. 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 (faut qu'il truste votre CA)
- Dépendance à un domaine qu'un inconnu sympathique maintient depuis dix ans (merci à lui d'ailleurs)
Bref, c'est le bon compromis "5 minutes". C'est pas le bon choix "j'ai du temps et un domaine".
La stack que j'ai gardée
[Chrome]
│
├─ DNS query : monprojet.dev.exemple.com
│ → Cloudflare DNS répond : 127.0.0.1
│
├─ HTTPS handshake
│ → cert Let's Encrypt wildcard *.dev.exemple.com
│ → trusté nativement (chaîne ISRG Root X1), zéro warning
│
└─ HTTP traffic (loopback)
→ Caddy local :443
→ reverse_proxy localhost:<port>
→ Next.js / TanStack Start
Les composants :
- Wildcard DNS
*.dev.exemple.com → 127.0.0.1sur votre domaine, hébergé par Cloudflare (free plan, ça suffit largement) - 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, opaques. On 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 faire le challenge HTTP-01 classique. Donc Caddy prouve qu'il contrôle le domaine en posant temporairement un TXT record _acme-challenge.dev.exemple.com via l'API Cloudflare. Une fois Let's Encrypt content, le record dégage. Caddy renouvelle tout seul tous les 60 jours. Vous touchez plus à rien.
Pourquoi pas juste rester chez son registrar
Mon domaine était chez Squarespace (héritage du rachat Google Domains en 2023). Premier réflexe : ajouter le wildcard A record *.dev.exemple.com → 127.0.0.1 chez Squarespace, garder leurs nameservers, lancer Caddy avec un challenge DNS-01 chez eux.
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. Aucun Certbot plugin Squarespace non plus. Sans API DNS automatisable, pas de challenge DNS-01, pas de cert wildcard auto-renouvelé.
La nuance qui m'a fait clic-clic : un domaine a un registrar (qui vous le vend, gère la propriété ICANN, le renouvelle chaque année) et un DNS host (qui répond aux requêtes DNS). Deux services indépendants. Vous pouvez garder Squarespace comme registrar (continuité, pas de transfert, facture inchangée) et déléguer uniquement la résolution DNS à Cloudflare via les NS records. Pratique standard depuis ~2020.
Coût supplémentaire : 0 €. Cloudflare DNS est gratuit en plan Free, illimité en records.
Setup pas-à-pas
1. Snapshot DNS avant migration
Avant tout, audit complet de votre zone DNS. Vous en aurez besoin pour la recréer chez le nouveau DNS host et pour un éventuel rollback (croyez-moi, vous voulez ce filet).
# Inventaire des records visibles
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 quand vous ajoutez votre domaine ne trouve que les sous-domaines courants (www, mail, api, etc.). Tous vos sous-domaines custom (envs de prod, staging...) doivent être ajoutés à la main. Ouvrez l'interface admin de votre registrar et listez tout exhaustivement avant de switcher. J'ai zappé un staging.exemple.com la première fois, j'ai mis trois jours à comprendre pourquoi un dev se plaignait que son env tombait en 404.
2. Cloudflare : ajouter le domaine
dash.cloudflare.com → Add a Site → entrer le domaine → plan Free.
Cloudflare scanne, vous propose les records détectés. Comparez avec votre snapshot. 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 Vercel. J'ai testé, c'est moche.
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 (ça résout server-side), donc même @ CNAME cname.vercel-dns.com fonctionne. Beaucoup plus pérenne qu'une IP fixe Vercel legacy.
3. Switch des nameservers chez le registrar
Cloudflare vous donne deux NS (genre xxx.ns.cloudflare.com et yyy.ns.cloudflare.com). Chez votre registrar, section nameservers personnalisés, remplacez les NS existants par les deux NS Cloudflare.
⚠️ Copier-coller, jamais à la main. Une lettre près et la migration n'aboutit jamais. J'ai brûlé une bonne heure sur un josh au lieu de jose. Ça fait mal.
⚠️ Avant de switcher : si DNSSEC est activé chez le registrar, désactivez-le. Vous pourrez le réactiver après depuis Cloudflare.
Propagation : 5 minutes à 24h selon le TLD. Pour .io, comptez 30 min à 2h en pratique. Vérif :
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
Une fois la propagation faite, ajoutez le wildcard A record. Soit via l'UI Cloudflare, soit en une commande avec votre 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 (pas de proxy Cloudflare).
5. Caddy avec plugin Cloudflare
Caddy standard ne sait pas faire de challenge DNS-01 Cloudflare. 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érif : caddy list-modules | grep cloudflare doit afficher dns.providers.cloudflare. Si rien, le build a foiré silencieusement (parfois xcaddy ne dit pas quand un plugin a 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), injectez-le dans systemd Caddy :
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. Piège qui m'a coûté quelques minutes à m'arracher les cheveux en regardant les logs Caddy crier "Cloudflare API: 401 unauthorized" alors que le token marchait quand je le testais 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. J'ai mis vingt minutes à trouver ce piège la première fois. C'est documenté nulle part, c'est juste comme ça.
8. ACL POSIX (WSL2 / Linux multi-user)
Si vous mettez votre Caddyfile dans votre home (typique : ~/projects/.dev-proxy/Caddyfile), le user système caddy (lancé par systemd) ne pourra pas le lire : /home/<user> est 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
Beaucoup plus propre que chmod 755 /home/<user> qui exposerait votre home à tout le monde.
9. Lancer + tester
sudo systemctl restart caddy
sudo journalctl -u caddy -f
Au premier accès à https://monapp1.dev.exemple.com, Caddy demande le cert wildcard à Let's Encrypt :
"trying to solve challenge","identifier":"*.dev.exemple.com","challenge_type":"dns-01"
"certificate obtained successfully","issuer":"acme-v02.api.letsencrypt.org-directory"
13 secondes chez moi. Le cert couvre désormais tous les sous-domaines *.dev.exemple.com, présents et futurs.
10. Côté projets
Pour chaque projet, trois modifs minimales.
package.json : fixer le port (déterministe, généré par hash) :
"dev": "next dev -p 31234"
.env : 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 : 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. Vous serez content de pouvoir rollback si quelque chose pète.
La couche d'automatisation : une skill Claude Code
Onze étapes pour ajouter un projet. Trop pour une procédure que je vais répéter à chaque nouveau repo. J'ai encodé tout ça 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). C'est ce que les sources 2026 décrivent comme la best practice pour ce genre de workflow (DEV Community, 2026).
Une session typique :
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 risques d'oubli, du genre "ah j'ai oublié allowedDevOrigins, c'est pour ça que HMR rame") à trente secondes avec validation automatique.
Ce que j'ai compris en route
La best practice DNS migration 2026
Le consensus des guides 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" n'est pas "garder éternellement". C'est un filet de sécurité borné. Une fois la migration stable, supprimer les records archivés chez l'ancien DNS host évite la dérive et la confusion future ("attends, mes records sont à jour chez qui déjà ?").
Ne jamais coller un token API dans un chat IA
Évidence évidente, mais piège facile sous pression. Quand votre token apparaît dans une conversation, il 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 (sans copier-coller dans le chat).
Pour la deuxième fois, on le sait. La première, ça pique.
Cloudflare propre vs Cloudflare puriste
J'ai laissé mes records configurés dans l'interface web Cloudflare. La best practice 2026 enterprise serait du IaC : Terraform Cloudflare provider (ou OctoDNS, que Cloudflare utilise en interne pour ses propres DNS - détails), avec :
- Cloudflare Dashboard en mode read-only (toggle dans Settings)
- Single source of truth dans Git
terraform plansur PR,terraform applysur merge
Overkill pour un setup solo avec un seul domaine et des modifs rares. Mais 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, ou si vous voulez juste tester rapidement, lvh.me (ou son cousin localtest.me) fait exactement le job en cinq minutes :
- Wildcard DNS public, tous les sous-domaines résolvent vers
127.0.0.1 - TLD
.meaccepté par Google OAuth - Pas de DNS à configurer
Seuls inconvénients : cert Caddy Local CA à truster manuellement, dépendance à un service tiers gratuit qui pourrait disparaître un jour.
Le résultat, concrètement
- 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 quelque 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 (ce qui est un cauchemar pour la sécurité)
- Renouvellement de cert automatique tous les 60 jours, j'oublie que ça existe
- Une commande pour ajouter un nouveau projet :
"ajoute le projet X au dev"
Le tout pour 0 € de plus que mon setup d'avant. Free plan Cloudflare suffit largement, Let's Encrypt est gratuit, Caddy est open source.
Trois mois après la migration, je n'ai touché à rien. Aucun cert n'a expiré sans rien me dire. Aucun projet n'a planté à cause du proxy. Aucun OAuth qui se réveille un matin pour me dire que mon localhost:3847 est invalide. Je crois que c'est ça, le luxe.
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