---
title: Tailscale Serve : HTTPS pour 50 applis auto-hébergées, aucune sur Internet
description: Chaque appli de mon Mac mini n’écoute que sur 127.0.0.1, et tailscale serve donne à chacune une URL HTTPS que seuls mes appareils peuvent joindre. Aucun port ouvert, pas de reverse proxy à maintenir, de vrais certificats pour le téléphone. Voici la configuration et les quatre pièges où je suis tombé.
date: 2026-10-09
tags: Tailscale, auto-hébergement, réseau, homelab, sécurité
---

Je fais tourner une cinquantaine de petites applis sur un Mac mini chez moi : tableaux de bord, PWA, API pour mes applis mobiles, un Ollama local. Aucune n’est joignable depuis Internet, et aucune n’écoute non plus sur mon réseau domestique. Chaque appli n’écoute que sur `127.0.0.1`, et **`tailscale serve`** la publie sur mon tailnet à l’adresse `https://<machine>.<tailnet>.ts.net:<port>`, avec un vrai certificat, joignable uniquement depuis mes propres appareils, par le tunnel WireGuard chiffré de Tailscale.

Une commande par appli, rien d’autre à faire tourner :

```bash
tailscale serve --bg --https 5174 http://127.0.0.1:5174
```

## Le chemin d’une requête

<figure class="fd-fig tsm" aria-labelledby="ts1-cap" data-desc="Le chemin d’une requête, dessiné en zones imbriquées. À l’extérieur, l’Internet public : personne d’autre n’a de route pour entrer, et Funnel, la fonctionnalité qui y publierait un port, est désactivé. Dedans, mon tailnet, mes propres appareils connectés à Tailscale. Un téléphone ou un portable ouvre l’URL ts.net, et la requête voyage en HTTPS dans le tunnel WireGuard jusqu’au Mac mini. Là, tailscale serve termine le HTTPS avec un certificat Let’s Encrypt et transmet la requête en HTTP simple à l’appli sur la loopback, 127.0.0.1, souvent un conteneur Docker. Le HTTP simple ne quitte jamais la machine. tailscale serve status liste 51 entrées, toutes en tailnet only.">
<div class="tsm-in">
<div class="tsm-net" role="group" aria-label="L’Internet public, mon tailnet à l’intérieur, et le Mac mini à l’intérieur de celui-ci. Une requête part de mon téléphone par le tunnel chiffré jusqu’à tailscale serve, puis en HTTP simple jusqu’à l’appli sur la loopback. Les inconnus n’ont aucune route pour entrer.">
  <p class="tsm-zone">Internet</p>
  <p class="tsm-stranger"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="6" r="3.5"/><path d="M3 18c0-4 3-6.5 7-6.5s7 2.5 7 6.5"/></svg><span>Tous les autres</span><b>aucune route d’entrée</b></p>
  <div class="tsm-tail">
    <p class="tsm-zone tsm-zone-c">Mon tailnet</p>
    <div class="tsm-row">
      <div class="tsm-dev">
        <p class="tsm-icons" aria-hidden="true"><svg viewBox="0 0 16 24"><rect x="1.5" y="1.5" width="13" height="21"/><path d="M6 19h4"/></svg><svg viewBox="0 0 32 24"><rect x="5" y="3" width="22" height="14"/><path d="M1 21h30"/></svg></p>
        <h3>Mes appareils</h3>
        <p>Ouvrent l’URL ts.net</p>
      </div>
      <div class="tsm-wire tsm-wire-c" aria-hidden="true"><span>HTTPS dans WireGuard</span><i class="tsm-pkt"></i></div>
      <div class="tsm-mini">
        <p class="tsm-zone">Mac mini</p>
        <p class="tsm-funnel">Funnel <i aria-hidden="true"></i> désactivé</p>
        <div class="tsm-box">
          <div class="tsm-serve">
            <svg viewBox="0 0 16 20" aria-hidden="true"><rect x="1.5" y="8.5" width="13" height="10"/><path d="M4.5 8.5V5.5a3.5 3.5 0 0 1 7 0v3"/></svg>
            <h3>tailscale serve</h3>
            <p>Le certificat s’arrête ici</p>
          </div>
          <div class="tsm-wire tsm-wire-p" aria-hidden="true"><span>HTTP simple</span><i class="tsm-pkt"></i></div>
          <div class="tsm-apps">
            <p class="tsm-lo">127.0.0.1</p>
            <p class="tsm-dots" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></p>
            <p><b>51</b> entrées serve, toutes en tailnet only</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</div>
