A Go application that acts as an HTTPS load balancer for remote endpoints accessible via Tailscale network.
-
Install
go install github.com/AndreZiviani/tsgw@latest
-
Setup OAuth Client - Follow the detailed steps in OAuth Setup below
-
Configure and Run
./tsgw \ --tailscale-domain "your-domain.ts.net" \ --oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \ --oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \ --route "app=http://app.internal:8080" \ --route "api=https://api.internal:3000"
-
Access
https://app.your-domain.ts.net→http://app.internal:8080https://api.your-domain.ts.net→https://api.internal:3000
- 🔐 Automatic Authentication: OAuth 2.0 with token refresh
- 🏗️ Isolated Architecture: One Tailscale node per route with dedicated Echo instance
- 🔒 Automatic HTTPS: Tailscale-managed certificates
- 🎯 Host-Based Routing: Route by Host header
- ⚡ Optimized Proxy: Pre-configured proxy setup (no per-request overhead)
- 📊 Observability: Optional OpenTelemetry support
- 🐳 Container Ready: Docker and Kubernetes support
- ⚙️ CLI-First Config: Environment variables and CLI flags only
TSGW requires OAuth client credentials to manage Tailscale devices via the API. Follow these steps to create the necessary tag and OAuth client:
- Open your Tailnet Policy File in the Tailscale admin console
- Add the following tags to the
tagOwnerssection:
{
"tagOwners": {
"tag:tsgw": [],
// ... your other tags
}
}- Save the policy file
- Go to OAuth Clients in the Tailscale admin console
- Click "Generate OAuth Client"
- Configure the client:
- Name:
TSGW Load Balancer(or any descriptive name) - Scopes: Select both:
- ✅ Devices (to create and manage Tailscale machines)
- ✅ Auth Keys (to generate authentication keys)
- Tags: Enter
tag:tsgw
- Name:
- Click "Generate Client"
- Copy and save the Client ID and Client Secret immediately (the secret won't be shown again)
Verify your OAuth client works:
# Test API access with your credentials
curl -u "tskey-client-xxxxx:tskey-secret-xxxxx" \
"https://api.tailscale.com/api/v2/tailnet/-/devices"If successful, you'll see a JSON response with your tailnet's devices.
## Configuration
TSGW uses a CLI-first configuration approach with environment variable support. No config files are supported.
### Required Configuration
```bash
# Required: Tailscale domain
--tailscale-domain "your-domain.ts.net" \
# Required: OAuth credentials
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
# Required: At least one route
--route "app=http://app.internal:8080"# Required
export TSGW_TAILSCALE_DOMAIN="your-domain.ts.net"
export TSGW_OAUTH_CLIENT_ID="tskey-client-xxxxxxxxxxxxxx"
export TSGW_OAUTH_CLIENT_SECRET="tskey-secret-xxxxxxxxxxxxxx"
# Routes - Option 1: Single environment variable with space-separated routes
export TSGW_ROUTES="app=http://app.internal:8080 api=https://api.internal:3000 web=http://web.internal:8080"
# Routes - Option 2: Individual environment variables (overrides TSGW_ROUTES if both are set)
export TSGW_ROUTE_APP="http://app.internal:8080"
export TSGW_ROUTE_API="https://api.internal:3000"
export TSGW_ROUTE_WEB="http://web.internal:8080"
# Optional
export TSGW_HOSTNAME="tsgw" # Base hostname for nodes
export TSGW_PORT=443 # Listen port
export TSGW_LOG_LEVEL="info" # trace, debug, info, warn, error
export TSGW_LOG_FORMAT="console" # console or json
export TSGW_SKIP_TLS_VERIFY="false" # Skip backend TLS verification
export TSGW_LISTEN_ADDRESS="" # Optional regular network listener
export TSGW_TSNET_DIR="./tsnet" # Tailscale state directory
export TSGW_FORCE_CLEANUP="false" # Force cleanup of existing state
# OpenTelemetry (optional)
export TSGW_OTEL_ENABLED="false"
export TSGW_OTEL_SERVICE_NAME="tsgw"
export TSGW_OTEL_ENDPOINT="localhost:4317"
export TSGW_OTEL_PROTOCOL="grpc"
export TSGW_OTEL_INSECURE="false"
# Pyroscope (optional)
export TSGW_PYROSCOPE_ENABLED="false"
export TSGW_PYROSCOPE_SERVER_ADDRESS="http://pyroscope:4040"
export TSGW_PYROSCOPE_APPLICATION_NAME="tsgw"
# Auth options (pick one)
export TSGW_PYROSCOPE_BASIC_AUTH_USER=""
export TSGW_PYROSCOPE_BASIC_AUTH_PASS=""
export TSGW_PYROSCOPE_AUTH_TOKEN=""
# Optional
export TSGW_PYROSCOPE_TENANT_ID=""
export TSGW_PYROSCOPE_PROFILE_TYPES="cpu,alloc_objects,alloc_space,inuse_objects,inuse_space"
export TSGW_PYROSCOPE_TAGS="env=prod,region=us-east-2"
export TSGW_PYROSCOPE_HEADERS="X-Scope-OrgID=tenant-a"
export TSGW_PYROSCOPE_UPLOAD_RATE="15s"
export TSGW_PYROSCOPE_DISABLE_GC_RUNS="false"./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "app=http://app.internal:8080" \
--route "api=https://api.internal:3000" \
--route "web=http://web.internal:8080" \
--log-level "info" \
--tsnet-dir "./tsnet"Routes are specified in name=backend_url format:
# Examples
--route "app=http://app.internal:8080"
--route "api=https://api.internal:3000"
--route "web=http://web.internal:8080"
--route "secure-app=https://secure-app.internal:8443"Each route creates:
- A dedicated Tailscale node:
app.your-domain.ts.net - A dedicated Echo instance for that route
- Pre-configured proxy to the backend URL
Priority: Environment Variables > CLI Flags
### Environment Variables
```bash
# Core
export TSGW_TAILSCALE_DOMAIN="your-domain.ts.net"
export TSGW_OAUTH_CLIENT_ID="tskey-client-xxx"
export TSGW_OAUTH_CLIENT_SECRET="tskey-secret-xxx"
export TSGW_TSNET_DIR="/persistent/path" # ⚠️ CRITICAL
# Routes
export TSGW_ROUTE_APP="http://app.internal:8080"
export TSGW_ROUTE_API="https://api.internal:3000"
# Optional
export TSGW_LOG_LEVEL="info"
export TSGW_OTEL_ENABLED="true"
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "xxx" \
--oauth-client-secret "xxx" \
--tsnet-dir "/persistent/path" \
--force-cleanup \
--route "app=http://app.internal:8080" \
--route "api=https://api.internal:3000"Priority: Environment Variables > CLI Flags
The tsnet_dir contains Tailscale machine state and MUST be persistent. Loss results in:
- Lost machine registrations
- Certificate renewal failures
- Service disruption
Always use persistent storage in production.
# Build
docker build -t tsgw .
# Run with persistent storage
docker run \
-e TSGW_TAILSCALE_DOMAIN="your-domain.ts.net" \
-e TSGW_OAUTH_CLIENT_ID="tskey-client-xxxxxxxxxxxxxx" \
-e TSGW_OAUTH_CLIENT_SECRET="tskey-secret-xxxxxxxxxxxxxx" \
-e TSGW_ROUTES="app=http://app.internal:8080 api=https://api.internal:3000" \
-e TSGW_TSNET_DIR="/app/tsnet" \
-v tsgw_data:/app/tsnet \
tsgwapiVersion: apps/v1
kind: Deployment
metadata:
name: tsgw
spec:
replicas: 1
selector:
matchLabels:
app: tsgw
template:
metadata:
labels:
app: tsgw
spec:
containers:
- name: tsgw
image: tsgw:latest
env:
- name: TSGW_TAILSCALE_DOMAIN
value: "your-domain.ts.net"
- name: TSGW_OAUTH_CLIENT_ID
valueFrom:
secretKeyRef:
name: tsgw-oauth
key: client-id
- name: TSGW_OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: tsgw-oauth
key: client-secret
- name: TSGW_TSNET_DIR
value: "/data/tsgw"
# Routes - Option 1: Single env var with space-separated routes
- name: TSGW_ROUTES
value: "app=http://app.internal:8080 api=https://api.internal:3000"
# Routes - Option 2: Individual route env vars (alternative)
# - name: TSGW_ROUTE_APP
# value: "http://app.internal:8080"
# - name: TSGW_ROUTE_API
# value: "https://api.internal:3000"
volumeMounts:
- name: tsgw-storage
mountPath: /data/tsgw
volumes:
- name: tsgw-storage
persistentVolumeClaim:
claimName: tsgw-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tsgw-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi# Basic usage
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "app=http://app.internal:8080"
# With multiple routes and options
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "app=http://app.internal:8080" \
--route "api=https://api.internal:3000" \
--route "web=http://web.internal:8080" \
--log-level "debug" \
--otel-enabled \
--otel-endpoint "localhost:4317"Machines removed from Tailscale console but cached locally
# Force cleanup of invalid state files
./tsgw --force-cleanup --log-level debug
# Or set environment variable
export TSGW_FORCE_CLEANUP=true
./tsgwMachines not appearing in Tailscale admin
# Check OAuth credentials with debug logging
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--log-level "debug"
# Verify API access
curl -H "Authorization: Bearer $(tailscale auth)" \
https://api.tailscale.com/api/v2/tailnet/-/devicesConnection failures
# Check tsnet directory permissions
ls -la ./tsnet/
# Verify backend connectivity
curl -v http://your-backend:8080/health
# Check certificates
openssl s_client -connect app.your-domain.ts.net:443High memory usage
# Check for certificate rotation issues
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--log-level "debug" 2>&1 | grep -i cert
# Monitor with observability
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--otel-enabled \
--otel-endpoint "localhost:4317"# Enable debug logging
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--log-level "debug"
# JSON format for production
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--log-level "info" \
--log-format "json"
# Check specific route
./tsgw \
--tailscale-domain "your-domain.ts.net" \
--oauth-client-id "tskey-client-xxxxxxxxxxxxxx" \
--oauth-client-secret "tskey-secret-xxxxxxxxxxxxxx" \
--route "test=http://httpbin.org" \
--log-level "trace"- Go 1.25.1+
- Tailscale account with OAuth client
- Persistent storage for production
MIT License - see LICENSE file.
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests
- Submit a pull request
For detailed development setup, see the original README or documentation.