Discovery providers
A provider reports machines. Policy decides which of them are mounted. Providers are listed in trust order, most trusted first:
| Provider | type |
Trust | Source |
|---|---|---|---|
| Static | — | 0 | [[machines]] in your config |
| Tailscale | tailscale |
1 | tailscale status --json |
| HTTP inventory | http |
2 | a JSON document you serve |
| DNS TXT | dns |
3 | bf1 TXT records |
[[discovery]]type = "tailscale" # tailscale | dns | http# name = "tailscale" # defaults to the type; needed for a second provider of one typeinterval = "30s" # defaults to daemon.discovery_intervalRefresh and expiry
Section titled “Refresh and expiry”- Each provider refreshes every
interval, so a new machine can take that long to appear.bifrost discoverrefreshes every provider now. - A machine missing from successful refreshes is dropped after
max(TTL, 3 × interval), and its mount is unmounted gracefully. - A provider that fails keeps serving its last good view, frozen, until it recovers. One that could not start at
all is retried every
reconcile_intervaland on the next reload or SIGHUP. - Invalid records are skipped one at a time, with a warning in the daemon log.
bifrost discover prints each provider’s state:
PROVIDER KIND STATUS MACHINES LAST-OKstatic static ok 2 -tailscale tailscale ok 5 12s agoStatic
Section titled “Static”Machines in [[machines]] are reported by a built-in provider named static. They are the most trusted source
and are allowed unless a global deny matches. See Configuration
and the examples.
Tailscale
Section titled “Tailscale”[[discovery]]type = "tailscale"
[discovery.filter]include_tags = ["dev", "agent"] # tags lose their "tag:" prefix
[discovery.mount]user = "sami"- Runs
tailscale status --jsonwith a 10 second timeout. It never runstailscale up,downorset. - The binary comes from
$PATHand the fixed directories, else on macOS/Applications/Tailscale.app/Contents/MacOS/Tailscale. - Id: the first label of the MagicDNS name, else the lowercased hostname.
- Address: the MagicDNS name when MagicDNS is on, else the first Tailscale IPv4 address.
- Tags lose their
tag:prefix. Metadata carriesos,hostnameanddns_name. - Your own node is never listed, and neither are other users’ devices that appear only because you shared this
machine with them (
tailscale statushides them too). - With no filter and no global allow, every peer is discover-only.
Examples: tags and a mount template, one machine by node ID, tagging machines.
DNS TXT (bf1)
Section titled “DNS TXT (bf1)”[[discovery]]type = "dns"name = "infra"domain = "infra.example.com"nameservers = ["10.0.0.2", "127.0.0.1:5353"] # optional; default: the system resolver
[discovery.filter]include_names = ["agent-*", "build-*"]
[discovery.mount]user = "ubuntu"honor_hints = truePublish one TXT record set at _bifrost.<domain>, one value per machine:
_bifrost.infra.example.com. TXT "v=bf1 node=agent-01 host=10.0.0.5 port=22 user=sami tags=dev,agent path=/home/sami"_bifrost.infra.example.com. TXT "v=bf1 node=agent-02 tags=dev"Past about a dozen values (about 5 without nameservers on Linux when resolv.conf lacks options edns0), the
answer needs TCP, and Bifröst retries over TCP by itself. If TCP port 53 is blocked, or each machine should publish
its own record, list the machines in an index value and give each one a record of its own:
_bifrost.infra.example.com. TXT "v=bf1 nodes=build-01,build-02"_bifrost.build-01.infra.example.com. TXT "v=bf1 host=10.0.0.18 tags=ci"_bifrost.build-02.infra.example.com. TXT "v=bf1 host=10.0.0.19 tags=ci"Both forms mix in one record set. A complete zone, the same records in Cloudflare, Route 53, dnsmasq and CoreDNS, how to check them, and when to use the index: DNS records.
Record format
Section titled “Record format”A value is space-separated key=value tokens, and v=bf1 must come first. Values that don’t start with
v=bf1 (SPF and friends) are ignored. Root keys go only in values at _bifrost.<domain>; machine keys go in an
inline value or a per-node record (at _bifrost.<node>.<domain>, without node=).
| Key | Where | Value |
|---|---|---|
v |
every value | bf1, first |
node |
root | the node label: lowercase [a-z0-9-], 1–63 characters, a letter or digit at each end, no dots. Makes the value an inline node record. Not with nodes= |
nodes |
root | an index: comma-separated node labels. 256 nodes at most, inline and indexed together |
host |
machine | connect target, hostname or IP. Default <node>.<domain> |
port |
machine | 1–65535 |
user |
machine | remote user, a hint used only with honor_hints |
path |
machine | remote path, a hint used only with honor_hints |
tags |
machine | comma-separated, at most 32 tags and 256 bytes |
id |
machine | a native id, matched only by this provider’s include_ids / exclude_ids |
- Identity is the node label (
agent-01), neverid=. Labels have no dots, so an index can’t send queries to another domain. Every name is queried absolute, so search domains never apply. - A value over 2 KiB, a duplicate key, or any key that fails validation (say
host=-oProxyCommand=…) rejects the whole node. Two different values for one node, or a node both inline and innodes=, are ambiguous, and the node is skipped. - Unknown keys,
driver=included, are ignored. - Changes show up on the first refresh after the record’s TTL (resolvers cache). Without
nameservers, the system resolver config is re-read on every refresh, so a network or VPN change is picked up. - DNSSEC is not checked.
- With no filter and no global allow, every node is discover-only.
HTTP inventory
Section titled “HTTP inventory”[[discovery]]type = "http"name = "inventory"url = "https://inventory.example.com/bifrost/v1/machines"headers = { Authorization = "Bearer ${BIFROST_INVENTORY_TOKEN}" }interval = "60s"
[discovery.filter]include_cidrs = ["10.20.0.0/16"]include_metadata = { env = "dev" }Bifröst sends GET url with your headers and Accept: application/json. Header values may use ${VAR}, so
secrets stay in the environment; they are never logged. Examples: a local file, an authenticated endpoint,
generating the document with jq.
Response schema
Section titled “Response schema”{ "machines": [ { "name": "agent-01", "id": "i-0abc", "host": "agent-01.corp", "addresses": ["10.20.0.4"], "port": 22, "online": true, "user": "sami", "path": "/home/sami", "metadata": { "tags": ["dev", "agent"], "env": "dev" } } ]}| Field | Required | Meaning |
|---|---|---|
name |
yes, else id |
the machine id, lowercased |
id |
no | native id, matched only by this provider’s include_ids / exclude_ids |
host |
one of host / addresses |
connect target. When present, addresses is ignored, so CIDR rules see the real target |
addresses |
one of host / addresses |
hostnames or IPs; invalid entries are dropped |
port |
no | SSH port |
online |
no | false holds a pending mount until the machine is back |
user, path |
no | hints, used only with honor_hints |
metadata |
no | tags becomes the machine’s tags; other scalars become strings; nested values are dropped |
A driver field is ignored.
Limits
Section titled “Limits”- The URL must be
https://, orhttp://only to127.0.0.1,[::1]orlocalhost. - Redirects are never followed, so your auth header can’t leak to another host.
- 10 second timeout, 1 MiB body, the first 1000 entries.
- A non-2xx status marks the provider failed (for example
HTTP 401) and keeps its last good view. - A bad entry is skipped on its own; the rest of the response still counts.