<figcaption id="ts1-cap">Des zones dans des zones. La requête est chiffrée jusqu’à serve, et seule la dernière étape, à l’intérieur de la machine, passe en HTTP simple. Chaque carré est l’une des 51 entrées serve.</figcaption>
</figure>

En ce moment, `tailscale serve status` liste 51 entrées, toutes marquées *tailnet only*. Aucune n’utilise Funnel, la fonctionnalité qui publierait un port sur l’Internet public.

## Pourquoi HTTPS, alors que le tunnel est déjà chiffré

WireGuard chiffre déjà le trafic entre mes appareils, donc du HTTP simple serait assez privé. HTTPS, c’est pour le navigateur. iOS n’exécute le service worker d’une PWA que sur une origine sécurisée : sans HTTPS, mes applis mobiles perdent le mode hors ligne et l’installation sur l’écran d’accueil. Tailscale émet le certificat pour le nom `ts.net` de la machine, donc il n’y a rien à renouveler à la main.

Un détail : utilisez le nom complet. `https://<machine>:5174` échoue, parce que le certificat est émis pour le nom `ts.net`, pas pour le nom court.

<figure class="fd-fig ts-https" aria-labelledby="ts2-cap" data-desc="Pourquoi HTTPS alors que le tunnel est déjà chiffré. En HTTP simple, WireGuard chiffre quand même le trafic entre mes appareils, donc c’est assez privé, mais iOS n’exécute le service worker d’une PWA que sur une origine sécurisée : ni mode hors ligne, ni installation sur l’écran d’accueil. En HTTPS sur le nom ts.net de la machine, le service worker tourne, donc le mode hors ligne et l’installation marchent, et Tailscale émet le certificat, donc rien à renouveler à la main. Utilisez le nom complet : le nom court de la machine avec le port échoue, parce que le certificat est émis pour le nom ts.net. Le nom complet, machine point tailnet point ts.net avec le port, fonctionne.">
<div class="mp-two-row">
<div class="mp-two-card">
<p class="mp-two-who">Le tunnel, sans certificat</p>
<p class="mp-two-name">HTTP</p>
<svg class="mp-two-art" viewBox="0 0 300 120" aria-hidden="true">
<path class="ts-pipe" d="M10,60 H110 M190,60 H290"/>
<rect class="ts-phone" x="110" y="4" width="80" height="112"/>
<rect class="ts-bar" x="117" y="12" width="66" height="16"/>
<text class="mp-art-t" x="150" y="24" text-anchor="middle">http://</text>
<rect class="ts-tile" x="122" y="40" width="14" height="14"/><rect class="ts-tile" x="143" y="40" width="14" height="14"/><rect class="ts-tile" x="164" y="40" width="14" height="14"/>
<rect class="ts-tile" x="122" y="62" width="14" height="14"/><rect class="ts-tile" x="143" y="62" width="14" height="14"/>
<rect class="ts-tile-off" x="164" y="62" width="14" height="14"/>
<path class="ts-x" d="M167,65 L175,73 M175,65 L167,73"/>
<rect class="ts-home" x="140" y="102" width="20" height="4"/>
</svg>
<p class="mp-two-line">Assez privé : WireGuard le chiffre</p>
<p class="mp-two-line">iOS refuse de lancer le service worker</p>
<p class="mp-two-line">Ni hors ligne, ni installation sur l’écran d’accueil</p>
</div>
<p class="mp-two-vs" aria-hidden="true">vs</p>
<div class="mp-two-card mp-two-peter">
<p class="mp-two-who">Le même tunnel, plus un certificat</p>
<p class="mp-two-name">HTTPS</p>
<svg class="mp-two-art" viewBox="0 0 300 120" aria-hidden="true">
<path class="ts-pipe" d="M10,60 H110 M190,60 H290"/>
<rect class="ts-phone" x="110" y="4" width="80" height="112"/>
<rect class="ts-bar" x="117" y="12" width="66" height="16"/>
<rect class="ts-lock-b" x="122" y="18" width="8" height="7"/><path class="ts-lock" d="M123.5,18 V15.5 A2.5,2.5 0 0 1 128.5,15.5 V18"/>
<text class="mp-art-t" x="160" y="24" text-anchor="middle">https://</text>
<rect class="ts-tile" x="122" y="40" width="14" height="14"/><rect class="ts-tile" x="143" y="40" width="14" height="14"/><rect class="ts-tile" x="164" y="40" width="14" height="14"/>
<rect class="ts-tile" x="122" y="62" width="14" height="14"/><rect class="ts-tile" x="143" y="62" width="14" height="14"/>
<rect class="ts-tile-on" x="164" y="62" width="14" height="14"/>
<rect class="ts-home" x="140" y="102" width="20" height="4"/>
</svg>
<p class="mp-two-line">Une origine sécurisée : le navigateur lui fait confiance</p>
<p class="mp-two-line">Service worker, hors ligne, installation</p>
<p class="mp-two-line">Tailscale émet le certificat : rien à renouveler</p>
</div>
</div>
<div class="ts-urls">
<p class="ts-url ts-url-bad"><code>https://&lt;machine&gt;:5174</code><span>✗ le certificat est émis pour le nom ts.net</span></p>
<p class="ts-url ts-url-ok"><code>https://&lt;machine&gt;.&lt;tailnet&gt;.ts.net:5174</code><span>✓ correspond au certificat</span></p>
</div>
<p class="mp-strip"><b>Utilisez le nom complet</b><span>Le nom court ne correspond pas au certificat.</span></p>
<figcaption id="ts2-cap">Les deux téléphones sont sur le même tunnel chiffré, la ligne bleue. Ce qui change, c’est ce que le navigateur laisse faire à mes applis mobiles, et ça dépend du certificat, pas du réseau.</figcaption>
</figure>

