---
title: "Tailscale Serve: HTTPS for 50 self-hosted apps, none of them on the internet"
description: Every app on my Mac mini listens on 127.0.0.1 only, and tailscale serve gives each one an HTTPS URL that only my own devices can reach. No open ports, no reverse proxy to maintain, real certificates for the phone. Here's the setup and the four traps I hit.
date: 2026-10-09
tags: Tailscale, self-hosting, networking, homelab, security
---

I run about 50 small apps on a Mac mini at home: dashboards, PWAs, APIs for my phone apps, a local Ollama. None of them is reachable from the internet, and none listens on my home network either. Each app binds to `127.0.0.1`, and **`tailscale serve`** puts it on my tailnet at `https://<machine>.<tailnet>.ts.net:<port>`, with a real certificate, reachable only from my own devices over Tailscale's encrypted WireGuard tunnel.

One command per app, nothing else to run:

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

## The path a request takes

<figure class="fd-fig tsm" aria-labelledby="ts1-cap" data-desc="The path a request takes, drawn as zones inside zones. Outside is the public internet: anyone else has no route in, and Funnel, the feature that would publish a port there, is off. Inside it is my tailnet, my own devices signed in to Tailscale. A phone or laptop opens the ts.net URL, and the request travels as HTTPS inside the WireGuard tunnel to the Mac mini. There, tailscale serve terminates HTTPS with a Let's Encrypt certificate and passes the request on as plain HTTP to the app on loopback, 127.0.0.1, often a Docker container. Plain HTTP never leaves the box. tailscale serve status lists 51 entries, every one tailnet only.">
<div class="tsm-in">
<div class="tsm-net" role="group" aria-label="The public internet, my tailnet inside it, and the Mac mini inside that. A request goes from my phone over the encrypted tunnel to tailscale serve, then as plain HTTP to the app on loopback. Strangers have no route in.">
  <p class="tsm-zone">The 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>Anyone else</span><b>no route in</b></p>
  <div class="tsm-tail">
    <p class="tsm-zone tsm-zone-c">My 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>My devices</h3>
        <p>Open the ts.net URL</p>
      </div>
      <div class="tsm-wire tsm-wire-c" aria-hidden="true"><span>HTTPS in 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> off</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>The certificate ends here</p>
          </div>
          <div class="tsm-wire tsm-wire-p" aria-hidden="true"><span>plain HTTP</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> serve entries, every one tailnet only</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</div>
<figcaption id="ts1-cap">Zones inside zones. The request is encrypted all the way to serve, and only the last step inside the box is plain HTTP. Each square is one of the 51 serve entries.</figcaption>
</figure>

Right now `tailscale serve status` lists 51 entries, every one marked *tailnet only*. None uses Funnel, the feature that would publish a port to the public internet.

## Why HTTPS, when the tunnel is already encrypted

WireGuard already encrypts the traffic between my devices, so plain HTTP would be private enough. HTTPS is for the browser. iOS only runs a PWA's service worker on a secure origin, so without it my phone apps lose offline mode and the home-screen install. Tailscale issues the certificate for the machine's `ts.net` name, so there's nothing to renew by hand.

One detail: use the full name. `https://<machine>:5174` fails because the certificate is for the `ts.net` name, not the short one.

