Profile
Back to NewsBack
GitHub Trending 9 min
Reader Mode
rajsinghtech/tsflow:  Network flow visualizer for Tailscale -  Requires Premium TS plans

rajsinghtech/tsflow: Network flow visualizer for Tailscale - Requires Premium TS plans

16 hours ago

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:

  1. Go to OAuth clients in Tailscale Admin
  2. Create a new OAuth client with all:read scope
  3. Set TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET
API Key Setup:
  1. Go to API keys in Tailscale Admin
  2. Create a new API key
  3. 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_ID and TAILSCALE_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
tsnet mode serves on both port 80 (HTTP) and port 443 (HTTPS).

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..ts.net and http://tsflow..ts.net.

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:

  1. Stop tsflow.
  2. Replace the live database with the backup. For the path above, move tsflow.db.pre-tailnet to tsflow.db.
  3. Delete tsflow.db-wal and tsflow.db-shm if they exist, so the restored file is not opened with a write-ahead log from the new process.
  4. Start the previous release.
Rows saved after the upgrade are not in the backup. If startup cannot write the copy, it stops before changing the schema. Set 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

Star History Chart</a>

License

MIT


Built with ❤️ for the Tailscale community

Chat with me