Sign inSign up

bhanji/tsnet-proxy

By bhanji

•Updated about 2 months ago

Tailscale reverse proxy for multiple services from a single container

Image
0

1.7K

bhanji/tsnet-proxy repository overview

⁠tsnet-proxy

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.

⁠Features

  • šŸš€ Multi-Service Support - Proxy multiple backend services from a single container
  • šŸ”’ Zero-Trust Security - Each service appears as a separate Tailscale device with independent ACLs
  • šŸŽ›ļø Web UI - Manage services through a beautiful web interface
  • ā¤ļø Health Checks - Automatic health monitoring with circuit breaker pattern
  • šŸ“Š Prometheus Metrics - Built-in metrics for monitoring and alerting
  • šŸ›£ļø Path-Based Routing - Route specific URL paths to different backends
  • šŸ” TLS Backend Support - Proxy to HTTPS backends with cert verification options
  • šŸ“ Dynamic Configuration - Add/remove services without restarting
  • 🐳 Docker Native - Access containers on Docker network seamlessly

⁠Why tsnet-proxy?

Traditional Tailscale setups require one Tailscale container per service, leading to:

  • Container sprawl (10+ sidecar containers)
  • Complex docker-compose configurations
  • Resource overhead
  • Management headaches

tsnet-proxy solves this by creating multiple Tailscale "devices" (one per service) from a single container, each with:

  • Unique hostname (grafana.your-tailnet.ts.net, api.your-tailnet.ts.net, etc.)
  • Independent TLS certificates
  • Separate ACL policies
  • Health monitoring

⁠Quick Start

⁠Prerequisites
  1. A Tailscale account⁠
  2. Docker and Docker Compose installed
  3. A Tailscale auth key (generate one here⁠)
⁠Installation
  1. Clone the repository:

    git clone https://github.com/NathanBhanji/tsnet-proxy.git
    cd tsnet-proxy
    
  2. 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)
    
  3. Start the stack:

    docker-compose up -d
    
  4. 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.

⁠Device Cleanup on Shutdown

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:

  1. Create an API key at https://login.tailscale.com/admin/settings/keys⁠
  2. Set the environment variables:
    export TS_API_KEY=tskey-api-YOUR-KEY
    export TS_TAILNET=yourtailnet.ts.net  # Or your email if personal
    
  3. Restart the container - devices will now be instantly removed on shutdown

Without the API key, devices will still be cleaned up via ephemeral mode, but it may take a few minutes.

⁠Configuration

⁠Config File (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
⁠Service Configuration Options
FieldDescriptionRequired
nameTailscale hostname (must be lowercase, alphanumeric, hyphens only)Yes
backendBackend URL (e.g., http://service:port)Yes
pathsURL path prefixes to match (empty = match all)No
stripPrefixRemove matched path prefix before forwardingNo
healthCheck.enabledEnable health checkingNo
healthCheck.pathHealth check endpoint pathIf enabled
healthCheck.intervalCheck interval (e.g., 30s, 1m)No
healthCheck.timeoutRequest timeoutNo
healthCheck.unhealthyThresholdFailures before marking unhealthyNo
tls.enabledBackend uses HTTPSNo
tls.skipVerifySkip TLS certificate verification (insecure)No

⁠Usage

⁠Adding Services via UI
  1. Navigate to the management UI
  2. Click "Add Service"
  3. Fill in the form:
    • Service Name: The Tailscale hostname (e.g., grafana)
    • Backend URL: Docker service URL (e.g., http://grafana:3000)
    • Paths: Optional path prefixes (e.g., /api, /grafana)
    • Configure health checks and TLS as needed
  4. Click "Add Service"

The service will appear immediately in your Tailscale network!

⁠Editing the Config File

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
⁠Management API

Everything the dashboard does is available over plain JSON/YAML on the UI device (http://tsnet-proxy-docker.<tailnet>.ts.net):

MethodPathDescription
GET/api/servicesList services (sorted by name) with health flag
POST/api/servicesAdd 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/configRaw services.yaml as stored on disk (env placeholders intact)
PUT/api/configValidate, save and hot-reload; returns {added, removed, updated, unchanged, restartRequired, errors} (HTTP 500 with the same body if any service failed)
GET/api/settingsRunning config with secrets masked, plus the tailnet's MagicDNS suffix and the UI's own URL
GET/api/devicesThe tsnet devices this proxy runs (UI + one per service): IP, DNS name, online state
GET/api/activity?limit=NThe most recent proxied requests (newest first, in-memory, last 200)
GET/api/metricsPrometheus 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.

⁠Path-Based Routing Example

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

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.

⁠HTTPS Backends

For backends using HTTPS:

tls:
  enabled: true
  skipVerify: false              # Set true for self-signed certs (insecure)

⁠Monitoring

⁠Prometheus Metrics

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
⁠Sample Prometheus Config
scrape_configs:
  - job_name: 'tsnet-proxy'
    static_configs:
      - targets: ['tsnet-proxy:9090']
⁠Grafana Dashboard

Import the included Grafana dashboard (coming soon) or create custom panels:

  • Request rate by service
  • P95 latency
  • Error rate
  • Service health status

⁠Docker Compose Examples

⁠Basic Setup
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:
⁠Multiple Services
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!

⁠Security Best Practices

  1. Use reusable auth keys: Generate non-ephemeral keys for persistent devices
  2. Set up ACLs: Configure Tailscale ACLs⁠ to restrict access
  3. Don't expose ports: Let tsnet-proxy handle all access, don't bind backend ports
  4. Verify TLS certs: Set tls.skipVerify: false for production backends
  5. Use health checks: Enable health checking to automatically stop routing to failed services
  6. Monitor metrics: Set up alerting on health and error rate metrics

⁠Troubleshooting

⁠Service not appearing in Tailscale
  1. Check logs: docker-compose logs tsnet-proxy
  2. Verify auth key is set: echo $TS_AUTHKEY
  3. Check Tailscale state directory has correct permissions
  4. Wait 30-60 seconds for initial connection to complete
⁠Health checks failing
  1. Verify backend URL is correct and accessible from container
  2. Check health endpoint returns 2xx/3xx status code
  3. Adjust timeout/interval if backend is slow
  4. View health status in management UI
⁠Cannot access management UI
  1. Check managementUI.enabled: true in config
  2. Verify UI device appears in Tailscale admin panel
  3. Try accessing via Tailscale IP: http://100.x.x.x (the UI listens on port 80 of its own tailnet device)
  4. Check logs for UI startup errors
⁠Backend connection errors
  1. Verify backend container is on same Docker network
  2. Test connectivity: docker exec tsnet-proxy ping backend-service
  3. Check backend logs for errors
  4. Verify TLS settings match backend (HTTP vs HTTPS)

⁠Development

⁠Building from Source
# 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
⁠Project Structure
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

⁠Roadmap

  • WebSocket support
  • Load balancing (multiple backends per service)
  • Rate limiting
  • Request/response logging
  • Grafana dashboard template
  • Hot reload configuration
  • CLI for management
  • Helm chart for Kubernetes

⁠Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Add tests if applicable
  5. Submit a pull request

⁠License

MIT License - see LICENSE⁠ file for details

⁠Acknowledgments

⁠Support


Made with ā¤ļø for the Tailscale community

Tag summary

Content type

Image

Digest

sha256:f8140eebc…

Size

13 MB

Last updated

about 2 months ago

docker pull bhanji/tsnet-proxy