## Pourquoi la loopback, et pas le LAN

Lier l’appli à `127.0.0.1`, c’est faire de `tailscale serve` la seule entrée. Un invité sur mon Wi-Fi ne peut pas la joindre, un routeur mal configuré non plus. Dans Docker, c’est la partie hôte du mappage de ports :

```yaml
ports:
  - "127.0.0.1:5174:80"   # loopback only; tailscale serve proxies to it
```

<figure class="fd-fig ts-loop" aria-labelledby="ts3-cap" data-desc="Qui peut joindre une appli liée à la loopback. Un invité sur mon Wi-Fi arrive sur l’adresse LAN du Mac mini, où rien n’écoute : il n’obtient rien. Un routeur mal configuré obtient la même chose : rien sur l’adresse LAN. Mon téléphone, sur le tailnet, passe par tailscale serve, qui relaie vers 127.0.0.1, et atteint l’appli. Dans Docker, c’est la partie hôte du mappage de ports 127.0.0.1:5174:80. 127.0.0.1 est l’adresse hôte, loopback uniquement. 5174 est le port hôte, celui vers lequel tailscale serve relaie. 80 est le port à l’intérieur du conteneur.">
<ol class="ts-list ts-who">
<li class="ts-no"><p class="ts-who-n">Un invité sur mon Wi-Fi</p><p class="ts-hop">Adresse LAN ✗</p><p class="ts-end">rien n’écoute</p></li>
<li class="ts-no"><p class="ts-who-n">Un routeur mal configuré</p><p class="ts-hop">Adresse LAN ✗</p><p class="ts-end">rien n’écoute</p></li>
<li class="ts-yes"><p class="ts-who-n">Mon téléphone, sur le tailnet</p><p class="ts-hop">tailscale serve</p><p class="ts-end"><b>127.0.0.1</b> → l’appli</p></li>
</ol>
<div class="ts-map">
<p class="ts-map-k">Le mappage de ports Docker</p>
<p class="ts-map-line" aria-hidden="true"><span class="ts-seg ts-seg-host">127.0.0.1</span>:<span class="ts-seg">5174</span>:<span class="ts-seg">80</span></p>
<ul class="ts-list ts-map-legend">
<li><b>127.0.0.1</b>adresse hôte : loopback uniquement</li>
<li><b>5174</b>port hôte : tailscale serve y relaie les requêtes</li>
<li><b>80</b>le port à l’intérieur du conteneur</li>
</ul>
</div>
<p class="mp-strip"><b>Une seule entrée</b><span>Par tailscale serve, ou pas du tout.</span></p>
<figcaption id="ts3-cap">Trois façons de frapper, une seule porte qui s’ouvre. La partie surlignée du mappage est ce qui tient les deux autres dehors.</figcaption>
</figure>

