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.
brew install rajsinghtech/tap/tsflowdocker pull ghcr.io/rajsinghtech/tsflow:latestghcr.io/rajsinghtech/tsflow:main tracks main and isn't a release.
Download from GitHub Releases.
Note: TSFlow requires Tailscale Network Flow Logs (Premium/Enterprise plans). Enable it in your Tailscale admin console.
export TAILSCALE_OAUTH_CLIENT_ID=your-client-id
export TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret
tsflowOpen http://localhost:8080
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:latestAll of these use made-up demo data.
Analytics: top talkers, ports, protocols and traffic types for the selected window.
Me: your own devices, top peers and new connections. Shows up when tsflow knows who you are (see Internal access control).
Time picker: presets, free text like last 3 hours or since 9am, local or UTC.
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
API Key Setup:
- Go to API keys in Tailscale Admin
- Create a new API key
- Set
TAILSCALE_API_KEY
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. The display name is the entry's display_name when one is set, and the tailnet name otherwise. With a display name, the entry also has tailnet, the tailnet name. It does not return credentials. The UI calls GET /api/tailnets once on load. With one tailnet it shows no switcher and leaves data requests unchanged. With several, a header switcher keeps the choice in the tailnet query parameter and sends that id on data requests.
Tailnet switcher: pick a tailnet from the header. Each entry shows its display_name, with the tailnet name and id underneath. Without a display_name, the tailnet name is the label.
tailnets:
- id: default
tailnet: example.com
api_key_env: DEFAULT_TAILSCALE_API_KEY
s3_prefix: network/
- id: lab
tailnet: lab.example.com
display_name: Lab
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. prefix is the same field. api_url and oauth_scopes are optional too. display_name is an optional label for the header switcher, such as Lab. The switcher shows it with the tailnet name and id underneath. displayName is the same field. Without it, the switcher shows the tailnet name with the id underneath. 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. An auth block (type: oauth, api_key, or wif) is the other way to set credentials. Do not combine it with the flat credential fields. See docs/workload-identity.md.
An entry can also say where its flow logs live. flow_backend is api, s3, or gcs. bucket, region, endpoint, and s3_auth (static, aws_default, or gcs_adc) override the matching process settings. A field that is left out inherits the process value, so existing files that only set s3_prefix stay valid. role_arn with web_identity_token_file assumes that AWS role from the OIDC token in the file (AssumeRoleWithWebIdentity). That client does not read AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE, so two tailnets can use two token files. Static object-store keys stay on the process.
tailnets:
- id: prod
tailnet: prod.example.com
api_key_env: PROD_TAILSCALE_API_KEY
flow_backend: gcs
bucket: example-prod-flow-logs
prefix: network/
- id: staging
tailnet: staging.example.com
api_key_env: STAGING_TAILSCALE_API_KEY
flow_backend: s3
s3_auth: aws_default
bucket: example-staging-flow-logs
region: us-east-1
role_arn: arn:aws:iam::123456789012:role/tsflow-reader
web_identity_token_file: /var/run/tsflow/staging/gcp-token
prefix: staging/network/
- id: lab
tailnet: lab.example.com
api_key_env: LAB_TAILSCALE_API_KEY
flow_backend: apiprod reads a GCS bucket with Application Default Credentials. staging assumes a role in another account with a Google-issued ID token. lab has no streaming bucket and uses the Tailscale API logs endpoint.
JSON uses the same fields:
{"tailnets":[{"id":"default","tailnet":"example.com","api_key_env":"DEFAULT_TAILSCALE_API_KEY"}]}docs/environment.md lists every variable tsflow reads, including the older names that still work. A test keeps that list in sync with the code. The tables below cover the common ones.
| 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_AUTH |
oauth, api_key, or wif. Unset keeps the historical choice. |
- |
TAILSCALE_WIF_CLIENT_ID |
Federated client id for API calls. Requires TAILSCALE_AUTH=wif. |
- |
TAILSCALE_WIF_ID_TOKEN |
OIDC token for API workload identity. One token source only. | - |
TAILSCALE_WIF_ID_TOKEN_FILE |
File containing that OIDC token. | - |
TAILSCALE_WIF_AUDIENCE |
Audience used to request a platform token when no token is supplied. | - |
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. | - |
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 8080 |
ENVIRONMENT |
development or production |
development |
ALLOWED_CORS_ORIGINS |
Comma-separated origins allowed to call the API cross-origin. Unset, production allows none and development allows only loopback origins such as http://localhost:3000 |
unset |
TSFLOW_TRUSTED_PROXIES |
Comma-separated proxy IPs or CIDRs allowed to set the client address with X-Forwarded-For. That address keys the API rate limit (100 requests a minute) and appears in request logs. Unset means the TCP peer is used, so set this when tsflow runs behind a reverse proxy or ingress. Header access mode uses TSFLOW_ACCESS_TRUSTED_PROXIES when this is unset. |
- |
TSFLOW_MCP_ENABLED |
Serve a read-only MCP endpoint at /mcp. Off unless set to true or 1. |
false |
TSFLOW_LOG_LEVEL |
debug logs every denied request instead of one line per peer and reason a minute. |
- |
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 |
TSFLOW_HEALTH_PORT |
Also serve GET /health, and nothing else, on this plain port. For Kubernetes probes, which cannot reach the tailnet. Only valid with TSFLOW_SERVE=true. |
- |
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_ID_TOKEN_FILE |
File holding the ID token, read once at startup. For tokens that a sidecar or init container writes. | - |
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 exactly one of TS_ID_TOKEN, TS_ID_TOKEN_FILE, or TS_AUDIENCE. You must also set TSFLOW_TAGS.
tsnet WIF registers the embedded node only. API calls and the flow-log bucket have their own opt-in modes, described in docs/workload-identity.md. TAILSCALE_AUTH=wif is the API mode. TSFLOW_S3_AUTH=aws_default is the bucket mode. Leaving both unset keeps OAuth, API key, and static S3 keys.
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:latestExample 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:latestTSFlow will be accessible at both https://tsflow.<your-tailnet>.ts.net and http://tsflow.<your-tailnet>.ts.net.
Access control is off unless you set it. With the variables below unset, every request behaves as it does today, including GET /api/tailnets and the data routes. Health checks stay open either way: GET /health and GET /api/health.
When it is on, a viewer needs a Tailscale application capability, or membership in a mapped group, unless you set TSFLOW_ACCESS_GRANTS=identity. That setting is explicit: any resolved WhoIs identity, or a Tailscale-User-Login header from a trusted proxy, can see every configured tailnet, and no identity is 403. whoami and autoscope still work. Unset, or required, still requires a capability or a group grant, including when TSFLOW_ACCESS_MODE is set. The capability name is TSFLOW_ACCESS_CAPABILITY. The example below uses example.com/cap/tsflow. Each grant is a JSON object. The only field is optional:
{"tailnets": ["default", "lab"]}Omit tailnets, or include "*", to allow every configured tailnet id. Those ids are the ones from TSFLOW_TAILNETS_FILE (default when you use the single-tailnet environment variables). A request with no matching capability and no mapped group is 403 with missing access grant. GET /api/tailnets lists only the tailnets that grant allows. Other data routes return 403 for a tailnet the grant does not include. If several grants match, the tailnet lists are unioned. One grant that allows every tailnet allows every tailnet.
Identity is logged per request only when TSFLOW_LOG_LEVEL=debug.
A denied request always logs one line with the reason, the TCP peer (peer=, the r.RemoteAddr that trusted-proxy checks use), the raw X-Forwarded-For value, and in header mode whether the peer matched TSFLOW_ACCESS_TRUSTED_PROXIES (trusted_proxy=). The client IP in the request log comes from X-Forwarded-For only when the peer is a trusted proxy (TSFLOW_TRUSTED_PROXIES), so it can differ from the peer. Each peer and reason pair is logged at most once a minute, or on every request with TSFLOW_LOG_LEVEL=debug. Identity headers are not logged.
There are two front doors.
tsnet. TSFLOW_SERVE=true with TSFLOW_ACCESS_CAPABILITY set. tsflow is already a node on the tailnet. Each request calls LocalAPI WhoIs on the connection's remote address and reads that peer's capability map. You can leave TSFLOW_ACCESS_MODE unset. Set it to tsnet if you want to be explicit. TSFLOW_SERVE must be true for that mode. Do not enable Funnel for this, because Funnel traffic has no tailnet identity and those requests are denied.
Example grant in the tailnet policy file:
The empty object allows every configured tailnet. group:ops can see all of them. group:eng can see default and lab.
Reverse proxy. Use this when Tailscale Serve, or Caddy with the Tailscale plugin, already authenticated the viewer. Set TSFLOW_ACCESS_MODE=header and TSFLOW_ACCESS_TRUSTED_PROXIES to the proxy CIDRs. Identity headers are trusted only from those CIDRs. If the proxy dials localhost over IPv6, include ::1/128 next to 127.0.0.1/32. The same rule applies to the groups header. A client that is not in the list cannot supply Tailscale-User-Login, Tailscale-User-Name, the capability header, or the groups header. Those values are ignored, and the request is denied.
Tailscale Serve sends Tailscale-User-Login and Tailscale-User-Name. If your proxy sends identity under other names, set TSFLOW_ACCESS_USER_HEADER (login, for example X-Tailscale-User) and TSFLOW_ACCESS_NAME_HEADER (display name, for example X-Tailscale-Name). Point them at headers the proxy always overwrites, never at one a client can pass through. A custom header replaces the default for that field, and the default name is not read as a fallback. To also send capabilities, pass --accept-app-caps with the same capability name. Serve puts them in Tailscale-App-Capabilities. Override that name with TSFLOW_ACCESS_CAPABILITY_HEADER if your proxy uses another header.
tailscale serve --accept-app-caps=example.com/cap/tsflow --https=443 http://127.0.0.1:8080TSFLOW_ACCESS_MODE=header
TSFLOW_ACCESS_CAPABILITY=example.com/cap/tsflow
TSFLOW_ACCESS_TRUSTED_PROXIES=127.0.0.1/32If a local tailscaled socket is reachable, header mode calls WhoIs on the X-Forwarded-For peer instead of trusting the identity and capability headers. The right-most forwarded address, across every X-Forwarded-For line, is the peer. If that entry is not an IP address the request is denied. Set TSFLOW_ACCESS_LOCAL_WHOIS=off to always trust headers. Set it to require, or set TSFLOW_ACCESS_TAILSCALED_SOCKET, when a missing socket should stop startup. The default is auto: use WhoIs when the socket answers, and headers when it does not.
Some proxies forward identity and a groups header, and do not forward app capabilities. Set TSFLOW_ACCESS_GROUPS_HEADER to that header name. Values are comma-separated and must match the keys in the grant map exactly. Load the map from TSFLOW_ACCESS_GROUP_GRANTS or TSFLOW_ACCESS_GROUP_GRANTS_FILE, not both. The values use the same grant object as the capability. A mapped group grants access. If a request has both a capability and mapped groups, the tailnet lists are unioned.
{"group:eng": {"tailnets": ["default"]}, "group:ops": {}, "ops@example.com": {"tailnets": ["lab"]}}Caddy, using the Tailscale plugin for identity. tailscale_user is the full login and tailscale_name is the display name. If the proxy also sends a groups header, forward that header under the name you set in TSFLOW_ACCESS_GROUPS_HEADER.
{
order tailscale_auth before reverse_proxy
}
:443 {
bind tailscale/tsflow
tls {
get_certificate tailscale
}
tailscale_auth
reverse_proxy 127.0.0.1:8080 {
header_up Tailscale-User-Login {http.auth.user.tailscale_user}
header_up Tailscale-User-Name {http.auth.user.tailscale_name}
}
}TSFLOW_ACCESS_MODE=header
TSFLOW_ACCESS_TRUSTED_PROXIES=127.0.0.1/32
TSFLOW_ACCESS_GROUPS_HEADER=X-Tsflow-Groups
TSFLOW_ACCESS_GROUP_GRANTS={"group:eng":{"tailnets":["default"]},"group:ops":{}}On Kubernetes, keep the Service reachable only from the proxy, and set the trusted CIDR to that proxy range. The default manifests in k8s/ do not enable this. k8s/access-example.yaml is a starting point:
env:
- name: TSFLOW_ACCESS_MODE
value: header
- name: TSFLOW_ACCESS_CAPABILITY
value: example.com/cap/tsflow
- name: TSFLOW_ACCESS_TRUSTED_PROXIES
value: 10.0.0.0/8
- name: TSFLOW_ACCESS_GROUPS_HEADER
value: X-Tsflow-Groups
- name: TSFLOW_ACCESS_GROUP_GRANTS
value: '{"group:eng":{"tailnets":["default"]}}'
- name: TSFLOW_ACCESS_AUTOSCOPE
value: userStartup fails when the settings disagree. Header mode without trusted CIDRs is rejected. A capability with neither TSFLOW_SERVE nor header mode is rejected. A groups header without a grant map is rejected.
GET /api/whoami returns the viewer when a grant matched. With access control off it returns {"autoscope":"off"}.
TSFLOW_ACCESS_AUTOSCOPE is off by default. user preselects devices whose user matches the viewer login. groups preselects devices for the mapped groups the viewer is in: group:eng and eng match tag tag:eng, and a mapping key that contains @ matches devices owned by that login. This is only the initial traffic view. The filter panel has a My devices chip with Clear. Clearing it shows the full view. The API does not enforce the device filter.
| 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_POLL_DELAY |
API backend only. Each poll fetches logs up to now minus this delay, because the logs API can publish a message a little after its logged time and a window is never re-read. Set 0 to poll up to now. S3 and GCS polling use TSFLOW_S3_LOOKBACK instead. |
2m |
TSFLOW_GRAPH_CACHE_MB |
Memory cap, in MiB, for the cache of closed hours that the graph view reads. Each cached hour stays until a late write or retention changes it, so wide live windows only read the current hour from disk. Each hour takes about 0.5 MiB at 1k nodes, 2.3 MiB at 5k and 9 MiB at 20k, so the default holds a full day at 20k nodes. Least-recently-used hours are evicted at the cap. Set 0 to disable. |
256 |
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, s3, or gcs |
api |
TSFLOW_S3_AUTH |
static, aws_default, or gcs_adc |
static |
TSFLOW_S3_ROLE_ARN |
Optional role to assume when auth is aws_default. With a web identity token file this is AssumeRoleWithWebIdentity. |
- |
TSFLOW_S3_WEB_IDENTITY_TOKEN_FILE |
OIDC token file used to assume TSFLOW_S3_ROLE_ARN. A tailnets entry can set its own file. |
- |
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. aws_default uses the SDK region. |
garage |
TSFLOW_S3_ACCESS_KEY_ID |
S3 access key ID | - |
TSFLOW_S3_SECRET_ACCESS_KEY |
S3 secret access key | - |
TSFLOW_S3_PATH_STYLE |
Path-style bucket addressing | true, false with aws_default |
TSFLOW_S3_LOOKBACK |
Object-store listing overlap for late objects | 15m |
TSFLOW_S3_MAX_OBJECTS_PER_POLL |
Max objects imported per poll cycle | 500 |
The older S3 names still work when the TSFLOW_S3_* name is unset: TAILSCALE_LOGS_S3_*, AWS_ENDPOINT_URL, AWS_REGION, AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and lower-case endpoint and region. With no backend set, an endpoint plus keys still means s3. Every variable also accepts a VITE_ prefix. See docs/environment.md.
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.
GET /api/analytics/talkers and GET /api/analytics/pairs return JSON rankings for a time window. The Analytics page's Top Talkers and Top Pairs tables use them, for the page's window and traffic-type filter, with previous and next pages, a bytes or flows sort, and a search box. Clicking a device name opens it on the traffic graph over the same window. The /api/stats/top-talkers and /api/stats/top-pairs routes stay for API callers.
start and end are RFC3339. limit defaults to 20 and stops at 200. offset defaults to 0. sort is bytes (total volume, the default) or flows. On a single-tailnet install the tailnet parameter can be omitted. With several tailnets, pass the same id the other data routes use. Optional q narrows both rankings before paging, with the same rules as the traffic graph search: tag:x, ip:x, user@x for the owner login (including the creator login recorded for a tagged device), or a case-insensitive substring of a device name, owner email, address, or tag. A pair matches when either end does.
A talker is one device. The row has nodeId, hostname, owner, txBytes, rxBytes, totalBytes, and flowCount. nodeId is the canonical device id, and rows stored under another id for the same device (a legacy numeric id or an address) are merged into it on the page. hostname and owner come from the device list, falling back to the stored flow-log name; 127.3.3.40 is labeled DERP relay. A pair row has srcNodeId, srcHostname, srcOwner, dstNodeId, dstHostname, dstOwner, the same byte fields, and flowCount. Physical (WireGuard transport) traffic is left out unless trafficTypes includes physical. Rows are ordered by the sort field descending, then by id. metadata.hasMore is true when a later page exists.
An hour that sits fully inside the window is read from the hourly rollup (node_pair_hours). The partial hour at each end is read from minute rows in node_pairs. When no hour is rolled up yet, the read uses minute rows. An empty window returns an empty list. Optional trafficTypes uses the same values as the other stats routes (virtual, subnet, exit, physical).
GET /api/analytics/new-pairs lists directed pairs that appear in the selected window and do not appear in the lookback immediately before it. lookback defaults to 7d. It also accepts 24h, 30d, or any duration from 1h through 90d. limit, offset, start, end, tailnet, and trafficTypes match the ranked pair route. Rows are ordered by first seen, newest first, then by volume. There is no alert. The page is /new.
metadata.lookbackStart is where the lookback begins. metadata.dataStart is the earliest stored minute and is left out when nothing is stored. metadata.lookbackComplete is false when stored data starts after lookbackStart. A pair last seen before the data starts then shows up as new, and the page says so.
Physical traffic is left out unless trafficTypes includes physical. A DERP address is labeled DERP relay. Complete hours are read from node_pair_hours.
GET /api/analytics/device-timeline returns one device's bytes over time, split into virtual, subnet, and exit, plus one page of its peers. Pass node (the stored device id). limit and offset page the peers, not the time buckets. Windows of two hours or less use one-minute buckets. Longer windows use hourly buckets, and complete hours are read from node_pair_hours.
physical is omitted unless trafficTypes includes it. Each bucket includes seconds, the part of the bucket inside the query window. A DERP peer is labeled DERP relay. Selecting a device on the traffic graph opens the same timeline.
GET /api/analytics/me is the signed-in viewer's devices, traffic, per-device timelines, top peers, and new connections. The login is the request identity from WhoIs or the trusted proxy headers. No request parameter sets it. me=1 on the talker, pair, new-pair, and device-timeline routes uses that same login and compares it in full, including a tagged device's creator login. On the talker and pair routes, q then narrows the viewer's own devices. Devices are listed busiest first. Top peers leave out the viewer's own devices and peers with no bytes. New connections leave out a device talking to itself, but keep one between two of the viewer's devices. The response metadata carries the same lookbackStart, dataStart, and lookbackComplete fields as /api/analytics/new-pairs for newPairs. Without a login the route is unauthorized, and the Me tab is not shown. When a login is known, / opens Me unless the address has query parameters or the viewer already chose another tab. That choice is stored in tsflow-last-tab.
TSFLOW_MCP_ENABLED=true serves a read-only Model Context Protocol endpoint at /mcp on the same HTTP server as the UI, so Claude, Cursor and other MCP clients can ask questions about your flow data. The route is absent when the variable is unset. It uses streamable HTTP and the same access middleware as /api: trusted-proxy and WhoIs identity, then the capability or group grant and its optional tailnet allowlist. A viewer cannot query a tailnet outside that allowlist. There is no separate MCP credential.
Add the variable to the container you already run:
docker run -d \
--name tsflow \
-p 127.0.0.1:8080:8080 \
-v tsflow_data:/app/data \
-e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
-e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
-e TSFLOW_MCP_ENABLED=true \
ghcr.io/rajsinghtech/tsflow:latestThe endpoint is then http://localhost:8080/mcp. With TSFLOW_SERVE=true it is https://<TSFLOW_HOSTNAME>.<your-tailnet>.ts.net/mcp. With header access on, use the trusted proxy's URL so the proxy sets the identity headers. A client should never send Tailscale-User-Login itself.
On Kubernetes (the repository has manifests, not a Helm chart), set it on the container:
env:
- name: TSFLOW_MCP_ENABLED
value: "true"k8s/deployment.yaml leaves it false. k8s/access-example.yaml shows it next to the access settings. Keep the Service reachable only from the tailnet or the trusted proxy.
The examples use https://tsflow.example.ts.net/mcp. Swap in your own URL.
Claude Code:
claude mcp add --transport http tsflow https://tsflow.example.ts.net/mcpOr commit it to a project's .mcp.json:
{
"mcpServers": {
"tsflow": {
"type": "http",
"url": "https://tsflow.example.ts.net/mcp"
}
}
}Cursor, in ~/.cursor/mcp.json or a project's .cursor/mcp.json:
{
"mcpServers": {
"tsflow": {
"url": "https://tsflow.example.ts.net/mcp"
}
}
}Claude Desktop's claude_desktop_config.json only starts local servers, so bridge to the URL with mcp-remote (needs Node 18 or newer):
{
"mcpServers": {
"tsflow": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://tsflow.example.ts.net/mcp"]
}
}
}Claude's "Add custom connector" setting connects from Anthropic's cloud rather than from your machine, so it can't reach a tsflow that only listens on your tailnet or on localhost. Use the bridge above instead.
| Tool | Answers |
|---|---|
whoami |
Who the tools run as: login, groups, permitted tailnets, and what scope=mine keeps |
list_tailnets |
Which tailnets you can query, with poller status |
search_devices |
Find devices by name, hostname, IP, tag, or owner login |
get_device |
One device by id, name, or IP, with tags and owner |
top_talkers |
Busiest devices over a window, by bytes or flows |
top_pairs |
Busiest device pairs over a window |
flows_between |
Bytes, protocols, and ports between two devices, IPs, or CIDRs |
device_peers |
Who one device talks to most |
device_timeline |
One device's bytes over time, split by traffic type |
new_connections |
Pairs first seen in the window and absent from the lookback before it |
stats_overview |
Network-wide protocol, traffic-type, flow, and pair totals |
Every tool is marked read-only and reads stored rollups. start and end take RFC3339 (2026-01-02T15:04:05Z) or a time relative to now (now-2h, 2h ago, last 24h, last week). Windows default to the last hour and stop at 7 days. lookback takes durations like 24h, 3d, or 1w, up to 7 days. Lists default to 20 rows, stop at 100 per page, and page with offset up to 1000. Physical transport is excluded unless trafficTypes includes physical. DERP relays are labeled DERP relay. Pass tailnet when more than one tailnet is configured.
Identity autoscope is only the initial device view, the same way the UI starts. Device tools take scope: mine or all. When autoscope is user or groups, the default is mine and results match that filter. scope=all clears it and returns every device in the permitted tailnets. list_tailnets and stats_overview stay tailnet-wide, matching the REST routes.
Things to ask once it's connected: "what were the top talkers in the last 2h?", "who has db-01 been talking to today?", "any new connections since this morning?"
- MCP is off by default. With it off,
/mcpdoesn't exist. - The tools can't change anything in tsflow or Tailscale, and no tool returns credentials or API keys.
- With access control off, anyone who can reach
/mcpcan read every tailnet's flow data, the same as the UI. Keep tsflow on your tailnet or bound to localhost, as in the example above. - With access control on, MCP uses the same identity, grants, and tailnet allowlists as
/api. A viewer can't query a tailnet they aren't granted. scope=mineis a convenience view, not a boundary.scope=alland the REST API show every device in the tailnets a viewer is granted. To keep people apart, give them different tailnet grants./mcphas its own rate limit of 100 requests a minute per client address. Over the limit it returns429withRetry-After. Behind a proxy, setTSFLOW_TRUSTED_PROXIESso the limit counts the real client and not the proxy.- Whatever a tool returns (hostnames, owner logins, tags, traffic volumes) goes to the model your client uses. Device names and tags come from your tailnet, so treat them as untrusted text.
- The health port used with
TSFLOW_SERVEdoes not expose/mcp.
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
Your 1.6 config works as is. No variable was renamed or removed, no default changed, and nothing new is required. A table test in backend/internal/config runs every 1.6.2 config shape against the current loader to keep it that way. With no TSFLOW_TAILNETS_FILE, the single-tailnet variables work as before and the data is stored as tailnet default. MCP, access control, several tailnets and the health port stay off until you set them.
On first start tsflow migrates the database. It writes a backup first (see below), tags your existing rows as tailnet default, and builds the hourly rollups. Old data stays visible and S3 objects that were already imported aren't read again.
Things that behave a bit differently:
- Physical traffic (the underlay, including DERP) is left out of totals unless you pick it in the traffic filter or pass
trafficTypes=physical. Totals can look lower than in 1.6. /api/network-logsserves at most 30 minutes per request and returns410for longer windows.limitdefaults to 1000 and caps at 5000. Use the stats and analytics routes for longer ranges.- Device
idin API responses is the stable node id (n...) instead of the numeric id. Numeric ids still work in queries. - The rate limit (100 requests a minute) is keyed by the TCP peer. 1.6 trusted
X-Forwarded-Forfrom anyone. Behind a reverse proxy, setTSFLOW_TRUSTED_PROXIESto the proxy address so each client gets its own limit again. - In development mode, CORS only allows loopback origins unless
ALLOWED_CORS_ORIGINSis set. It no longer echoes any origin. - API polls stop 2 minutes behind now (
TSFLOW_POLL_DELAY), so late log messages aren't missed. The newest 2 minutes show up one poll later.
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.
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.
You need Go (see backend/go.mod) and Node 22 or newer. The frontend build fails on Node 20.
git clone https://github.com/rajsinghtech/tsflow.git
cd tsflow
# Install dependencies
cd frontend && npm install && cd ..
cd backend && go mod download && cd ..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-frontendFrontend runs on http://localhost:5173 and proxies /api to backend on :8080.
make build
./backend/tsflowThis builds the SvelteKit frontend and embeds it in the Go binary.
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: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.
See CHANGELOG.md.
MIT
Built with ❤️ for the Tailscale community


{ "grants": [ { "src": ["group:eng"], "dst": ["tag:tsflow"], "app": { "example.com/cap/tsflow": [ {"tailnets": ["default", "lab"]} ] } }, { "src": ["group:ops"], "dst": ["tag:tsflow"], "app": { "example.com/cap/tsflow": [{}] } } ] }