Tailscale reverse proxy for multiple services from a single container
1.7K
A powerful Tailscale-native reverse proxy that allows you to expose multiple Docker containers as separate Tailscale devices from a single container. Built with tsnetā , the Go library that embeds Tailscale directly into applications.
Traditional Tailscale setups require one Tailscale container per service, leading to:
tsnet-proxy solves this by creating multiple Tailscale "devices" (one per service) from a single container, each with:
grafana.your-tailnet.ts.net, api.your-tailnet.ts.net, etc.)Clone the repository:
git clone https://github.com/NathanBhanji/tsnet-proxy.git
cd tsnet-proxy
Set your Tailscale credentials:
export TS_AUTHKEY=tskey-auth-YOUR-KEY-HERE
export TS_API_KEY=tskey-api-YOUR-API-KEY-HERE # Optional but recommended for instant device cleanup
export TS_TAILNET=yourtailnet.ts.net # Your tailnet name (required if using API key)
Start the stack:
docker-compose up -d
Access the management UI:
The UI will appear as a new device in your Tailscale admin panelā . Access it at:
http://tsnet-proxy-docker.your-tailnet.ts.net
That's it! The example configuration includes Grafana, which will be available at https://grafana.your-tailnet.ts.net.
When you stop the container (docker-compose down), all Tailscale devices are automatically deleted from your tailnet. This requires setting up the Tailscale API key:
export TS_API_KEY=tskey-api-YOUR-KEY
export TS_TAILNET=yourtailnet.ts.net # Or your email if personal
Without the API key, devices will still be cleaned up via ephemeral mode, but it may take a few minutes.
configs/services.yaml)# Global settings
authKey: "${TS_AUTHKEY}" # Tailscale auth key (from environment)
apiKey: "${TS_API_KEY}" # Tailscale API key for instant device deletion (optional)
tailnet: "${TS_TAILNET}" # Your tailnet name (required if apiKey is set)
stateDir: "/data/tsnet" # Persistent state directory
# Management UI
managementUI:
enabled: true
hostname: "tsnet-proxy-docker" # UI hostname on tailnet
port: 8080
# Prometheus metrics
metrics:
enabled: true
port: 9090
# Services to proxy
services:
- name: "grafana" # Tailscale hostname
backend: "http://grafana:3000" # Docker service URL
paths: [] # Empty = proxy all paths
stripPrefix: false
healthCheck:
enabled: true
path: "/api/health"
interval: 30s
timeout: 5s
unhealthyThreshold: 3
tls:
enabled: false # Backend uses HTTP
- name: "api" # Another service
backend: "http://api-server:8080"
paths:
- "/api" # Only proxy /api/* requests
stripPrefix: true # Remove /api prefix before forwarding
healthCheck:
enabled: true
path: "/health"
interval: 10s
timeout: 3s
unhealthyThreshold: 2
tls:
enabled: false
| Field | Description | Required |
|---|---|---|
name | Tailscale hostname (must be lowercase, alphanumeric, hyphens only) | Yes |
backend | Backend URL (e.g., http://service:port) | Yes |
paths | URL path prefixes to match (empty = match all) | No |
stripPrefix | Remove matched path prefix before forwarding | No |
healthCheck.enabled | Enable health checking | No |
healthCheck.path | Health check endpoint path | If enabled |
healthCheck.interval | Check interval (e.g., 30s, 1m) | No |
healthCheck.timeout | Request timeout | No |
healthCheck.unhealthyThreshold | Failures before marking unhealthy | No |
tls.enabled | Backend uses HTTPS | No |
tls.skipVerify | Skip TLS certificate verification (insecure) | No |
grafana)http://grafana:3000)/api, /grafana)The service will appear immediately in your Tailscale network!
Open the config tab in the management UI to edit configs/services.yaml in place and
click apply & reload. The proxy validates the YAML, rewrites the file atomically and
hot-reloads services: new services are started, removed services are stopped (and their
devices deleted from the tailnet), and services whose settings changed are restarted on the
same device. Changes to top-level settings (authKey, apiKey, tailnet, stateDir,
managementUI, metrics) are saved but need a restart to take effect ā the UI tells you which.
You can also edit the file by hand and restart:
docker-compose restart tsnet-proxy
Everything the dashboard does is available over plain JSON/YAML on the UI device
(http://tsnet-proxy-docker.<tailnet>.ts.net):
| Method | Path | Description |
|---|---|---|
GET | /api/services | List services (sorted by name) with health flag |
POST | /api/services | Add a service (JSON body, same shape as the list) |
GET | /api/services/{name} | One service |
DELETE | /api/services/{name} | Remove a service and delete its tailnet device |
GET | /api/health | `{ "": true |
GET | /api/config | Raw services.yaml as stored on disk (env placeholders intact) |
PUT | /api/config | Validate, save and hot-reload; returns {added, removed, updated, unchanged, restartRequired, errors} (HTTP 500 with the same body if any service failed) |
GET | /api/settings | Running config with secrets masked, plus the tailnet's MagicDNS suffix and the UI's own URL |
GET | /api/devices | The tsnet devices this proxy runs (UI + one per service): IP, DNS name, online state |
GET | /api/activity?limit=N | The most recent proxied requests (newest first, in-memory, last 200) |
GET | /api/metrics | Prometheus exposition (same data as the metrics port) |
The API has no authentication of its own ā it is only reachable from your tailnet, so use Tailscale ACLsā to restrict who can reach the UI device.
Route different paths to different backends:
services:
- name: "myapp"
backend: "http://api:8080"
paths: ["/api"]
stripPrefix: true
- name: "myapp-web"
backend: "http://frontend:3000"
paths: ["/"]
stripPrefix: false
Access patterns:
https://myapp.tailnet.ts.net/api/users ā http://api:8080/users (prefix stripped)https://myapp-web.tailnet.ts.net/ ā http://frontend:3000/Health checks automatically mark services unhealthy after consecutive failures:
healthCheck:
enabled: true
path: "/health" # Endpoint to check
interval: 30s # Check every 30 seconds
timeout: 5s # Fail if no response in 5s
unhealthyThreshold: 3 # Mark unhealthy after 3 failures
Unhealthy services return 503 Service Unavailable until they recover.
For backends using HTTPS:
tls:
enabled: true
skipVerify: false # Set true for self-signed certs (insecure)
Metrics are exposed at http://localhost:9090/metrics (inside the container) and, for
the management dashboard, at /api/metrics on the UI device:
# Request metrics (one sample per proxied request, including 503/404s raised by the proxy)
tsnet_proxy_requests_total{service, method, status}
tsnet_proxy_request_duration_seconds{service, method}
# Health metrics
tsnet_proxy_service_health{service} # 1 = healthy, 0 = unhealthy
scrape_configs:
- job_name: 'tsnet-proxy'
static_configs:
- targets: ['tsnet-proxy:9090']
Import the included Grafana dashboard (coming soon) or create custom panels:
version: '3.8'
services:
tsnet-proxy:
image: bhanji/tsnet-proxy:latest
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_API_KEY=${TS_API_KEY} # Optional: for instant device cleanup
- TS_TAILNET=${TS_TAILNET} # Required if using API key
volumes:
- ./configs/services.yaml:/app/configs/services.yaml:rw
- tsnet-data:/data/tsnet
networks:
- services
myapp:
image: myapp:latest
networks:
- services
networks:
services:
volumes:
tsnet-data:
services:
tsnet-proxy:
# ... (same as above)
grafana:
image: grafana/grafana:latest
networks:
- services
prometheus:
image: prom/prometheus:latest
networks:
- services
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
networks:
- services
Then add each service via the UI or config file!
tls.skipVerify: false for production backendsdocker-compose logs tsnet-proxyecho $TS_AUTHKEYmanagementUI.enabled: true in confighttp://100.x.x.x (the UI listens on port 80 of its own tailnet device)docker exec tsnet-proxy ping backend-service# Clone repository
git clone https://github.com/NathanBhanji/tsnet-proxy.git
cd tsnet-proxy
# Install dependencies
go mod download
# Build binary
go build -o tsnet-proxy ./cmd/tsnet-proxy
# Run locally
export TS_AUTHKEY=tskey-auth-YOUR-KEY
./tsnet-proxy --config configs/services.yaml
tsnet-proxy/
āāā cmd/
ā āāā tsnet-proxy/
ā āāā main.go # Application entry point
āāā internal/
ā āāā activity/ # In-memory ring buffer of recent requests
ā āāā config/ # Configuration parsing
ā āāā manager/ # Service lifecycle management + request observers
ā āāā health/ # Health checking system
ā āāā ui/ # Web UI and API
ā āāā metrics/ # Prometheus metrics
āāā configs/
ā āāā services.yaml # Default configuration
āāā Dockerfile # Container image
āāā docker-compose.yml # Docker Compose setup
āāā README.md # This file
Contributions are welcome! Please:
MIT License - see LICENSEā file for details
Made with ā¤ļø for the Tailscale community
Content type
Image
Digest
sha256:f8140eebcā¦
Size
13 MB
Last updated
about 2 months ago
docker pull bhanji/tsnet-proxy