Skip to content

Configuration Guide

Bifrost uses YAML configuration files. Environment variables can be used with ${VAR_NAME} syntax.

server:
http:
listen: ":7080"
read_timeout: "30s"
write_timeout: "30s"
idle_timeout: "60s"
tls:
enabled: false
cert_file: "/path/to/cert.pem"
key_file: "/path/to/key.pem"
socks5:
listen: ":7180"
graceful_period: "30s"
backends:
- name: direct
type: direct
enabled: true
- name: wireguard
type: wireguard
enabled: true
config:
private_key: "${WG_PRIVATE_KEY}"
address: "10.0.0.2/24"
dns: ["1.1.1.1"]
mtu: 1420
peer:
public_key: "${WG_PEER_PUBLIC_KEY}"
endpoint: "vpn.example.com:51820"
allowed_ips: ["0.0.0.0/0"]
persistent_keepalive: 25
routes:
- domains: ["*.internal.com"]
backend: wireguard
priority: 100
- domains: ["*"]
backend: direct
priority: 1
auth:
providers:
- name: native
type: native
enabled: true
priority: 1
config:
users:
- username: admin
password_hash: "$2a$10$..."
rate_limit:
enabled: true
requests_per_second: 100
burst_size: 200
per_ip: true
access_log:
enabled: true
format: json
output: "/var/log/bifrost/access.log"
metrics:
enabled: true
listen: ":7090"
path: "/metrics"
logging:
level: info
format: json
output: stdout
Field Type Default Description
server.http.listen string :7080 HTTP proxy listen address
server.socks5.listen string :7180 SOCKS5 proxy listen address
server.<listener>.read_timeout duration 30s Inbound request read deadline (see below)
server.<listener>.write_timeout duration 30s No-progress bound on writes back to the client
server.<listener>.idle_timeout duration 60s Bound on a connection with nothing in flight
server.<listener>.tunnel_idle_timeout duration unset Opt-in reaper for established tunnels quiet in both directions
server.<listener>.max_connections int 0 Per-listener ceiling; 0 = unlimited
server.graceful_period duration 30s Graceful shutdown period

A listener is active when its listen address is set; there is no per-listener enabled flag. At least one of the two listeners must be configured.

