Tailnet hosting¶
Localghost can expose every opted-in local route to devices in one Tailscale
tailnet. If the local route is https://shop.localhost, enabling the suffix
tail1234 adds https://shop.tail1234; api.shop.localhost becomes
api.shop.tail1234, and the dashboard becomes traefik.tail1234. There is no route at
the bare tail1234 name.
This is an opt-in extension of the local hub. The normal .localhost routes
remain loopback-only and keep working when tailnet hosting is disabled.
How the pieces fit¶
Four pieces cooperate, and each has exactly one job:
- The hub is the
localghostDocker Compose project: Traefik plus its certificate providers. Docker labels on your containers are the single routing source of truth — applications never acquire tailnet-specific labels, so localhost and tailnet routing cannot drift apart. - The gateway is one extra container in that same hub. It appears three
ways that all name the same thing: Compose service
tailscale-gateway, tailnet devicelocalghost-<machine>, named after the hosting machine's own tailnet name (what the admin console shows), and "the gateway" in this documentation. It joins the tailnet as a userspace node and only transports: it answers DNS for the suffix with its own tailnet addresses, forwards HTTP to Traefik with the original Host header and the requesting device's tailnet address, and passes HTTPS through as raw TCP behind a PROXY protocol header naming that device. It also tells the hub which tailnet user owns an address, so applications receive Tailscale identity headers. It terminates no TLS, holds no offline CA material, and has no Docker socket. - Two certificate authorities live in Docker volumes, one for
.localhostand one per tailnet suffix, each split into an offline root and a constrained online signer. Traefik's provider plugin — one instance per suffix — watches the same Docker labels and issues leaf certificates from the matching signer. Because the authorities are separate, trusting a tailnet root never widens what the.localhostroot may sign, and vice versa. - Split DNS is the only piece outside your machine: a per-suffix entry in
the tailnet's DNS configuration that sends
*.<suffix>lookups to the gateway. Enable adds it, disable restores exactly what was there before.
A request from another device therefore flows: tailnet DNS → gateway →
Traefik (certificate chosen by SNI from the suffix authority) → the same
container or host bridge that serves the .localhost route.
Enable it¶
Without a credential, enable walks through the one-time admin-console setup
before prompting: allow the device tag (default tag:localghost) in the
tailnet policy file, for
example
and create a scoped
OAuth client with the
auth_keys scope (with that tag allowed) and the dns:write scope. Paste the
client id and secret at the prompts, or provide them with
TAILSCALE_CLIENT_ID/TAILSCALE_CLIENT_SECRET or
--client-id/--client-secret. When a step was missed, the API errors are
translated into the exact console fix instead of raw HTTP responses.
After a successful enable, the credential is saved in the system keyring
(service localghost-tailscale), so disable and a later re-enable need no
re-entry; disable removes it again. Without a usable keyring, localghost
warns and stores nothing, and disable prompts for a client with the
dns:write scope instead.
The OAuth client's own tailnet is the default. Localghost asks the installed
tailscale CLI for a suffix: an explicit one-label search domain wins, then
this machine's own tailnet short name (wrk.taildc3ac3.ts.net becomes wrk,
serving shop.wrk), and finally the tailnet's MagicDNS label (taildc3ac3)
when the short name is a public word. The short name keeps resolving to the
machine itself alongside the suffix. Pass --tailnet or --suffix
explicitly when those defaults are not the desired values.
A suffix's split-DNS entry applies to the whole tailnet, so enable refuses to
replace an existing one — most likely another machine already hosting that
suffix. Machine short names keep hosting machines apart by default; pass a
distinct --suffix, or --takeover to replace the mapping deliberately. When a
later enable step fails, the split-DNS entry and saved state are rolled back
so the command can simply be run again.
A suffix that names a public TLD or reserved zone (dev, com, work, any
two-letter country code, internal, …) still routes, but over HTTP only:
even though the root is name-constrained, an anchor scoped to a real TLD could
impersonate real websites on every machine that trusts it, so no tailnet
authority is minted for such a suffix. Requests are still encrypted by the
tailnet's WireGuard tunnel; what is lost is the browser's secure context
(service workers, crypto.subtle, camera access) and the trust.<suffix>
page. enable, hub up, and localghost tailscale status say so, the
last in place of the trust command a private suffix shows. Detection never picks a public label; one has to
be passed as --suffix deliberately. Choose a private label such as
tail1234 for trusted HTTPS.
Enable performs four bounded operations:
- bootstraps the localhost and tailnet HTTPS authorities and builds the gateway image;
- creates a single-use, ten-minute auth key — after the build, so a slow
first build cannot outlive it — and enrolls a persistent tagged
localghost-<machine>userspace node, named after this machine's tailnet name (its hostname when thetailscaleCLI is unavailable); - adds split DNS for
tail1234, preserving the previous suffix mapping; and - starts the gateway and suffix-specific HTTPS provider with the hub.
The OAuth access token and auth key are never saved, and the client secret is kept only in the system keyring. Localghost stores the tailnet name, suffix, gateway addresses, device tag, and previous split-DNS map in its state directory so it can remove its split-DNS entry on disable without discarding unrelated changes made later.
Every client must trust this hub's development roots once:
When tailnet hosting is enabled, the standard trust command installs both the
.localhost root and the active tailnet root. It downloads the latter from
http://trust.tail1234 over the tailnet. Private CA keys never leave Docker volumes.
On another device, name the suffix instead:
That download is plain HTTP, and the tailnet is what secures it: split DNS
resolves the name to the tagged gateway, and the connection to it is
WireGuard-encrypted and gated by tailnet policy. Whoever can rewrite the
tailnet's DNS configuration can therefore answer for trust.tail1234. To close
that gap, pin the fingerprint: a successful enable — and localghost
tailscale status afterwards — prints a ready-to-paste command for other
machines, and the download is installed only if it matches:
Devices without the localghost CLI (a phone, a colleague's untooled laptop)
can open http://trust.tail1234 in a browser instead: the gateway serves a
small page with the pinned command, the expected fingerprint, and a direct
link to the root certificate for manual installation.
Trusting a root again replaces the one this client had for that suffix; the
superseded certificate leaves the trust stores first, and the .localhost
root is never disturbed.
What applications receive¶
A tailnet request reaches the application like a local one, on the same
route, with two differences. The requesting device's tailnet address, such as
100.101.102.103, arrives in X-Real-Ip and X-Forwarded-For instead of
127.0.0.1. And the person behind the device arrives in the
Tailscale-User-Login, Tailscale-User-Name and Tailscale-User-Profile-Pic
headers Tailscale Serve would set, so an application can recognize a
teammate without a sign-in of its own. See
Tailnet identity for the
values, tagged devices, and what to trust.
Operate and disable it¶
Normal commands continue to own the hub:
localghost tailscale status
localghost # reconciles localhost and tailnet hosting
localghost hub down # stops both
A foreground localghost run pins both URLs to the bottom of the terminal —
https://shop.localhost · https://shop.tail1234 — so the tailnet address stays
visible for other devices while the application runs.
The gateway carries a Docker healthcheck that reports healthy only once its
tailnet node is enrolled and every listener is up. localghost tailscale
status includes that observed state as Gateway health, so a gateway that
died after enable shows up there rather than as a timeout on another device.
Routers that cannot be mirrored (custom labels without explicit service and
entrypoints) appear there as Localhost-only routers.
An unreachable tailnet never blocks local work: when the hub starts but only
the gateway fails its healthcheck, localghost warns and carries on with the
.localhost routes while the gateway keeps retrying in the background.
To restore the DNS configuration that existed at enable time and remove the gateway from the running hub:
Disable also deletes the OAuth credential from the system keyring, and says so, so the next enable asks for it again. Switching suffix (below) keeps it.
Disabling deliberately does not delete the offline machine record. Remove the
tagged localghost-<machine> device from the Tailscale admin console after
you have confirmed it is the expected node. Trust installed on other clients
is also left in place; run localghost trust remove on each to revoke it.
Switch to another suffix¶
Run enable again with the new suffix while tailnet hosting is enabled:
Without --suffix, enable detects the suffix again, so after renaming this
machine in the admin console a plain localghost tailscale enable moves the
routes to the new short name. That applies to a suffix chosen with
--suffix too; when detection gives the current suffix, enable just reports
that hosting is already enabled.
The gateway keeps its device, addresses, tailnet, and tag, so no auth key is
created and nothing new appears in the admin console. The new suffix's split
DNS entry points at the gateway, and in the same change the old suffix gets
back whatever it resolved to before enable. The hub is recreated to answer
for the new names. The same --takeover rule applies to the new suffix, and
if the hub fails to start the switch is rolled back to the old suffix. The
stored credential is used and kept.
Each suffix has its own HTTPS root, because a root may only sign names under
its suffix. Other machines therefore run the printed localghost tailscale
trust command for the new suffix, and localghost trust remove if they
should stop trusting the old one.
A hub enabled before gateways were named after the machine keeps its
localghost-<suffix> device across switches; disable and enable again to
enroll one named after the machine.
While tailnet hosting is enabled, localghost trust remove on the hosting
machine removes local trust but the hub keeps serving HTTPS — tailnet TLS
terminates on Traefik — so a full downgrade needs localghost tailscale
disable first.
Scope and limitations¶
The first implementation supports one Tailscale tailnet and one suffix. It is a mesh-only path: the gateway is reachable only through Tailscale, with no public ingress, Funnel, Serve, or LAN listener. Anyone authorized to reach the tagged gateway can reach the development applications it routes, so apply a tailnet ACL or grant appropriate for your development group and data.
This feature mirrors Docker-label routes generated by localghost. Custom
routers must use literal Traefik Host rules for .localhost plus explicit service and
entrypoint labels to be mirrored safely; unsupported rules remain localhost
only and are reported by the provider.