## Quatre pièges où je suis tombé

| Piège | Ce que j’ai vu | Correctif |
|---|---|---|
| Entrée serve perdue | L’appli répond sur `127.0.0.1`, l’URL du tailnet ne se connecte pas | L’entrée avait disparu après un redémarrage de Tailscale. Vérifier `tailscale serve status`, la rajouter |
| Appli liée à l’IP du LAN | Serve renvoie des erreurs, la loopback est vide | Un changement avait lié Docker à l’adresse LAN. Retour à `127.0.0.1:port` |
| Course au port (Ollama) | Après un redémarrage, Ollama plante en boucle sur `address already in use` | Le réglage « Expose to network » d’Ollama prenait `0.0.0.0:11434` alors que serve tenait le même port sur l’adresse du tailnet. J’ai désactivé Expose |
| Vérification du Host (Ollama) | 403 via serve, tout va bien en local | Serve transmet l’en-tête Host `ts.net` et Ollama sur la loopback le rejette. Un petit nginx intermédiaire réécrit le Host |

Les deux derniers n’ont touché qu’Ollama, mais la leçon est générale : serve relaie la requête telle quelle, donc une appli qui vérifie l’en-tête Host ou veut le port pour elle seule mérite un coup d’œil.

<figure class="fd-fig ts-traps" aria-labelledby="ts4-cap" data-desc="Où chacun des quatre pièges casse le chemin qui va de mon appareil à l’appli en passant par tailscale serve et 127.0.0.1. Un, entrée serve perdue : la requête s’arrête à serve, parce que l’entrée a disparu après un redémarrage de Tailscale, alors que l’appli répond toujours sur 127.0.0.1. Le correctif : rajouter l’entrée. Deux, appli liée à l’IP du LAN : serve marche mais la loopback est vide, parce qu’un changement avait lié Docker à l’adresse LAN. Le correctif : revenir à 127.0.0.1. Trois, la course au port, Ollama seulement : après un redémarrage, Ollama plante en boucle sur address already in use, parce que son réglage Expose to network prenait le port 11434 sur toutes les adresses alors que serve tenait le même port sur l’adresse du tailnet. Le correctif a été de désactiver Expose. Quatre, la vérification du Host, Ollama seulement : la requête atteint l’appli, qui répond 403 parce que serve transmet l’en-tête Host ts.net et qu’Ollama sur la loopback le rejette. Le correctif : un petit nginx intermédiaire qui réécrit le Host.">
<div class="ts-traps-in">
<p class="ts-hops" aria-hidden="true"><span>Appareil</span><span>tailscale serve</span><span>127.0.0.1</span><span>Appli</span></p>
<ol class="ts-list ts-rows">
<li class="ts-row"><p class="ts-row-n"><b>1</b>Entrée serve perdue</p><p class="ts-cells ts-stop-1" aria-hidden="true"><i class="ok"></i><i class="bad">absente</i><i class="idle"></i><i class="idle"></i><i class="ts-tok"></i></p><p class="ts-fix"><b>Correctif</b>la rajouter ; l’appli allait bien depuis le début</p></li>
<li class="ts-row"><p class="ts-row-n"><b>2</b>Appli liée à l’IP du LAN</p><p class="ts-cells ts-stop-2" aria-hidden="true"><i class="ok"></i><i class="ok"></i><i class="bad">vide</i><i class="idle"></i><i class="ts-tok"></i></p><p class="ts-fix"><b>Correctif</b>revenir à 127.0.0.1</p></li>
<li class="ts-row"><p class="ts-row-n"><b>3</b>Course au port<span>Ollama</span></p><p class="ts-cells ts-stop-3" aria-hidden="true"><i class="ok"></i><i class="ok"></i><i class="ok"></i><i class="bad">crash</i><i class="ts-tok"></i></p><p class="ts-fix"><b>Correctif</b>désactiver Expose to network</p></li>
<li class="ts-row"><p class="ts-row-n"><b>4</b>Vérification du Host<span>Ollama</span></p><p class="ts-cells ts-stop-3" aria-hidden="true"><i class="ok"></i><i class="ok"></i><i class="ok"></i><i class="bad">403</i><i class="ts-tok"></i></p><p class="ts-fix"><b>Correctif</b>un nginx intermédiaire réécrit le Host</p></li>
</ol>
</div>
<p class="mp-strip"><b>La leçon</b><span>Serve transmet la requête telle quelle.</span></p>
<figcaption id="ts4-cap">Le même chemin quatre fois, avec l’étape où chaque piège a arrêté ma requête. Le carré jaune est la requête ; les deux derniers pièges ne touchaient qu’Ollama.</figcaption>
</figure>

