---
name: tailscale-funnel-setup
description: Configure Tailscale Serve and Funnel for exposing local services with HTTPS
category: devops
---

# Tailscale Serve & Funnel Setup

## Serve vs Funnel
- **Serve:** Expose local service within tailnet only (private)
- **Funnel:** Expose local service to the **entire internet** (public HTTPS)

## Commands
```bash
# Check current serve config
tailscale serve status --json

# Check funnel status
tailscale funnel status --json

# Set up serve (tailnet-only)
tailscale serve 3000          # HTTP on port 3000
tailscale serve https+insecure://localhost:3000  # HTTPS with self-signed cert

# Set up funnel (public internet)
tailscale funnel 3000         # Public HTTPS on port 3000
```

## JSON Config Format
```json
{
  "TCP": {
    "443": { "HTTPS": true }
  },
  "Web": {
    "hostname.tailf83ed5.ts.net:443": {
      "Handlers": {
        "/": { "Proxy": "http://127.0.0.1:<port>" }
      }
    }
  },
  "AllowFunnel": {
    "hostname.tailf83ed5.ts.net:443": true
  }
}
```

## Port Conflict Rules
- Tailscale binds to the **Tailscale IP** (`100.x.x.x:443`), NOT `0.0.0.0:443`
- Nginx can safely listen on `0.0.0.0:443` for non-Tailscale hosts
- Tailscale and Nginx can coexist on port 443 if they bind to different IPs
- **Never** configure Nginx to proxy to a Tailscale Funnel endpoint — that's circular

## Production Hardening

When exposing a Funnel service to the public internet, add a reverse proxy layer for rate limiting and security:

### Hybrid Architecture (Tailscale Funnel + nginx)

```
Internet → Tailscale Funnel (valid HTTPS, IP hidden)
              ↓
          nginx (rate limits: 50r/s per IP, burst 100)
              ↓
          backend service (Docker, private network, no direct ports)
```

### Steps
1. **Isolate the backend:** Remove `0.0.0.0` port mappings from the Docker service. Attach to a private bridge network.
2. **Configure nginx as proxy:** Create a site block with `limit_req_zone` and `proxy_pass` to the container's internal IP.
3. **Point Funnel at nginx, not the backend:** Update Funnel to proxy to `http://127.0.0.1:NGINX_PORT` instead of directly to the backend.
4. **Verify:** The public Funnel URL should still work, but all requests now pass through nginx's rate limiter.

### Rate Limiting Example (nginx)
```nginx
limit_req_zone $binary_remote_addr zone=scraper:10m rate=50r/s;

server {
    listen 127.0.0.1:8081;
    location / {
        limit_req zone=scraper burst=100 nodelay;
        proxy_pass http://172.19.0.2:3091;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

### Comparison Research
See `references/reverse-proxy-comparison.md` for a detailed comparison of Tailscale Funnel vs Cloudflare Tunnel vs nginx, including latency benchmarks, throughput limits, and use-case guidance.

## Verification
```bash
# Check what's listening on 443
ss -tlnp | grep 443

# Verify funnel is active
curl -sk https://<hostname>.tail<random>.ts.net/health

# Check serve status
tailscale serve status --json
```

## Common Pitfalls
1. **`tailscale funnels` (plural) doesn't exist** — use `tailscale funnel` (singular)
2. **Tailscale CLI version matters** — older versions (<1.50) may have different syntax
3. **Funnel requires tailnet access** — the node must be connected to a tailnet
4. **HTTPS certs are auto-provisioned** by Tailscale — no manual cert setup needed
5. **`tailscale funnel` requires root** — commands fail with `Access denied: serve config denied`. Fix: `sudo tailscale funnel --bg <port>` or run `sudo tailscale set --operator=$USER` once to allow non-root usage.
6. **Running `tailscale funnel` replaces, not adds** — calling `tailscale funnel --bg <port>` replaces the current funnel config entirely. It does not add a second funnel target. There is only one Funnel URL per node.
