
Mon VPS comme atelier de dev nomade : faire tourner mes dev servers et les ouvrir au mobile en HTTPS valide (sans nouveau certificat)
Vous avez un VPS (le même qui héberge déjà un reverse proxy type Traefik/Caddy/Pangolin), vous bossez avec un agent CLI (Claude Code, opencode…) et vous voulez faire tourner vos dev servers sur le serveur pour les piloter et les visualiser depuis n'importe quel appareil, mobile compris, en HTTPS valide. Niveau requis : à l'aise avec SSH, Docker, DNS, systemd.
Le problème
Reprendre une session Claude Code sur mobile règle le fait de coder à distance. Mais à un moment il faut voir l'app tourner : le rendu mobile d'une landing, un parcours d'inscription, un responsive douteux. Or le dev server est sur la machine locale.
Première bascule logique : faire tourner le dev server non plus sur WSL (que Windows met agressivement en pause), mais sur le VPS, toujours allumé. Le même VPS Lite à ~3 €/mois qui héberge déjà le tunnel SFTP famille et Headscale auto-hébergé.
Le décor à ce moment-là : deux patterns de certs déjà en place, dont aucun ne couvrait le cas.
| Pattern | Où | Pour quoi | Certificat |
|---|---|---|---|
| Caddy wildcard | machine locale | *.dev.exemple.com → 127.0.0.1, un sous-domaine par projet | wildcard LE, via Cloudflare DNS-01 |
| Traefik / Pangolin | le VPS | exposer des services (NAS, dashboards…) | wildcard LE *.exemple.com |
Le pattern local sert du loopback, inutile à distance ; et le Traefik du VPS n'avait jamais servi un dev server. Le premier réflexe — bricoler un troisième chemin — était le mauvais.
Étape 1 : Claude Code sur le VPS
Rejeu de l'article remote-control côté serveur. Deux points de friction :
- Le piège
DISABLE_TELEMETRY: présent aussi dans lesettings.jsondu VPS. Tant qu'il y est, Remote Control reste gated (le client n'interroge plus les feature flags). Retiré → la commande remonte. - Persistance :
tmuxpour ne pas bloquer un terminal (on verra plus bas qu'on fait mieux avec systemd).
Étape 2 : le dev server, et le mur HTTPS
Le projet est un TanStack Start (Vite), port fixe, base Postgres distante. Le dev server écoute en 127.0.0.1, curl local renvoie HTTP 200.
Côté téléphone (déjà membre du tailnet Headscale), on binde le dev server sur l'IP Tailscale du VPS :
vite dev --port 35421 --host 100.64.0.1
(100.64.0.1 = l'IP du VPS sur le tailnet, dans la plage CGNAT 100.64.0.0/10.) Host ajouté à allowedHosts dans vite.config.ts, puis ouverture de http://vps.tail.exemple.com:35421 sur le téléphone :
ERR_SSL_PROTOCOL_ERROR
❌ Servir en HTTP nu
Chrome Android force le HTTPS (« Always use secure connections »). Il tente une poignée de main TLS sur le port 35421, qui ne parle que HTTP, et meurt en ERR_SSL_PROTOCOL_ERROR. Pas de fallback : le port répond, donc ce n'est pas un « connection refused » qui déclencherait le repli HTTP.
Et même en contournant : un dev server en HTTP signifie cookies Secure cassés → l'auth (better-auth, OAuth) ne se pose pas. Pour visualiser, OK ; pour tester un vrai parcours, non.
❌ tailscale serve --https (parce que Headscale)
$ sudo tailscale serve --https=443 http://127.0.0.1:35421
error enabling https feature: error 404 Not Found
$ sudo tailscale cert vps.tail.exemple.com
500 Internal Server Error: your Tailscale account does not support getting TLS certs
tailscale cert / tailscale serve en HTTPS nécessitent que le control plane sache provisionner des certs (créer les TXT ACME pour la zone), ce que Headscale ne fait pas encore (issue #2527). La reco officielle Headscale dans ce cas : terminer le TLS dans un reverse proxy.
⚠️ Le hack qui marche : l'IP littérale
Chrome ne force PAS le HTTPS sur une IP littérale, et Vite exempte les hosts en IP de la vérification allowedHosts. Donc http://100.64.0.1:35421 s'ouvre sur le téléphone et l'app s'affiche — mais avec le triangle « non sécurisé », pas de cert, pas d'auth. C'est du dépannage.
Quand on bute, la bonne question n'est pas « quel nouvel outil ? » mais « qu'est-ce que j'ai déjà qui résout ça ? ».
Le déclic : réutiliser le wildcard déjà en place
« Terminer le TLS dans un reverse proxy » — il y en a déjà un sur ce VPS. C'est la stack de l'article SFTP : Traefik v3 derrière Pangolin, avec un cert Let's Encrypt wildcard *.exemple.com (challenge DNS-01 Cloudflare), qui tourne 24/7 et renouvelle tout seul.
Le dev server tourne sur le VPS, donc local à Traefik. Pas besoin de Newt (le tunnel sortant servait à joindre un service distant comme le NAS). Il faut juste un record DNS et une route Traefik ; le cert wildcard fait le reste.
La solution
1. Le record DNS : le combo « cert public + accès privé »
L'astuce qui rend tout ça élégant : pointer le sous-domaine vers l'IP Tailscale du VPS, pas vers son IP publique.
# A record DNS only (grey cloud)
monapp-dev.exemple.com → 100.64.0.1
100.64.0.1 est en CGNAT (100.64.0.0/10), non routable depuis l'Internet public. Le nom résout publiquement, le cert wildcard public le couvre (cadenas vert, zéro warning), mais seul un appareil sur le tailnet peut atteindre l'IP. Cert public valide + accès strictement privé, sans gate supplémentaire : c'est le tailnet qui filtre. Publier une IP CGNAT en DNS public est inhabituel mais bénin — elle ne mène nulle part hors du tailnet.
Le wildcard local, c'est *.dev.exemple.com → 127.0.0.1. Inutilisable ici (loopback) et le wildcard *.exemple.com du VPS ne couvre PAS un sous-domaine à deux niveaux (x.y.exemple.com). Donc côté VPS, on reste en mono-niveau : monapp-dev.exemple.com. Convention -dev pour ne pas collisionner avec les vrais sous-domaines de services.
2. La route Traefik — et le piège du conteneur
# dynamic_config.yml
http:
routers:
monapp-dev:
rule: 'Host(`monapp-dev.exemple.com`)'
entryPoints: [websecure]
service: monapp-dev-svc
tls:
certResolver: letsencrypt
domains:
- main: exemple.com
sans: ['*.exemple.com']
services:
monapp-dev-svc:
loadBalancer:
servers:
- url: 'http://127.0.0.1:35421' # ← FAUX
http://127.0.0.1:35421 semble évident. Sauf que Traefik tourne en network_mode: service:gerbil : il partage la pile réseau du conteneur WireGuard. Son 127.0.0.1, c'est le loopback du conteneur, pas celui de l'hôte. Le dev server, lui, écoute sur l'hôte. La route pointe dans le vide.
La vérification qui débloque :
# Depuis le conteneur Traefik, l'hôte est-il joignable sur l'IP Tailscale ?
docker exec traefik sh -c "wget -q -O /dev/null -T4 http://100.64.0.1:35421/ && echo REACHABLE"
# → REACHABLE
Le conteneur atteint l'hôte sur 100.64.0.1 (l'interface tailscale0 de l'hôte). La cible correcte est donc l'IP Tailscale :
- url: 'http://100.64.0.1:35421' # ✅
Bonus : le dev server reste bindé sur la seule IP Tailscale (jamais sur 0.0.0.0), donc rien d'exposé publiquement — et le firewall provider du VPS, qui ne laisse passer que 80/443/22/2022, bloquerait de toute façon le port 35421 en entrée.
Le file provider de Traefik hot-reload, pas de restart de conteneur. Validation :
curl -sS -o /dev/null -w "HTTP %{http_code} TLS=%{ssl_verify_result}\n" \
--resolve monapp-dev.exemple.com:443:100.64.0.1 \
https://monapp-dev.exemple.com/
# → HTTP 200 TLS=0
TLS=0 = chaîne validée. Sur le téléphone : cadenas vert, l'app charge, l'auth peut poser ses cookies Secure.
3. Le token Cloudflare ne quitte jamais le VPS
Le token API Cloudflare nécessaire au record DNS est déjà sur le VPS (celui qu'utilise Traefik pour le DNS-01), en chmod 600 root. On l'utilise sur place via sudo, dans un script qui lit le fichier et tape l'API. Le secret ne transite jamais par le chat avec l'assistant, ni par l'historique shell.
Persistance : systemd plutôt que tmux
Pour un dev server qui doit rester debout et survivre à un reboot, la bonne brique est un service systemd --user avec lingering :
# ~/.config/systemd/user/monapp-dev.service
[Service]
Type=simple
WorkingDirectory=/home/debian/projects/mon-app
ExecStart=/home/debian/.bun/bin/bun run dev -- --port 35421 --host 100.64.0.1
Restart=on-failure
RestartSec=5
sudo loginctl enable-linger debian # les services user tournent sans session
systemctl --user enable --now monapp-dev.service
Si le dev server démarre avant que tailscale0 soit up (au boot), le bind échoue et Restart=on-failure réessaie. Pour Next.js, les flags d'écoute changent (-p / -H au lieu de --port / --host).
Pour les choses qui doivent rester attachables (comme claude remote-control, dont on veut pouvoir relire le QR), un service Type=oneshot + RemainAfterExit=yes qui lance tmux new-session -d -s rc "..." fait le job : systemd le considère actif après le lancement, et la session tmux reste joignable.
Les pièges qui coûtent du temps
Le « restart interne » de Vite casse allowedHosts. Une URL qui marchait se met à renvoyer 403 This host is not allowed alors que vite.config.ts liste bien le host. Les logs disent :
vite.config.ts changed, restarting server...
server restarted.
Ce restart interne ne ré-applique pas proprement server.allowedHosts : le process garde le même PID et sert l'ancienne politique de host. Fix : tuer et relancer le process, un vrai redémarrage.
Chrome force le HTTPS sauf sur une IP littérale. http://host.domaine:port est silencieusement upgradé en https://, http://100.64.0.x:port ne l'est pas. Utile pour un quick-look, à bannir comme solution durable.
Une deploy key read-only bloque le push en silence. Après quelques commits faits depuis la session mobile :
ERROR: The key you are authenticating with has been marked as read only.
Les commits sont là, le push échoue, et sans vérification on croit son travail synchronisé. Pour un serveur, une deploy key write scopée au repo (ou une GitHub App) est le bon niveau — et il faut vérifier que le push passe.
fish n'a pas de heredoc. Toutes les commandes ssh vps 'cat <<EOF ...' plantent. Solution : écrire le fichier en local puis scp, ou piper (ssh vps 'cat > x' < fichier). Et éviter for/if/{} dans la commande passée à ssh.
L'automatisation : une skill Claude Code
Comme pour le local (skill add-dev-subdomain), le pendant VPS est encodé dans une skill add-vps-dev-resource :
- Audit distant (SSH) du projet sur le VPS : framework (Next/TanStack/Vite), port figé dans le script
dev, fichiers.env, présence d'OAuth, monorepo. - Record DNS Cloudflare
<name>-dev.exemple.com → 100.64.0.1(token lu sur le VPS, jamais exfiltré). - Route Traefik insérée dans
dynamic_config.yml(backup auto, idempotent, hot-reload). - Patch projet : URLs applicatives dans les
.env(jamaisDATABASE_URL) +allowedHosts/allowedDevOrigins. - Service systemd
--userpersistant, flags d'écoute selon le framework. - Registry des resources VPS +
curlde validation.
Pattern hybride agent orchestrateur + helper Python déterministe (calcul de port, parsing, idempotence). Une phrase — « expose mon-app du VPS en HTTPS » — et la plomberie se fait, validation comprise.
Comparatif des approches
| Approche | HTTPS valide | Login/cookies Secure | Surface | Réutilise l'existant |
|---|---|---|---|---|
| HTTP nu + IP Tailscale | ❌ | ❌ | tailnet | ❌ (3ᵉ pattern) |
| Hack IP littérale | ❌ | ❌ | tailnet | ❌ |
tailscale serve --https | — | — | tailnet | ❌ impossible sous Headscale |
| Traefik wildcard + DNS→CGNAT (privé) | ✅ | ✅ | tailnet | ✅ |
| Traefik + Pangolin + SSO (public) | ✅ | ✅ | public + auth | ✅ |
Les leçons
Consolider ses patterns au lieu d'en empiler. Deux solutions de certs existaient déjà, et le réflexe était d'en bricoler une troisième. La compétence utile, ce n'est pas connaître un outil de plus, c'est reconnaître que la brique manquante existe déjà et la brancher.
Un secret ne transite jamais dans une conversation avec un LLM. Le token Cloudflare reste sur le VPS, lu en sudo, utilisé sur place. L'assistant guide les commandes ; la machine garde ses secrets. Idem pour les DATABASE_URL et les clés SSH.
Lire les logs avant de théoriser. Le 403 Vite se diagnostique en lisant server restarted, pas en théorisant sur le DNS. Le 127.0.0.1 du conteneur, en testant explicitement la connectivité depuis le conteneur. À chaque fois, la réponse était dans une commande de vérification à dix secondes.
Résultat : des dev servers persistants sur le VPS (systemd + linger), accessibles du téléphone en HTTPS valide sur https://monapp-dev.exemple.com, avec login fonctionnel, accès tailnet-only, zéro nouveau certificat et zéro nouvel outil — juste une route et un record sur l'infra déjà en place.
Sources
- Tailscale Serve — docs
- Headscale :
tailscale cert+servetracking (issue #2527) - Headscale : TLS / reverse proxy
- Traefik : file provider & dynamic configuration
- Your Ultimate Dev Server Setup: Tailscale, Caddy, Docker — DEV, 2026
- Tailscale VPS Setup Guide 2026
- Mes articles liés : HTTPS local wildcard · SFTP famille Pangolin/Newt/SFTPGo · Claude Code Remote Control · VPN auto-hébergé Headscale
Articles similaires
Claude Code Remote Control : reprendre ses sessions WSL depuis le téléphone
claude-code · ia · productivite
HTTPS local en 2026 : un seul certificat Let's Encrypt pour tous mes projets dev
dev · infra · caddy
Claude Code comme back-office : connecter Drive, Gmail et Trello pour piloter sa boîte
claude-code · ia · mcp