<figure class="fd-fig ts-https" aria-labelledby="ts2-cap" data-desc="Why HTTPS when the tunnel is already encrypted. Over plain HTTP, WireGuard still encrypts the traffic between my devices, so it is private enough, but iOS only runs a PWA's service worker on a secure origin: no offline mode and no home-screen install. Over HTTPS on the machine's ts.net name, the service worker runs, so offline mode and the install work, and Tailscale issues the certificate, so there is nothing to renew by hand. Use the full name: the short machine name with the port fails, because the certificate is for the ts.net name. The full name, machine dot tailnet dot ts.net with the port, works.">
<div class="mp-two-row">
<div class="mp-two-card">
<p class="mp-two-who">The tunnel, no certificate</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">Private enough: WireGuard encrypts it</p>
<p class="mp-two-line">iOS won't run the service worker</p>
<p class="mp-two-line">No offline mode, no home-screen install</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">The same tunnel, plus a certificate</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">A secure origin, so the browser trusts it</p>
<p class="mp-two-line">Service worker, offline mode, install</p>
<p class="mp-two-line">Tailscale issues the cert: nothing to renew</p>
</div>
</div>
<div class="ts-urls">
<p class="ts-url ts-url-bad"><code>https://&lt;machine&gt;:5174</code><span>✗ the cert is for the ts.net name</span></p>
<p class="ts-url ts-url-ok"><code>https://&lt;machine&gt;.&lt;tailnet&gt;.ts.net:5174</code><span>✓ matches the cert</span></p>
</div>
<p class="mp-strip"><b>Use the full name</b><span>The short one fails the certificate.</span></p>
<figcaption id="ts2-cap">Both phones sit on the same encrypted tunnel, the blue line. What changes is what the browser lets my phone apps do, and that depends on the certificate, not on the network.</figcaption>
</figure>

## Why loopback, not the LAN

Binding the app to `127.0.0.1` means the only way in is through `tailscale serve`. A guest on my Wi-Fi can't reach it, and neither can a misconfigured router. In Docker that's the host part of the port mapping:

```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="Who can reach an app bound to loopback. A guest on my Wi-Fi reaches the Mac mini's LAN address, where nothing is listening, so they get nothing. A misconfigured router gets the same: nothing on the LAN address. My phone, on the tailnet, goes through tailscale serve, which proxies to 127.0.0.1, and reaches the app. In Docker that is the host part of the port mapping 127.0.0.1:5174:80. 127.0.0.1 is the host address, loopback only. 5174 is the host port, the one tailscale serve proxies to. 80 is the port inside the container.">
<ol class="ts-list ts-who">
<li class="ts-no"><p class="ts-who-n">A guest on my Wi-Fi</p><p class="ts-hop">LAN address ✗</p><p class="ts-end">nothing listening</p></li>
<li class="ts-no"><p class="ts-who-n">A misconfigured router</p><p class="ts-hop">LAN address ✗</p><p class="ts-end">nothing listening</p></li>
<li class="ts-yes"><p class="ts-who-n">My phone, on the tailnet</p><p class="ts-hop">tailscale serve</p><p class="ts-end"><b>127.0.0.1</b> → the app</p></li>
</ol>
<div class="ts-map">
<p class="ts-map-k">The Docker port mapping</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>host address: loopback only</li>
<li><b>5174</b>host port: tailscale serve proxies to it</li>
<li><b>80</b>the port inside the container</li>
</ul>
</div>
<p class="mp-strip"><b>One way in</b><span>Through tailscale serve, or not at all.</span></p>
<figcaption id="ts3-cap">Three ways to knock, one door that opens. The highlighted part of the mapping is what keeps the other two out.</figcaption>
</figure>

## Four traps I hit

| Trap | What I saw | Fix |
|---|---|---|
| Serve entry lost | App answers on `127.0.0.1`, the tailnet URL doesn't connect | The entry disappeared after a Tailscale restart. Check `tailscale serve status`, re-add it |
| App bound to the LAN IP | Serve returns errors, loopback is empty | A change bound Docker to the LAN address. Back to `127.0.0.1:port` |
| Bind race (Ollama) | After a reboot, Ollama crash-loops on `address already in use` | Ollama's "Expose to network" took `0.0.0.0:11434` while serve held the same port on the tailnet address. Turned Expose off |
| Host check (Ollama) | 403 through serve, fine locally | Serve forwards the `ts.net` Host header and Ollama on loopback rejects it. A small nginx hop rewrites the Host |

The last two only bit Ollama, but the lesson is general: serve proxies the request as-is, so an app that checks the Host header or wants the port for itself needs a look.

<figure class="fd-fig ts-traps" aria-labelledby="ts4-cap" data-desc="Where each of the four traps breaks the path from my device through tailscale serve and 127.0.0.1 to the app. One, serve entry lost: the request stops at serve, because the entry disappeared after a Tailscale restart, while the app still answers on 127.0.0.1. The fix is to re-add the entry. Two, app bound to the LAN IP: serve works but loopback is empty, because a change bound Docker to the LAN address. The fix is to bind back to 127.0.0.1. Three, the bind race, Ollama only: after a reboot Ollama crash-loops on address already in use, because its Expose to network setting took port 11434 on every address while serve held the same port on the tailnet address. The fix was turning Expose off. Four, the Host check, Ollama only: the request reaches the app, which answers 403 because serve forwards the ts.net Host header and Ollama on loopback rejects it. The fix is a small nginx hop that rewrites the Host.">
<div class="ts-traps-in">
<p class="ts-hops" aria-hidden="true"><span>Device</span><span>tailscale serve</span><span>127.0.0.1</span><span>App</span></p>
<ol class="ts-list ts-rows">
<li class="ts-row"><p class="ts-row-n"><b>1</b>Serve entry lost</p><p class="ts-cells ts-stop-1" aria-hidden="true"><i class="ok"></i><i class="bad">missing</i><i class="idle"></i><i class="idle"></i><i class="ts-tok"></i></p><p class="ts-fix"><b>Fix</b>re-add it; the app was fine all along</p></li>
<li class="ts-row"><p class="ts-row-n"><b>2</b>App bound to the LAN IP</p><p class="ts-cells ts-stop-2" aria-hidden="true"><i class="ok"></i><i class="ok"></i><i class="bad">empty</i><i class="idle"></i><i class="ts-tok"></i></p><p class="ts-fix"><b>Fix</b>bind back to 127.0.0.1</p></li>
<li class="ts-row"><p class="ts-row-n"><b>3</b>Bind race<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>Fix</b>turn off Expose to network</p></li>
<li class="ts-row"><p class="ts-row-n"><b>4</b>Host check<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>Fix</b>an nginx hop rewrites the Host</p></li>
</ol>
</div>
<p class="mp-strip"><b>The lesson</b><span>Serve passes the request on as-is.</span></p>
<figcaption id="ts4-cap">The same path four times, with the hop where each trap stopped my request. The yellow square is the request; the last two traps only hit Ollama.</figcaption>
</figure>

## What I'd tell someone starting

- **Bind every app to loopback** and let `tailscale serve` be the only door.
- **Use HTTPS from day one** if a phone will open it. PWAs need it.
- **Keep a list of the ports.** With 50 apps, a port registry saves you from two apps fighting over one number.
- **Check `tailscale serve status` first** when a URL stops working. Most of my outages were a missing entry, not a broken app.

<figure class="fd-fig ts-habits" aria-labelledby="ts5-cap" data-desc="The four habits, one picture each. One: bind every app to loopback, so tailscale serve is the only door; the LAN line stops at the wall. Two: use HTTPS from day one if a phone will open it, because PWAs need it. Three: keep a list of the ports, so two apps never fight over one number. Four: check tailscale serve status first when a URL stops working, because most of my outages were a missing entry, not a broken app.">
<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 only</p>
<p class="ts-tip-what">Serve is the one door</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, day one</p>
<p class="ts-tip-what">If a phone opens it, its PWA needs it</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>A list of the ports</p>
<p class="ts-tip-what">Two apps never fight over one number</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>Status first</p>
<p class="ts-tip-what">Most outages: a missing entry, not a broken app</p>
</li>
</ol>
<figcaption id="ts5-cap">The same four habits, drawn. The fourth has the blue frame because that's where most of my outages turned out to be.</figcaption>
</figure>
