---
name: tailscale-infrastructure-access
description: "Set up Tailscale for private infrastructure access — install on servers, authenticate, standard for services that don't need public exposure."
version: 1.1.0
author: ShoNuff
license: MIT
platforms: [linux, macos]
metadata:
  hermes:
    tags: [tailscale, vpn, networking, access, security]
---

# Tailscale Infrastructure Access

Standard for accessing services that don't need public internet exposure. Vaultwarden, future internal tools, and anything without a customer-facing requirement stays behind Tailscale.

## Principles

- **No public ports for internal services** — Tailscale-only, or SSH tunnel as fallback. No DNS records, no Let's Encrypt, no exposed endpoints for administrative tools.
- **One Tailscale network per infrastructure** — All IT Pro Partner servers join the same tailnet. The user authenticates via their Google account on each device.
- **Zero-config mesh** — Tailscale handles NAT traversal, DERP relays, and key exchange. No WireGuard configs, no port forwarding, no public IP needed.
- **UFW must allow the service port** — Tailscale gets traffic to the server, but UFW/iptables still needs to permit the port. Tailscale does not bypass the local firewall. After setting up a Tailscale-only service, verify with `ufw status` and add the port if missing.

## Installation (server side)

```bash
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
```

This prints a one-time auth URL. Send it to the user to open in their browser (phone, laptop, any device logged into their Google account). The command times out after 15s if the URL isn't opened — that's expected, the URL is still valid. Just re-run `tailscale up` for a fresh URL.

Rename the server from the auto-generated hostname to a clean name:
```bash
tailscale set --hostname app1
```

## Installation (client devices)

| Device | Download |
|---|---|
| **macOS** | [tailscale.com/download-mac](https://tailscale.com/download-mac) |
| **iPhone/iPad** | App Store → "Tailscale" |
| **Linux** | `curl -fsSL https://tailscale.com/install.sh | sh` |

On first launch, sign in with the same Google account used to authenticate the server. Devices auto-discover each other and appear in `tailscale status`.

## Post-setup

After the server shows `Connected` in `tailscale status`:

```bash
# Verify connection — lists all connected devices
tailscale status

# Get the tailnet IP (100.x.x.x)
tailscale ip -4

# Access services via tailnet IP instead of public IP
# http://100.x.x.x:8080 → Vaultwarden
# ssh root@100.x.x.x → SSH via tailnet (if public SSH is closed later)
```

## Deploying a Tailscale-only service (Vaultwarden pattern)

1. Deploy the service to `~/docker/<service>/` with `docker-compose.yml` + `.env` + `CHANGELOG.md`
2. Verify it works locally: `curl -s http://localhost:<port>/`
3. Allow the port through UFW: `ufw allow <port>/tcp comment '<description>'`
4. Set the service's `DOMAIN` / `PUBLIC_URL` / base URL to the Tailscale access URL — if using raw tailnet IP, set to `http://100.x.x.x:port`. If using Tailscale Serve for HTTPS (recommended — avoids browser mixed-content warnings on Vaultwarden), set it to the `.ts.net` URL:
   ```bash
   tailscale serve --bg --https 443 --set-path / http://127.0.0.1:<port>
   ```
   Then set DOMAIN to `https://<hostname>.tail<random>.ts.net`
5. The service is now available at `https://<hostname>.tail<random>.ts.net/` — valid HTTPS cert, tailnet-only
6. Once confirmed working, close the UFW port so the service has zero public surface: `ufw delete allow <port>/tcp`
7. Log the change: `bash ~/.hermes/scripts/changelog.sh "Networking" "Tailscale Serve enabled for <service> at https://..."`

Vaultwarden specific: The web vault is a JavaScript SPA that calls API endpoints from the browser. If DOMAIN is wrong or points to HTTP from HTTPS (or vice versa), the requests fail silently and the spinner never stops. Fix is always docker-compose restart with correct DOMAIN. Additionally, `SIGNUPS_ALLOWED` must be temporarily true for the first user to register, then locked to false. The admin panel at `/admin` uses ADMIN_TOKEN from .env.

## Security consideration

Once Tailscale is operational and all team devices are connected, the public SSH port can be closed (UFW/iptables deny 0.0.0.0/0 :22, allow only tailnet subnet). This eliminates the public attack surface entirely.

Defer this until all team devices have Tailscale installed and confirmed working.

## Service-access policy

| Service | Public | Tailscale-only | Why |
|---|---|---|---|
| Vaultwarden | ❌ | ✅ | Passwords — zero public surface |
| Future internal tools | ❌ | ✅ | Default for non-customer tools |
| admin-ai (LiteLLM) | ✅ | — | Hermes needs it from its own subnet |
| Hermes Telegram | ✅ | — | Needs internet for bot polling |

## Pitfalls

- `tailscale up` prints an auth URL and times out after 15s — that's expected. The URL expires after a few minutes. Just re-run `tailscale up` to get a fresh one.
- The tailscale IP (`100.x.x.x`) can change if the machine re-authenticates. Use the machine name (visible in `tailscale status` or the admin console) for a stable reference.
- If the server shows "Logged out" after a reboot, run `tailscale up` again.
- UFW does not auto-deny by default — a new service on a new port is accessible from the public internet unless explicitly blocked. For Tailscale-only services, confirm with `ufw status` that either the port is allowed (at minimum) or the port has a tailnet-only exception (ideal).
- Vaultwarden's web vault needs the correct DOMAIN — it uses this to build API URLs. If users see a loading spinner that never resolves, check `docker logs vaultwarden` for `POST /identity/connect/token` requests, then fix DOMAIN and restart.
- To rename a device in Tailscale: `tailscale set --hostname <new-name>`. Takes effect immediately in `tailscale status` and the admin console.