## Ce que je dirais à quelqu’un qui démarre

- **Liez chaque appli à la loopback** et laissez `tailscale serve` être la seule porte.
- **Passez en HTTPS dès le premier jour** si un téléphone doit l’ouvrir. Les PWA en ont besoin.
- **Tenez une liste des ports.** Avec 50 applis, un registre de ports évite que deux applis se disputent le même numéro.
- **Vérifiez d’abord `tailscale serve status`** quand une URL ne répond plus. La plupart de mes pannes venaient d’une entrée manquante, pas d’une appli cassée.

<figure class="fd-fig ts-habits" aria-labelledby="ts5-cap" data-desc="Les quatre habitudes, un dessin chacune. Un : liez chaque appli à la loopback, pour que tailscale serve soit la seule porte ; la ligne du LAN s’arrête au mur. Deux : passez en HTTPS dès le premier jour si un téléphone doit l’ouvrir, parce que les PWA en ont besoin. Trois : tenez une liste des ports, pour que deux applis ne se disputent jamais le même numéro. Quatre : vérifiez d’abord tailscale serve status quand une URL ne répond plus, parce que la plupart de mes pannes venaient d’une entrée manquante, pas d’une appli cassée.">
<ol class="ts-list ts-tips">
<li>
<svg class="ts-tip-art" viewBox="0 0 120 72" aria-hidden="true">
<path id="ts5-in" class="ts-wire" d="M4,30 H58 V16 H74"/>
<path class="ts-lan" d="M4,58 H34"/>
<rect class="ts-mini" x="34" y="6" width="80" height="60"/>
<path class="ts-wire" d="M58,30 H74 M58,30 V44 H74"/>
<rect class="ts-app" x="74" y="10" width="12" height="12"/><rect class="ts-app" x="74" y="24" width="12" height="12"/><rect class="ts-app" x="74" y="38" width="12" height="12"/>
<rect class="ts-door" x="31" y="23" width="6" height="14"/>
<path class="ts-x" d="M39,53 L47,61 M47,53 L39,61"/>
<g class="fd-token fd-token-ui"><rect x="-4" y="-4" width="8" height="8"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#ts5-in"/></animateMotion></rect></g>
</svg>
<p class="ts-tip-n"><b>1</b>Loopback uniquement</p>
<p class="ts-tip-what">Serve est la seule porte</p>
</li>
<li>
<svg class="ts-tip-art" viewBox="0 0 120 72" aria-hidden="true">
<rect class="ts-phone" x="42" y="3" width="36" height="66"/>
<rect class="ts-bar" x="46" y="8" width="28" height="9"/>
<rect class="ts-lock-b" x="49" y="12" width="5" height="4"/><path class="ts-lock" d="M50,12 V10.5 A1.5,1.5 0 0 1 53,10.5 V12"/>
<rect class="ts-tile" x="47" y="24" width="7" height="7"/><rect class="ts-tile" x="56.5" y="24" width="7" height="7"/><rect class="ts-tile" x="66" y="24" width="7" height="7"/>
<rect class="ts-tile" x="47" y="34" width="7" height="7"/><rect class="ts-tile" x="56.5" y="34" width="7" height="7"/>
<rect class="ts-tile-on" x="66" y="34" width="7" height="7"/>
</svg>
<p class="ts-tip-n"><b>2</b>HTTPS dès le premier jour</p>
<p class="ts-tip-what">Si un téléphone l’ouvre, sa PWA en a besoin</p>
</li>
<li>
<svg class="ts-tip-art" viewBox="0 0 120 72" aria-hidden="true">
<rect class="ts-mini" x="40" y="4" width="70" height="64"/>
<rect class="ts-clash" x="41" y="26" width="68" height="12"/>
<rect class="mp-ln" x="46" y="15" width="12" height="3"/><rect class="mp-ln" x="64" y="15" width="38" height="3"/>
<rect class="ts-ln-ink" x="46" y="30.5" width="12" height="3"/><rect class="ts-ln-ink" x="64" y="30.5" width="38" height="3"/>
<rect class="mp-ln" x="46" y="46" width="12" height="3"/><rect class="mp-ln" x="64" y="46" width="30" height="3"/>
<rect class="mp-ln" x="46" y="58" width="12" height="3"/><rect class="mp-ln" x="64" y="58" width="34" height="3"/>
<rect class="ts-app" x="4" y="12" width="12" height="12"/><rect class="ts-app-new" x="4" y="42" width="12" height="12"/>
<path class="ts-wire" d="M16,18 H26 L40,30"/><path class="ts-lan" d="M16,48 H26 L40,34"/>
</svg>
<p class="ts-tip-n"><b>3</b>Une liste des ports</p>
<p class="ts-tip-what">Deux applis ne se disputent jamais un numéro</p>
</li>
<li class="ts-tip-first">
<svg class="ts-tip-art" viewBox="0 0 120 72" aria-hidden="true">
<rect class="ts-mini" x="10" y="4" width="100" height="64"/>
<rect class="ts-ln-ink" x="16" y="11" width="6" height="3"/><rect class="ts-ln-ink" x="26" y="11" width="60" height="3"/>
<path class="fd-rule" d="M10,20 H110"/>
<rect class="mp-ln" x="16" y="27" width="70" height="3"/>
<rect class="mp-ln" x="16" y="37" width="62" height="3"/>
<rect class="ts-gone" x="14" y="44" width="76" height="9"/>
<rect class="mp-ln" x="16" y="59" width="66" height="3"/>
</svg>
<p class="ts-tip-n"><b>4</b>Le statut d’abord</p>
<p class="ts-tip-what">La plupart des pannes : une entrée manquante, pas une appli cassée</p>
</li>
</ol>
<figcaption id="ts5-cap">Les quatre mêmes habitudes, en dessin. La quatrième a le cadre bleu, parce que c’est là que se trouvaient la plupart de mes pannes.</figcaption>
</figure>