The plain-HTTP forward-proxy path speaks HTTP/1.1 with one request per connection — the proxy now says so on the wire (Connection: close on both hops) instead of implying persistence and closing. CONNECT tunnels are opaque, so HTTP/2 and WebSocket-over-TLS work end-to-end through them; a plain-HTTP Upgrade (e.g. ws://) is honored by switching the connection to an opaque tunnel after the origin’s 101. An h2c prior-knowledge request is rejected with 505 HTTP Version Not Supported — HTTP/2 to the proxy itself is not supported. Hop-by-hop headers (RFC 7230 §6.1) are stripped in both directions and the proxy appends itself to Via.

All three timeouts describe the inbound client connection. None of them affects outbound dials to a backend — see Network Settings for network.dial_timeout. Set any of them to 0 to disable that deadline and leave the connection bounded only by its peer.

  • read_timeout — the complete inbound request must arrive within it, measured from the client’s first byte: the request line and header block for HTTP, the whole handshake (method negotiation, optional username/password exchange, connect request) for SOCKS5. This is an absolute bound, which is what makes it effective against a client that trickles a request one byte at a time. It applies to TLS-terminated listeners too — the TLS handshake must complete within it — and to every decrypted request on a MITM-intercepted tunnel. Once the headers are in it applies per read instead, so a large upload may take arbitrarily long overall as long as it keeps making progress.
  • write_timeout — a no-progress bound on writes back to the client: each window of write_timeout must deliver at least one byte. A streaming response (server-sent events, a chunked feed, a large download) is never truncated while it keeps moving — even to a very slow receiver — but a client that has stopped reading entirely is timed out within one window. (On a TLS-terminated listener a stalled window is fatal to the connection, a constraint of Go’s TLS implementation; a progressing response is still never cut off.)
  • idle_timeout — bounds a connection with no request in flight: one that has been accepted but has sent nothing, or the wait between exchanges on a kept-alive loop, including a MITM-intercepted tunnel. It deliberately does not apply to an established opaque CONNECT tunnel or SOCKS5 relay.
  • tunnel_idle_timeout — off by default. When set, an established tunnel or relay in which neither direction has carried data for the period is closed. An actively transferring tunnel is never interrupted, even to a very slow receiver — each window only requires progress, not completion.

The same three listener timeouts exist on the client’s proxy.http and proxy.socks5 blocks and are applied the same way; they default to 0 (disabled) there, since the client’s listeners are typically loopback-only.

The SOCKS5 listener implements the CONNECT command only. BIND (0x02) and UDP ASSOCIATE (0x03) are answered with reply code 0x07 (“command not supported”), so inbound-connection and UDP relaying — QUIC/HTTP-3, DNS-over-UDP, WebRTC media, some BitTorrent and FTP-active flows — cannot be proxied over SOCKS5. Use the HTTP listener, or VPN mode for a TUN device that carries UDP. Supported authentication methods are no-auth (0x00) and username/password (0x02); GSSAPI (0x01) is not offered.

Field Type Default Description
logging.level string info debug, info, warn or error
logging.format string text json or text
logging.output string stdout stdout, stderr or a file path
logging.time_format string RFC3339 with ms Go time layout for the timestamp
logging.max_size_mb int 0 File outputs only: rotate above this size; <= 0 disables built-in rotation
logging.max_backups int 0 Rotated files to retain; <= 0 keeps all

See Log Rotation for the rotation behaviour and the external-logrotate alternative.

  • direct - Direct connection
  • wireguard - WireGuard tunnel
  • openvpn - OpenVPN tunnel
  • http_proxy - Upstream HTTP proxy
  • socks5_proxy - Upstream SOCKS5 proxy
  • nordvpn, mullvad, pia, protonvpn - Commercial VPN providers (see VPN Providers)

Routes match domains to backends. Higher priority routes are evaluated first.

routes:
- name: "internal" # Optional name
domains: # Domain patterns
- "*.internal.com"
- "internal.com"
backend: wireguard # Backend name
priority: 100 # Higher = evaluated first

Pattern formats:

  • example.com - Exact match
  • *.example.com - Wildcard subdomain
  • .example.com - Suffix match (matches domain and all subdomains)
  • * - Match all

A route may name several backends instead of one. With load_balance: weighted, weights sets the relative share each receives; a backend absent from the map gets a weight of 1.

routes:
- name: "internal"
domains: ["*.internal.com"]
backends: ["wireguard-a", "wireguard-b"]
load_balance: weighted # round_robin | least_conn | random | weighted
weights:
wireguard-a: 3 # receives 3 of every 4 requests
wireguard-b: 1
web_ui:
enabled: true
listen: "127.0.0.1:7081"
base_path: "/bifrost" # serve the dashboard under a sub-path

base_path is optional in every setup: the dashboard derives its prefix from the browser URL at runtime, so serving it under a sub-path behind a reverse proxy works with or without this setting (the Go server does not consume the value). If the proxy also rewrites Host, add the browser-visible origin to api.allowed_origins, or the dashboard’s WebSocket will be refused.

Process-wide tuning for outbound dials. Every field is optional; omitted values keep the Go and OS defaults. This section requires a restart.

network:
ipv6: true # false restricts outbound dials to IPv4
prefer_ipv6: false # prefer AAAA when a host resolves to both
keepalive: 30s # TCP keep-alive on outbound dials; negative disables
max_connections: 0 # ceiling across all listeners; 0 = unlimited

ipv6 is deliberately three-state: leaving it unset means dual-stack, exactly as true does, so an absent value never narrows the address family. Only an explicit ipv6: false restricts dialling to IPv4.

dial_timeout is the default timeout for outbound connections to a backend. It applies only where the backend does not already derive a timeout from its own configuration — it will not override a backend-specific value. It has no bearing on inbound connections; those timeouts come from the per-listener server.http and server.socks5 settings.

api:
enabled: true
listen: "127.0.0.1:7082"
token: "..." # optional; when unset the API has no auth
allowed_origins: # extra browser origins allowed to open a WebSocket
- "https://bifrost.example.com"

allowed_origins matters when a reverse proxy rewrites Host, and it also grants the Host check that blocks DNS rebinding. Both rules, the matching semantics, and the ["*"] opt-out are documented in the WebSocket API reference.

Off by default. When enabled, the proxy terminates TLS with a certificate it mints per host from a CA you supply, so request bodies become visible to the traffic debugger. Both files are required, and the CA private key lets the proxy impersonate any host — treat it accordingly.

mitm:
enabled: false
ca_cert_file: "/etc/bifrost/mitm-ca.pem"
ca_key_file: "/etc/bifrost/mitm-ca.key"
leaf_ttl: 24h # lifetime of each minted host certificate
max_cached_certs: 100 # LRU bound on minted certificates held in memory

Clients must trust ca_cert_file, or every intercepted connection fails its certificate check. Changing this section requires a restart.

proxy:
http:
listen: "127.0.0.1:7380"
socks5:
listen: "127.0.0.1:7381"
server:
address: "proxy.example.com:7080"
protocol: http
username: "${PROXY_USER}"
password: "${PROXY_PASS}"
timeout: "30s"
retry_count: 3
retry_delay: "1s"
routes:
- domains: ["localhost", "127.0.0.1", "*.local"]
action: direct
priority: 100
- domains: ["*"]
action: server
priority: 1
debug:
enabled: true
max_entries: 1000
capture_body: false
web_ui:
enabled: true
listen: "127.0.0.1:7382"
tray:
enabled: true
start_minimized: false
  • server - Route through Bifrost server
  • direct - Connect directly

Use ${VAR_NAME} syntax for environment variable expansion:

server:
http:
listen: ":${HTTP_PORT}"
auth:
providers:
- name: native
type: native
enabled: true
priority: 1
config:
users:
- username: admin
password_hash: "${ADMIN_PASSWORD_HASH}"

The whole grammar is three forms. Everything else is literal text.

Form Meaning
${NAME} Value of NAME. If NAME is not set the reference expands to the empty string and the loader logs a warning naming the variable and the line.
${NAME:-fallback} Value of NAME when it is set and non-empty, otherwise the literal fallback. The fallback is used verbatim and is not itself expanded.
$$ A literal $. Only needed in front of a { or another $.
server:
http:
# Defaults to :8080 when HTTP_PORT is unset or empty
listen: ":${HTTP_PORT:-8080}"

A $ that is not followed by { or $ is literal, so passwords, API keys and bcrypt hashes pass through untouched:

auth:
providers:
- name: native
type: native
enabled: true
priority: 1
config:
users:
- username: admin
# Kept exactly as written
password_hash: "$2a$10$N9qo8uLOickgx2ZMRZoMye"

Write $$ only where a value must literally contain ${, for example token: "a$${not_expanded}b", which loads as a${not_expanded}b.

Bare $NAME is not expanded. Only the braced forms above are. This is what makes a literal $ in a value safe — expansion runs on the raw file before YAML parsing, so quoting cannot protect a value. If a bare $NAME names a variable that is set in the environment, the loader warns with the line number so the mismatch is visible; add the braces to expand it.

Expansion is a single pass. A value substituted from the environment is never expanded again, so a secret that itself contains ${ is safe.

A key that does not correspond to a setting is an error, not a silent no-op. The loader reports every offending key in one pass, with its line and the config block it appeared in:

Error: config file /etc/bifrost/config.yaml contains 2 unknown key(s):
line 3: "listem" (in ListenerConfig); line 12: "max_conns" (in ListenerConfig);
remove or correct the key, or set BIFROST_CONFIG_ALLOW_UNKNOWN_KEYS=1 to
downgrade unknown keys to warnings while migrating

This applies everywhere a config file is read: server and client startup, bifrost-server config validate, hot reload, and the dashboard’s config API. A rejected hot reload leaves the running configuration in place.

The check reaches into the dynamic sections too: backends[].config and auth.providers[].config are validated against the exact keys their backend type or provider plugin reads — including nested blocks such as a WireGuard peer, a native users entry, or an inline mfa_wrapper authenticator. A config file must also contain exactly one YAML document; content after a --- separator used to be silently ignored and is now an error.

Every shipped example config and both init templates pass this check, so a file that follows this guide is unaffected. A hand-edited file carrying a typo, or a key from an older release, will now fail to start — which is the point: a misspelled key used to look exactly like a setting that does not work.

If a deployment has to come up before the file can be cleaned, set BIFROST_CONFIG_ALLOW_UNKNOWN_KEYS=1 (or true, yes, on). Unknown keys are then logged as warnings and ignored:

WARN ignoring unknown configuration key config=/etc/bifrost/config.yaml line=3
key=listem section=ListenerConfig hint="this key has no effect; ..."

Treat this as temporary. A key that is ignored is a setting that is not applied, and the variable is documented as a migration aid rather than a supported mode.

Genuine type errors — a string where a number belongs, for instance — always fail, with or without the variable set.