TSFlow - Tailscale Network Flow Visualizer
A real-time network traffic visualization dashboard for Tailscale networks. Monitor device connectivity, analyze bandwidth usage, and explore network flows with an interactive graph interface.
Installation
Homebrew (macOS/Linux)
brew install rajsinghtech/tap/tsflow
Docker
docker pull ghcr.io/rajsinghtech/tsflow:latest
Binary Download
Download from GitHub Releases.
Quick Start
Note: TSFlow requires Tailscale Network Flow Logs (Premium/Enterprise plans). Enable it in your Tailscale admin console.
Run with Homebrew
export TAILSCALE_OAUTH_CLIENT_ID=your-client-id
export TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret
tsflow
Open http://localhost:8080
Run with Docker
docker run -d \
--name tsflow \
-p 8080:8080 \
-v tsflow_data:/app/data \
-e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
-e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
ghcr.io/rajsinghtech/tsflow:latest
Configuration
Authentication
TSFlow supports OAuth (recommended) or API key authentication.
OAuth Setup:
- Go to OAuth clients in Tailscale Admin
- Create a new OAuth client with
all:readscope - Set
TAILSCALE_OAUTH_CLIENT_IDandTAILSCALE_OAUTH_CLIENT_SECRET
- Go to API keys in Tailscale Admin
- Create a new API key
- Set
TAILSCALE_API_KEY
Several tailnets
The single-tailnet environment variables configure one tailnet with id default. To watch more than one, leave TAILSCALE_TAILNET, TAILSCALE_API_KEY, and the OAuth client variables unset, and set TSFLOW_TAILNETS_FILE to a YAML or JSON file. Secrets are not written in the file. Each entry points at an environment variable or a file.
Data routes take an optional tailnet query parameter. With one configured tailnet the parameter can be omitted, and the JSON matches a single-tailnet install. With several tailnets, a missing parameter uses id default when that id is configured. If it is not, the response is 400 and lists the valid ids. An unknown id is 404. GET /api/tailnets returns each id, display name, and poller status, including the last error. It does not return credentials. The UI does not send the parameter yet, so it follows those rules and shows default when that id exists.
tailnets:
- id: default
tailnet: example.com
api_key_env: DEFAULT_TAILSCALE_API_KEY
s3_prefix: network/
- id: lab
tailnet: lab.example.com
oauth_client_id_file: /secrets/lab/client-id
oauth_client_secret_file: /secrets/lab/client-secret
s3_prefix: lab/network/
s3_prefix is optional. When it is omitted, the tailnet uses TSFLOW_S3_PREFIX. api_url and oauth_scopes are optional too. oauth_scopes is a comma-separated string or a list. An entry needs either api_key_env or api_key_file, or both OAuth client id and secret. Do not set both an environment variable and a file for the same secret.
JSON uses the same fields:
{"tailnets":[{"id":"default","tailnet":"example.com","api_key_env":"DEFAULT_TAILSCALE_API_KEY"}]}
Environment Variables
Tailscale Authentication
| Variable | Description | Default |
|----------|-------------|---------|
| TAILSCALE_OAUTH_CLIENT_ID | OAuth client ID | - |
| TAILSCALE_OAUTH_CLIENT_SECRET | OAuth client secret | - |
| TAILSCALE_OAUTH_SCOPES | OAuth scopes (comma-separated) | all:read |
| TAILSCALE_API_KEY | API key (alternative to OAuth) | - |
| TAILSCALE_TAILNET | Tailnet name (- for auto-detect) | - |
| TAILSCALE_API_URL | API endpoint | https://api.tailscale.com |
| TSFLOW_TAILNETS_FILE | YAML or JSON list of tailnets. Do not combine with the single-tailnet variables above. | - |
Server Settings
| Variable | Description | Default |
|----------|-------------|---------|
| PORT | Server port | 8080 |
| ENVIRONMENT | development or production | development |
tsnet Serve Mode
TSFlow can embed a Tailscale node and serve itself directly on your tailnet, eliminating the need for a separate Tailscale sidecar container.
| Variable | Description | Default |
|----------|-------------|---------|
| TSFLOW_SERVE | Enable tsnet serve mode | false |
| TSFLOW_HOSTNAME | MagicDNS hostname on the tailnet | tsflow |
| TSFLOW_TAGS | Comma-separated ACL tags (e.g. tag:tsflow) | - |
| TSFLOW_FUNNEL | Expose via Tailscale Funnel. Refused when more than one tailnet is configured. | false |
| TSFLOW_STATE_DIR | tsnet state persistence directory | ./data/tsnet-state |
##### Workload Identity Federation
tsnet mode supports workload identity federation as an alternative to OAuth secrets. This lets tsflow authenticate using platform identity (AWS, GCP, GitHub Actions, Azure) without managing secrets.
| Variable | Description | Default |
|----------|-------------|---------|
| TS_CLIENT_ID | Federated client ID | - |
| TS_ID_TOKEN | ID token from identity provider | - |
| TS_AUDIENCE | Audience for requesting platform tokens | - |
When TS_CLIENT_ID is set, tsflow uses WIF instead of OAuth ClientSecret for the tsnet node. The platform token is auto-detected from the runtime environment. Set either TS_ID_TOKEN or TS_AUDIENCE, not both. You must also set TSFLOW_TAGS.
Note: OAuth credentials (TAILSCALE_OAUTH_CLIENT_IDandTAILSCALE_OAUTH_CLIENT_SECRET) are still required for Tailscale API access (fetching devices, network logs). WIF only replaces the tsnet node authentication secret.
Requirements:
- OAuth credentials or workload identity federation (API keys are not supported in tsnet mode)
- ACL tags must be allowed for the OAuth client or federated identity to register nodes
- For Funnel, the ACL must grant funnel access to the tag
Example with OAuth:
docker run -d \
--name tsflow \
-v tsflow_data:/app/data \
-e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
-e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
-e TSFLOW_SERVE=true \
-e TSFLOW_HOSTNAME=tsflow \
-e TSFLOW_TAGS=tag:tsflow \
ghcr.io/rajsinghtech/tsflow:latest
Example with Workload Identity (GCP):
docker run -d \
--name tsflow \
-v tsflow_data:/app/data \
-e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
-e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
-e TSFLOW_SERVE=true \
-e TSFLOW_HOSTNAME=tsflow \
-e TSFLOW_TAGS=tag:tsflow \
-e TS_CLIENT_ID=your-federated-client-id \
-e TS_AUDIENCE=your-tailnet.org \
ghcr.io/rajsinghtech/tsflow:latest
TSFlow will be accessible at both https://tsflow. and http://tsflow..
Data Storage & Polling
| Variable | Description | Default |
|----------|-------------|---------|
| TSFLOW_DB_PATH | SQLite database path | ./data/tsflow.db |
| TSFLOW_SKIP_DB_BACKUP | Skip the pre-migration database copy. Set 1 only when disk space is tight. | unset |
| TSFLOW_POLL_INTERVAL | How often to import new flow logs | 5m |
| TSFLOW_INITIAL_BACKFILL | How far back to fetch logs on startup | 6h |
| TSFLOW_RETENTION | How long to keep flow data. Set 0 to disable cleanup. | 720h for API mode, disabled for S3 mode |
| TSFLOW_FLOW_BACKEND | Flow backend: api or s3 | api |
| TSFLOW_S3_BUCKET | S3/Garage bucket containing exported flow logs | tailscale-logs |
| TSFLOW_S3_PREFIX | Object prefix for network flow objects | network/ |
| TSFLOW_S3_ENDPOINT | S3-compatible endpoint URL | - |
| TSFLOW_S3_REGION | S3 region | garage |
| TSFLOW_S3_ACCESS_KEY_ID | S3 access key ID | - |
| TSFLOW_S3_SECRET_ACCESS_KEY | S3 secret access key | - |
| TSFLOW_S3_LOOKBACK | Object-store listing overlap for late objects | 15m |
| TSFLOW_S3_MAX_OBJECTS_PER_POLL | Max objects imported per poll cycle | 500 |
Data Storage
TSFlow stores per-minute flow aggregates in SQLite with a rolling retention window (default 30 days). Charts over wider windows use query-time bucketing — no data loss from pre-aggregation. When TSFLOW_FLOW_BACKEND=s3, TSFlow imports immutable network/YYYY/MM/DD/.ndjson, .ndjson.zst, .ndjson.zstd, .ndjson.gz, or *.ndjson.gzip objects from S3-compatible storage and tracks ingested object keys so repeated polling does not double count traffic.
Raw flow-log endpoints are deprecated because raw events are not retained: use /api/flow-logs/aggregated for historical traffic. The legacy /api/flow-logs and /api/devices/:deviceId/flows routes return 410 Gone with the replacement endpoint.
Mount a volume to persist data: -v tsflow_data:/app/data
Rolling back to the previous release
This release changes the SQLite schema. Before it does, startup writes a full copy of the database beside the live file. For /app/data/tsflow.db the copy is /app/data/tsflow.db.pre-tailnet. The log names that path. The copy needs about as much free disk as the database file, on top of the temporary space the migration uses while it rewrites tables.
The previous release cannot write the migrated file. Its inserts target the old primary keys, and it reads the poll cursor from poll_state.id = 1, which the new schema does not have. To run that release again:
- Stop tsflow.
- Replace the live database with the backup. For the path above, move
tsflow.db.pre-tailnettotsflow.db. - Delete
tsflow.db-walandtsflow.db-shmif they exist, so the restored file is not opened with a write-ahead log from the new process. - Start the previous release.
TSFLOW_SKIP_DB_BACKUP=1 to migrate without a rollback copy. The log says when that happens. Keep a backup of your own if you still want a way back.
Development
Setup
git clone https://github.com/rajsinghtech/tsflow.git
cd tsflow
Install dependencies
cd frontend && npm install && cd ..
cd backend && go mod download && cd ..
Development Mode
Run backend and frontend separately for hot reload:
# Terminal 1: Backend (no embedded frontend)
make dev-backend
Terminal 2: Frontend with Vite dev server
make dev-frontend
Frontend runs on http://localhost:5173 and proxies /api to backend on :8080.
Production Build
make build
./backend/tsflow
This builds the SvelteKit frontend and embeds it in the Go binary.
Deployment
Docker Compose
services:
tsflow:
image: ghcr.io/rajsinghtech/tsflow:latest
ports:
- "8080:8080"
environment:
- TAILSCALE_OAUTH_CLIENT_ID=${TAILSCALE_OAUTH_CLIENT_ID}
- TAILSCALE_OAUTH_CLIENT_SECRET=${TAILSCALE_OAUTH_CLIENT_SECRET}
volumes:
- tsflow_data:/app/data
restart: unless-stopped
volumes:
tsflow_data:
Kubernetes
cd k8s
Requires envsubst (provided by gettext). Export the variables referenced by
secret.yaml, then expand the template before applying the rendered manifests.
kubectl kustomize . | envsubst | kubectl apply -f -
Alternatively, replace the ${...} placeholders in k8s/secret.yaml with
your credentials and run kubectl apply -k k8s directly. Do not apply the
template unchanged: Kubernetes does not expand shell-style environment
variables in manifests.
Star History
License
MIT
Built with ❤️ for the Tailscale community