Mon VPS comme atelier de dev nomade : faire tourner mes dev servers et les ouvrir au mobile en HTTPS valide (sans nouveau certificat)

Mon VPS comme atelier de dev nomade : faire tourner mes dev servers et les ouvrir au mobile en HTTPS valide (sans nouveau certificat)

·16 min de lecture·Mis à jour le 30 mai 2026
Pour qui

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, c'est bien ; faire tourner l'app, c'est mieux

Il y a quelques jours j'ai documenté comment reprendre mes sessions Claude Code sur mobile avec remote-control. Le téléphone devient une fenêtre propre sur la session, le calcul reste sur la machine. Génial pour coder.

Sauf qu'à un moment, coder ne suffit pas : je veux voir l'app tourner. Le rendu mobile d'une landing, un parcours d'inscription, un responsive douteux sur une vue précise. Et là, le dev server est sur ma machine, pas sur le téléphone.

Première bascule logique : ne plus faire tourner le dev server sur mon WSL (que Windows met agressivement en pause), mais sur mon VPS, toujours allumé. Le même VPS Lite à ~3 €/mois qui héberge déjà mon tunnel SFTP famille et mon Headscale auto-hébergé. Cloner le repo, bun install, bun dev, et hop.

Le « et hop » a pris une demi-journée.

Le décor : deux patterns de certs qui ne se parlaient pas

Avant de raconter les murs, il faut poser le décor, parce que c'est tout le sujet de l'article. À ce moment-là, j'avais déjà deux manières de faire du HTTPS propre, montées dans deux articles précédents :

PatternPour quoiCertificat
Caddy wildcardmachine locale*.dev.exemple.com → 127.0.0.1, un sous-domaine par projetwildcard LE, via Cloudflare DNS-01
Traefik / Pangolinle VPSexposer des services (NAS, dashboards…)wildcard LE *.exemple.com

Deux solutions solides. Le problème : « dev server sur le VPS, vu sur mobile » ne rentrait dans aucune des deux. Le pattern local sert du loopback (inutile à distance), et je n'avais jamais branché un dev server sur le Traefik du VPS. Résultat, mon premier réflexe a été de bricoler un troisième chemin. Mauvais réflexe, on va voir pourquoi.

Étape 1 : Claude Code sur le VPS

Rien d'original ici, j'ai juste rejoué l'article remote-control côté serveur. Deux points de friction valent le rappel :

  • Le piège DISABLE_TELEMETRY : il était dans le settings.json du VPS aussi. Tant qu'il y est, Remote Control reste gated (le client n'interroge plus les feature flags). Retiré → la commande remonte.
  • Persistance : tmux pour ne pas bloquer un terminal. (On verra plus bas qu'on peut faire mieux avec systemd.)

Une fois claude remote-control actif dans un tmux, le téléphone reprend la session. Le VPS étant toujours allumé, fini la pause WSL. Bien. Maintenant, l'app.

Étape 2 : le dev server tourne, et c'est là que ça se gâte

Le projet est un TanStack Start (Vite), port fixe, qui tape sur une base Postgres distante. bun install, je lance le dev server, il écoute en 127.0.0.1. curl local : HTTP 200. Parfait côté serveur.

Côté téléphone, maintenant. Le tél est déjà membre de mon tailnet (Headscale), donc il peut joindre le VPS sur son IP Tailscale. Je 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.) J'ajoute le host à allowedHosts dans vite.config.ts, et j'ouvre http://vps.tail.exemple.com:35421 sur le téléphone.

ERR_SSL_PROTOCOL_ERROR

Les fausses bonnes idées (le mur HTTP/HTTPS)

❌ Servir en HTTP nu et compter sur Chrome pour être conciliant

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), c'est juste que ce n'est pas du TLS.

Et même si on contourne : un dev server en HTTP, ça veut dire 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)

Le réflexe Tailscale, c'est tailscale serve : ça expose un port local en HTTPS sur le tailnet, avec un cert auto-provisionné. Sur le Tailscale SaaS. Chez moi c'est Headscale auto-hébergé, et là :

$ 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

C'est documenté côté amont : 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, c'est : terminez le TLS dans un reverse proxy. Tiens donc.

⚠️ Le hack qui marche : l'IP littérale

En attendant de réfléchir, un contournement immédiat : 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. L'app s'affiche. Mais : triangle « non sécurisé », pas de cert, pas d'auth, et une URL franchement moche. C'est du dépannage, pas une solution.

Le réflexe à ne pas avoir

À ce stade j'étais parti pour scripter un troisième chemin maison (binding tailnet + bidouille Chrome). Erreur. 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 que j'avais déjà

La reco Headscale (« terminez le TLS dans un reverse proxy ») m'a fait tilter. J'ai déjà un reverse proxy qui termine le TLS sur ce VPS. C'est exactement 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. Il est donc local à Traefik. Je n'ai même pas besoin de Newt (le tunnel sortant, c'était pour joindre un service distant comme le NAS). Il me faut juste :

  1. un record DNS pour le sous-domaine,
  2. une route Traefik vers le dev server,
  3. et le cert wildcard fait le reste, gratuitement.

C'est ça, le vrai sujet : je n'avais pas un problème de certificat, j'avais un problème de plomberie entre deux briques que je possédais déjà.

La solution

1. Le record DNS : le combo « cert public + accès privé »

L'astuce qui rend tout ça élégant : je pointe 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. Donc le nom résout publiquement, le cert wildcard public le couvre (cadenas vert, zéro warning), mais seul un appareil sur le tailnet peut effectivement 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 parfaitement bénin (elle ne mène nulle part hors du tailnet).

Pourquoi pas *.dev.exemple.com ?

Mon 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

C'est ici que j'ai failli m'arracher les cheveux. J'ajoute une route file-provider :

# 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 dans ma stack, 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). Donc la cible correcte, c'est l'IP Tailscale, pas 127.0.0.1 :

- 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, souvenir cuisant) bloquerait de toute façon le port 35421 en entrée.

