--- name: tailscale-internal-services description: "Deploy internal-only services behind Tailscale for zero public exposure — Tailscale Serve for HTTPS, UFW firewall rules, and service configuration patterns." version: 1.0.0 author: ShoNuff platforms: [linux] metadata: hermes: tags: [tailscale, vpn, networking, security, internal-services, docker] --- # Tailscale Internal Services Standard for deploying services that should not be publicly accessible. All internal services live behind Tailscale — no DNS records, no public ports, no Let's Encrypt. ## Architecture ``` Device (iPhone/Mac) app1 (netcup) ┌──────────────┐ Tailscale tunnel ┌─────────────────────┐ │ Tailscale │◄──────────────────────►│ tailscale serve │ │ connected │ encrypted mesh │ ↓ │ │ │ │ Docker container │ │ localhost │ │ port 8080 │ └──────────────┘ └─────────────────────┘ │ ▼ https://.tailXXXXX.ts.net ``` **Key principle:** Tailscale traffic is already encrypted end-to-end. The Tailscale Serve HTTPS layer is only needed for (a) browser security warnings and (b) apps (like Bitwarden) that reject plain HTTP self-hosted URLs. For API-only services, plain HTTP over the tailnet is fine. ## Setup ### 1. Install Tailscale ```bash curl -fsSL https://tailscale.com/install.sh | sh tailscale up # Auth URL printed — open in browser to log in ``` ### 2. Start a service with Tailscale Serve ```bash # Expose a local Docker port via Tailscale HTTPS tailscale serve --bg --https 443 --set-path / http://127.0.0.1: # Verify tailscale serve status # → https://.tail.ts.net/ → proxy http://127.0.0.1: ``` ### 3. Configure the service's DOMAIN Update the container's `DOMAIN` environment variable to match the Tailscale URL so the service's internal redirects and API URLs work correctly. ### 4. Close the public port ```bash ufw deny /tcp ufw reload ``` ### 5. Rename the hostname if the random tailnet name has confusing characters ```bash tailscale set --hostname # Then re-create serve config (it picks up the new hostname) ``` ## Application-Specific Notes ### Vaultwarden / Bitwarden - Use **"Self-hosted environment"** in the Bitwarden app - URL: `https://.tail.ts.net` - If the Tailscale hostname has zeroes or letter-O confusables, rename the node: `tailscale set --hostname vaultwarden` - The Bitwarden mobile app may have issues with certain SSL implementations from Tailscale Serve. If the app can't connect, try: - Use the **Custom Environment** option in the app and enter api/identity/web vault URLs separately - Access via pure HTTP on the Tailscale IP (`http://100.x.x.x:8080`) instead of the HTTPS hostname — works fine since tailnet traffic is encrypted ## UFW Rules ```bash # Default: ALLOW SSH, HTTP, HTTPS for management ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp # Per service: start with port open for testing, then close when Tailscale is verified ufw allow /tcp comment 'Service name (temp)' ufw delete allow /tcp # After verifying Tailscale access ``` ## Pitfalls - **Tailscale Serve requires HTTPS** — you can't serve a port on both HTTP and HTTPS via Tailscale Serve simultaneously - **Hostname changes after serve is active** — you need to restart the serve daemon (turn off, then re-enable with the new hostname) - **Safari on iOS shows "Not Secure"** for plain HTTP over tailnet — this is cosmetic, the tunnel is still encrypted - **`tailscale serve status` shows a tailnet-only badge** — verify with `-o json` for automated checks - **Renaming the tailscale node** (e.g. app1→vaultwarden) changes the serve URL immediately without downtime