Environment variables — core tunnel
This covers the variables you need for a standard browser tunnel (what
Your first tunnel and
Install ct-agent use). ct-agent also has a
separate set of variables for Agent-Fabric channels, MCP/AgentCard discoverability, and capacity
offers/auctions — see
Environment variables (channels, cards, offers).
If you’re not sure which of the several things ct-agent can do is the one you actually want,
Example configurations by use case gives
a complete, working .env per use case, side by side.
Pulled directly from ct-agent’s source, not from memory — if this drifts from the code, that’s a bug
in this page.
| Variable | Required | Default | Meaning |
|---|---|---|---|
CT_AGENT_CP_URL |
Yes | — | Your control plane’s base URL (https://bunsenbrenner.org for the hosted platform). |
CT_AGENT_JOIN_TOKEN |
Yes (first onboard) | — | Single-use join token from the portal’s Install page. Presence of this variable is what triggers the one-command onboarding path at all. |
CT_AGENT_TOKEN |
Yes | — | Your tunnel’s routing token. |
CT_AGENT_HOSTNAME |
Yes | — | The hostname this tunnel serves. |
CT_AGENT_ORIGIN |
Yes | 127.0.0.1:8080 |
Where your actual service is running — host:port. |
CT_AGENT_ORIGIN_PROTO |
No | tcp |
tcp or udp — the transport ct-agent uses to reach CT_AGENT_ORIGIN. Not http/https — this is the raw transport, not an application protocol; a plain web server is still tcp. The portal’s own generated .env snippet doesn’t set this line at all, which is fine — the guided setup script used to wrongly hard-require it before falling through to this same default; fixed to match the agent’s own behavior. |
CT_AGENT_ORIGIN_TLS |
No | passthrough |
passthrough or terminate. passthrough forwards the stream verbatim — TLS (if any) terminates at the Origin, unchanged behavior. terminate has the Agent itself terminate TLS (only for streams that begin with a TLS ClientHello — anything else still forwards raw) and hand the Origin plaintext; this is what lets ct-agent ssh reach a plain local sshd. Any other value is a startup error naming the variable, not a silent fallback. |
CT_AGENT_TLS_CERT |
No, only used with CT_AGENT_ORIGIN_TLS=terminate |
the ACME output dir’s fullchain.pem (CT_AGENT_CERT_OUT_DIR, or its own default) |
PEM certificate chain (leaf first) the Agent serves when terminating TLS. Defaults to the same cert Go from Gelb to Grün already issues — you only need to set this if you’re supplying a certificate from somewhere else. |
CT_AGENT_TLS_KEY |
No, only used with CT_AGENT_ORIGIN_TLS=terminate |
the ACME output dir’s privkey.pem |
PEM private key matching CT_AGENT_TLS_CERT. Same default-location pairing as above. |
CT_AGENT_MODE |
No | unset | Set to browser for the raw-TLS-passthrough browser tunnel mode (what the setup script configures by default). Unset means Mesh Plane (Noise) mode instead — the actual default, opaque-token routed, no TLS anywhere in the path. |
CT_AGENT_EDGE |
No, but see setup.sh | — | host:port of the mesh edge. The guided setup script derives this automatically from CT_AGENT_CP_URL + /network-info; only set it by hand if you’re not using the setup script. |
CT_AGENT_EDGE_CERT_URL |
No, but recommended | — | Base URL the agent fetches the edge’s CA root from (GET {url}/pki/ca — see The internal Mesh-Plane CA for what that root actually is and why a CA root instead of a pinned cert). Leaving this unset on a non-CADS-Tunnel deployment makes the agent wait indefinitely by default rather than error (see the two variables below for making that visible/bounded) — the guided setup script defaults it to CT_AGENT_CP_URL for you. |
CT_AGENT_EDGE_CERT |
No | /shared/edge-cert.der (not suitable outside CADS-Tunnel’s own compose network) |
Local path the fetched edge CA cert is cached to — the on-disk counterpart to CT_AGENT_EDGE_CERT_URL above. When CT_AGENT_EDGE_CERT_URL is unset, the agent instead polls this path on disk for the cert to show up. |
CT_AGENT_EDGE_CERT_WAIT_SECS |
No | unset (wait indefinitely) | Only applies when CT_AGENT_EDGE_CERT_URL is unset. Bounds the wait for CT_AGENT_EDGE_CERT to appear on disk — past it, the agent exits with edge cert not available within <n>s at <path> instead of waiting forever. Leave unset for a real tunnel that must survive a slow/delayed edge; set it for a fail-fast CI/smoke run, same reasoning as CT_AGENT_ONBOARD_TIMEOUT_SECS. |
CT_AGENT_EDGE_CERT_LOG_INTERVAL_SECS |
No | 5 |
Only applies when CT_AGENT_EDGE_CERT_URL is unset. How often the agent logs waiting for edge cert at <path> ... while it waits — the wait itself was never silent, this only controls how chatty it is. |
CT_AGENT_ID |
No | agent-<timestamp>-<pid> |
Stable identity key used to match persisted state on restart (onboard_or_restore). The guided setup script persists and reuses this across runs automatically — don’t regenerate it by hand between runs of the same tunnel, or restore will fail to match and it’ll try to re-onboard with an already-spent token. |
CT_AGENT_STATE_DIR |
No | ./.ct-agent-state (via setup script) |
Where the bound identity/tenant/capability get persisted. |
CT_AGENT_CAPABILITY_OUT |
No | /shared/capability.bin (agent’s own default — not suitable outside CADS-Tunnel’s own compose network) |
Where the Capability — the self-contained Mesh Plane connection grant you distribute to your own Clients out of band — is written. Not fetched from anywhere: the agent mints it locally (mint_capability, a fresh random routing token by default), from material it already has. The guided setup script overrides the output path to $CT_AGENT_STATE_DIR/capability.bin. |
CT_AGENT_ORIGIN_KEY |
No | ephemeral, generated fresh per process | File path persisting the origin’s static Noise private key. Set it (on a shared volume) to make the key durable and shareable across multiple agent processes — required for both running redundant agents and ct-agent rotate. Owner-only permissions; it’s the key that makes possession of your Capability enough to be trusted as your origin. |
CT_AGENT_ORIGIN_KEY_DIR |
No | — | Where ct-agent rotate retires the previous origin key during a zero-downtime rotation window, so the agent can keep serving both the old and new identity until every client has the new Capability. |
CT_BOOTSTRAP |
Alternative to CT_AGENT_JOIN_TOKEN+CT_AGENT_TOKEN |
— | A single short-lived bootstrap token the setup script redeems server-side for the two tokens above, so they never touch disk/shell history beyond the resulting .env. |
CT_AGENT_ONBOARD_TIMEOUT_SECS |
No | unset (wait indefinitely) | Bounds the one-shot onboarding call. Leave it unset for a real tunnel — CT_AGENT_JOIN_TOKEN is single-use, so a timeout that fires after the control plane already redeemed it leaves you with a dead token and no way to retry, unless CT_AGENT_STATE_DIR is also set (restart then restores the already-bound identity instead of re-redeeming). Only set this for a fail-fast CI/smoke-test run — scripts/e2e-smoke.sh defaults it to 30. |
ct-agent exits immediately with Error: "control-plane returned status 409 Conflict"
— your .env (or process manager) still has CT_AGENT_JOIN_TOKEN set from an
earlier successful onboarding. Its mere presence makes a bare ct-agent re-enter the
one-command onboarding path on every start (see the row above) — but a join token is single-use, so a
second redeem of an already-consumed one is refused. Control-plane-side there is exactly one enrollment
failure that maps to this status code, so a bare 409 here always means "this exact token was already
redeemed", never a network or config issue elsewhere.
Two things to check: (1) drop
CT_AGENT_JOIN_TOKEN from the environment before starting —
you're already enrolled, no need to onboard again; (2) set CT_AGENT_STATE_DIR to a
directory that survives process/container restarts (not a tmpfs or ephemeral volume). With a state
directory set, ct-agent restores the already-bound identity on every later boot instead of
re-redeeming, so this can't recur after the next restart either — the exact mechanism that fixed an
earlier crash-loop outage on this platform's own infrastructure.
Observability — metrics and forensics stay on your side
Per ADR-0016:
since the operator is payload-blind, per-connection observability can only exist at your own agent.
ct-agent can serve its own metrics and status locally, in your own open format, to your own stack —
nothing routes through the platform.
| Variable | Required | Default | Meaning |
|---|---|---|---|
CT_AGENT_METRICS_LISTEN |
No | unset (no metrics server) | host:port to serve GET /metrics, /status, /healthz, and /events on — point your own Prometheus/Grafana at the first, a container/systemd/load-balancer probe at the second. |
GET /metrics — Prometheus text exposition format. Twelve series as of ct-agent v0.7.28+
(#177/#178/#180), confirmed against source (ct_common::metrics::TunnelMetrics::render_prometheus
plus this crate’s own status/events/masque/task_guard renderers, in that fixed order):
ct_tunnels_opened_total — tunnels successfully established
ct_tunnels_failed_total — tunnel attempts that failed before or during the handshake
ct_bytes_to_origin_total — bytes relayed from client to origin
ct_bytes_to_client_total — bytes relayed from origin to client
ct_handshakes_total — completed Noise handshakes
ct_handshake_millis_total — cumulative handshake latency, milliseconds
ct_agent_registered — gauge, 1 while registered with the plane, else 0
ct_agent_reconnects_total — reconnect attempts since process start
ct_agent_transport{transport="quic"|"tcp-fallback"|"masque"|"none"} — gauge, 1 for the current transport, 0 for the other three
ct_agent_events_total{kind="..."} — one series per event taxonomy kind (below)
ct_agent_masque_dropped_datagrams_total{direction="outbound"|"inbound"} — MASQUE pump drops on a full channel (never blocks; UDP semantics)
ct_agent_tasks_live — gauge, JoinSet-tracked tasks currently running
GET /status — JSON snapshot of the same state ct-agent status prints (below):
version, session (16 hex, per process), conn, uptime_secs, transport, registered
(bool) + registered_since, last_seen_secs_ago, reconnects, last_error, tasks_live,
update_state, oidc_credential, masque_dropped_datagrams, events_ring_write_errors,
healthy (bool, the same check /healthz makes).
GET /healthz — 200 ok when registered and the edge was heard from within the last 90
seconds (a 15-second QUIC liveness tick keeps an idle-but-healthy tunnel from going stale); 503
with the reason as the plain-text body otherwise ("not registered", or "nothing heard in
Ns").
GET /events?n=<1..1000> — the last n lines (default 100) of the on-disk event ring as
application/x-ndjson, newest last. Twelve taxonomy kinds including registered,
disconnected, credential_degraded, update_applied; each line is also appended to
<state_dir>/events.jsonl (1 MiB cap, rotated to .1) regardless of whether this listener is
configured at all.
Re-verified hermetically for this page, not assumed from an earlier pass:
cargo test observe:: -p ct-agent — 9 passed; 0 failed, binding a real TCP listener and
scraping/fetching all four routes over it, plus the private-state unit tests for /status,
/healthz, and /events’ clamping behavior.
Self-update (ct-agent update)
ct-agent update (see the CLI reference) checks GitHub
Releases for a newer tag than the running binary’s own CARGO_PKG_VERSION and, if one exists,
downloads the matching platform asset and replaces itself in place. These variables control that
path and its always-on auto-update mode; none are required for a one-shot manual ct-agent update.
| Variable | Required | Default | Meaning |
|---|---|---|---|
CT_AGENT_AUTO_UPDATE |
No | unset (manual ct-agent update only) |
Truthy (1/true) starts a background loop that checks for and applies updates automatically on CT_AGENT_AUTO_UPDATE_INTERVAL_SECS, instead of only updating when you run ct-agent update yourself. |
CT_AGENT_AUTO_UPDATE_INTERVAL_SECS |
No | 86400 (24h) |
How often the auto-update loop checks GitHub Releases. Only read when CT_AGENT_AUTO_UPDATE is on. |
CT_AGENT_UPDATE_SKIP_VERIFY |
No | unset (verify) | Disables only the per-asset sha256 checksum check the update pipeline publishes next to every release asset — every use is logged loudly to stderr. For a private/unofficial build whose release has no .sha256 file; never needed against the real hosted releases. |
CT_AGENT_RELEASE_PUBKEY |
No | the public key(s) compiled into this build | Pins an Ed25519 public key an update’s release-manifest.json + detached signature must verify against, for a private build using its own signing key instead of this project’s compiled-in one. Hex-encoded. |
Reliability and connectivity fallbacks
| Variable | Default | Meaning |
|---|---|---|
CT_AGENT_RECONNECT_MAX_ATTEMPTS |
unbounded (retries forever) | Updated — this used to default to 10, but a real production outage (2026-08-13, sort.bunsenbrenner.org) traced to exactly that: the agent burned its 10-attempt budget (~2 minutes of backoff) during an edge redeploy that took slightly longer, exited, and stayed dead for hours until a human restarted it by hand — an edge restart/deploy/network partition longer than ~2 minutes turned a bounded default into a permanent, human-only-recoverable outage. CT_AGENT_JOIN_TOKEN is single-use, so exiting can’t cleanly re-onboard either, and a bare process-manager restart just crash-loops redeeming an already-spent token. Set this explicitly to a finite count only for a short-lived/scripted run where failing fast is genuinely wanted; 0 also means unbounded, equivalent to leaving it unset. |
CT_AGENT_FALLBACK_443 |
false |
If the configured edge port is blocked, also try the edge’s unified :443 front door (TLS-TCP, ALPN=ct-edge). Any non-empty value except 0/false (case-insensitive) counts as true — not the 1/true/yes convention used elsewhere in this reference, checked directly against the parsing code. |
CT_AGENT_TCP_FALLBACK_POOL_SIZE |
6 |
How many pooled connections the TCP-fallback path keeps warm. Must be at least 1 if set — 0 is a hard config error, not “disabled”. |
CT_AGENT_DIRECT_ADVERTISE |
unset | An IP to advertise for a direct P2P path in Mesh Plane mode, bypassing the relay. Only meaningful there — Browser Plane has no P2P/relay distinction. |
Grün-specific (certificate issuance)
| Variable | Required | Meaning |
|---|---|---|
CT_ACME_CERT_OUT_DIR |
Yes, for ct-agent certificate |
Where the issued fullchain.pem/privkey.pem/acme-account-key.der are written. |
Deployment-only (you won’t set these against the hosted platform)
CT_RELEASE_BASE (setup script only — override where release binaries are downloaded from) and
NO_COLOR (disable colored setup-script output) are the only two documented environment overrides for
the setup scripts themselves, as opposed to ct-agent.