Le file provider de Traefik hot-reload : pas de restart de conteneur. curl de 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. Le wildcard couvre le sous-domaine. Sur le téléphone : cadenas vert, l'app charge, l'auth peut enfin poser ses cookies Secure.

3. Le token Cloudflare ne quitte jamais le VPS

Pour créer le record DNS, j'ai un token API Cloudflare… mais il est déjà sur le VPS (celui qu'utilise Traefik pour le DNS-01), en chmod 600 root. Je l'utilise sur place via sudo, dans un petit script qui lit le fichier et tape l'API. Le secret ne transite jamais par le chat avec l'assistant, ni par mon historique shell. (J'y reviens dans les leçons — c'est la troisième fois que ce réflexe me sert.)

Persistance : systemd plutôt que tmux

tmux c'est parfait pour de l'interactif, mais pour un dev server qui doit rester debout (et survivre à un reboot du VPS), la bonne brique c'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) — détail qui compte si on généralise.

tmux sous systemd, le bon type

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 m'ont coûté du temps

Le « restart interne » de Vite casse allowedHosts (403 fantôme)

À un moment, l'URL qui marchait s'est mise à renvoyer 403 « This host is not allowed »… alors que vite.config.ts listait bien le host. Les logs :

vite.config.ts changed, restarting server...
server restarted.

Un changement de vite.config.ts (déclenché par une autre modif pendant que le serveur tournait) provoque un restart interne de Vite qui ne ré-applique pas proprement server.allowedHosts. Le process garde le même PID, mais sert l'ancienne politique de host. Fix : tuer et relancer le process (un vrai redémarrage, pas le restart interne). Vingt minutes à fixer un fichier déjà correct.

Chrome force le HTTPS — sauf sur une IP littérale

Le comportement déjà décrit, mais c'est le genre de truc à garder en tête : 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.

La deploy key read-only qui bloque le push… en silence

J'avais ajouté une deploy key en lecture seule pour cloner sur le VPS. Quelques commits plus tard (faits depuis la session mobile), git push :

ERROR: The key you are authenticating with has been marked as read only.

Les commits étaient là, le push échouait, et comme je ne regardais pas, je croyais mon travail synchronisé. Leçon de moindre privilège et de vigilance : pour un serveur, une deploy key write scoppée au repo (ou une GitHub App) est le bon niveau ; et il faut vérifier que le push passe vraiment, pas supposer.

fish n'a pas de heredoc

Détail bête mais qui m'a fait perdre plusieurs essais : mon shell local est fish, qui ne supporte pas les heredocs <<. Toutes mes commandes ssh vps 'cat <<EOF ...' plantaient. 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 (les enrober côté serveur).

L'automatisation : une skill Claude Code

Refaire ces étapes à la main à chaque projet, non. Comme pour le local (où j'ai la skill add-dev-subdomain), j'ai encodé le pendant VPS 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 (jamais DATABASE_URL) + allowedHosts/allowedDevOrigins.
  • Service systemd --user persistant, flags d'écoute selon le framework.
  • Registry des resources VPS + curl de validation.

Même philosophie que l'article local, côté serveur. 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

ApprocheHTTPS valideLogin/cookies SecureSurfaceRéutilise l'existant
HTTP nu + IP Tailscaletailnet❌ (3ᵉ pattern)
Hack IP littéraletailnet
tailscale serve --httpstailnetimpossible sous Headscale
Traefik wildcard + DNS→CGNAT (privé)tailnet
Traefik + Pangolin + SSO (public)public + auth

Les leçons

Consolide tes patterns au lieu d'en empiler

J'avais deux solutions de certs et j'allais en bricoler une troisième. La vraie compétence d'infra en 2026, ce n'est pas connaître un outil de plus, c'est reconnaître que la brique manquante existe déjà et la brancher. Mon Traefik wildcard tournait depuis des semaines pour le NAS ; il ne demandait qu'une route.

Un secret ne transite jamais dans une conversation avec un LLM

Troisième article d'affilée où ce réflexe revient, parce qu'il est facile à oublier sous pression. Le token Cloudflare est resté sur le VPS, lu en sudo, utilisé sur place. L'assistant guide les commandes ; la machine garde ses secrets. Idem pour les DATABASE_URL, les clés SSH, tout ce qui finit en .env.

Teste depuis un autre contexte, et regarde les logs avant de théoriser

Le 403 Vite, je l'ai diagnostiqué en lisant les logs (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, pas dans une hypothèse.

Le résultat

  • Mes dev servers tournent sur le VPS, persistants (systemd + linger), accessibles du téléphone en HTTPS valide : https://monapp-dev.exemple.com, cadenas vert, login fonctionnel.
  • Accès privé (tailnet-only via l'IP CGNAT), zéro nouveau certificat, zéro nouvel outil : juste une route et un record sur l'infra que j'avais déjà.
  • Une skill pour refaire tout ça en une phrase au prochain projet.

Trois articles plus tard, mon VPS est devenu un vrai atelier : il héberge mon VPN, expose mon NAS à la famille, fait tourner mes agents et sert mes dev servers. Et le téléphone, dans le RER, devient une fenêtre HTTPS propre sur tout ça. Je crois que c'est ça que je cherchais depuis le début.

Sources

PartagerLinkedInXBluesky

Articles similaires