---
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://<hostname>.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:<port>

# Verify
tailscale serve status
# → https://<hostname>.tail<random>.ts.net/ → proxy http://127.0.0.1:<port>
```

### 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 <port>/tcp
ufw reload
```

### 5. Rename the hostname if the random tailnet name has confusing characters
```bash
tailscale set --hostname <service-name>
# 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://<hostname>.tail<random>.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 <port>/tcp comment 'Service name (temp)'
ufw delete allow <port>/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
