SwitchTenderSwitchTender Docs

Configuration

SwitchTender is one binary with subcommands. This page lists every command, flag, and environment variable.

Licensed features

Almost everything on this page runs on Community, which needs no license. Seven features are licensed. There is no cap on how many approval policies an install holds. A flag or request that turns on a licensed feature is refused outright rather than quietly ignored, so an install never believes it has a control it does not have.

Capability Tier Turned on by
Directory sign-in (OIDC, SAML, LDAP, JWT) Pro Any --oidc-*, --saml-*, --ldap-*, or --jwt-* flag. serve refuses to start with one set and no license.
Deny rules and risk and reversibility floors Pro Creating a policy that uses one. Require-approval policies and exemptions stay Community, as many as you need.
The full policy engine: actor and account scoping, distinct-approver separation of duties, and Rego policies Team Creating a policy that uses one.
The period change register Team audit report, and GET /v1/audit/register.
Dedicated workers and named queues, and the relay that runs them inside networks the control node cannot reach Team Every switchtender worker, whether it shares the database or reaches the server over the mesh relay with --server. PostgreSQL, including active-active HA on it, is Community.
One-click drift reconcile Team POST /v1/drift/reconcile. Drift detection itself is free.

license status prints the tier this install runs and when a license lapses. Tiers and prices are at https://switchtender.com/pricing.

Environment variables

Variable Used by Purpose
SWITCHTENDER_ENCRYPTION_KEY serve, worker Passphrase that seals stored credentials with AES-256-GCM. Credentials are disabled when unset.
SWITCHTENDER_ENCRYPTION_SALT serve, worker Per-deployment salt for argon2id key derivation. Must be set alongside the key and stay stable across restarts, or stored credentials cannot be decrypted. Credentials are disabled when unset.
SWITCHTENDER_DB serve, worker, adopt The database when --db is not given: a SQLite file path or a postgres:// DSN. A DSN carries its password, and on the command line it shows in the process list and, on Kubernetes, in the pod spec, so a shared deployment names it here instead.
SWITCHTENDER_AUDIT_KEY serve, adopt Hex-encoded ed25519 seed for the install's signing identity, which signs the LoomSeal bundles it emits and binds every audit entry to this install. Unset beside a local database, the install mints and stores its own key there. Unset against a shared database it mints nothing, since every process must sign as the same install, and the chain is recorded unattributed and unbound until a seed is supplied to all of them. Once any process has signed the chain, a process without the seed refuses to start. A malformed value stops startup.
SWITCHTENDER_IDENTITY_DIR serve, adopt, audit, receipt Directory holding the install's producer signing identity. A SQLite install keeps it beside the database and needs no setting. A postgres install has no filesystem home, so it uses a per-user configuration directory; when the account has no home, as in a container, there is nowhere durable to put a key and startup refuses rather than choosing a path a restart would empty. Point this at a durable path the server owns.
SWITCHTENDER_PASSWORD user new Initial account password, read instead of prompting so it never lands on the command line.
SWITCHTENDER_SMTP_PASSWORD serve Password for SMTP authentication when --smtp-username is set.
SWITCHTENDER_AI_KEY serve API key for a cloud AI provider such as Anthropic or an OpenAI-compatible endpoint. A local Ollama needs none.
SWITCHTENDER_OIDC_CLIENT_SECRET serve OpenID Connect client secret, paired with --oidc-client-id. Read from the environment so it stays off the command line.
SWITCHTENDER_LDAP_PASSWORD serve Password for the --ldap-bind-dn service account.
SWITCHTENDER_WORKER_TOKEN serve, worker Mesh relay bearer token. The server reads it when --worker-token is unset, and a relay worker started with --server presents it on every call.
SWITCHTENDER_GALAXY_SERVER serve, worker Default for --galaxy-server, a private Ansible Galaxy or Automation Hub URL.
SWITCHTENDER_PUBLIC_URL serve Default for --public-url, this server's public address for links in pull request reviews.
SWITCHTENDER_GALAXY_TOKEN serve, worker Token for the --galaxy-server URL, read from the environment so it never lands on the command line.
SWITCHTENDER_ANSIBLE_BIN serve, worker Default for --ansible-bin, the directory of Ansible commands runs use, or system for the ones on PATH.
SWITCHTENDER_ANSIBLE_RUNTIME_DIR serve, worker, ansible The managed Ansible runtime directory when no flag names one. Defaults to ansible/ in the data directory.
SWITCHTENDER_FEDERATION_ISSUER serve, worker Default for --federation-issuer, the external URL this install is an OpenID Connect issuer at for federated cloud credentials. Every process on one database must see the same value.
SWITCHTENDER_PLUGINS_DIR serve, worker Directory of extension plugin binaries, read when --plugins-dir is unset.
SWITCHTENDER_EGRESS_PROXY serve, worker, demo Proxy for the server's own outbound requests: notifications, federation token exchange, forge review calls, external secret sources, git remotes, the registry lookups --image-digest-lookup makes, the identity provider fetches --saml-idp-metadata-url, --jwt-jwks-url, and --oidc-issuer name (OIDC discovery, its key set, and the code exchange), the timestamp authority --anchor-tsa-url names (--anchor-tsa for the demo), and the --forward-url sink. Its scheme is http, https, or socks5. The ambient HTTP_PROXY and HTTPS_PROXY are never used, so a forgotten proxy variable cannot route these requests somewhere that skips the cloud metadata and loopback checks. When this is set, each target is resolved and held to those checks before the request reaches the proxy, and an IP-literal target is refused. The proxy's own egress policy is then the boundary for a hostname that rebinds after the check, so point it at the hosts the server is meant to reach.
SWITCHTENDER_RUNFILES_DIR serve, worker Default for --runfiles-dir, the private directory runs stage their keys, tokens, and secret files under. See Run files.
SWITCHTENDER_ADMIN_PASSWORD init Password for the first admin account. When unset, init generates one and prints it once.
SWITCHTENDER_DESKTOP_NO_BROWSER desktop Set to any value to skip opening the browser, for a headless or remote run.
SWITCHTENDER_NOTIFY_NTFY_TOKEN serve Bearer token for a protected ntfy topic, read when --notify-ntfy-token is unset. A run cannot read it, and a flag shows in the process list.
SWITCHTENDER_NOTIFY_GRAFANA_TOKEN serve Bearer token for the Grafana annotations API, read when --notify-grafana-token is unset.
SWITCHTENDER_NOTIFY_TWILIO_TOKEN serve Twilio Auth Token, read when --notify-twilio-token is unset.
SWITCHTENDER_WITNESS_TOKEN witness serve Bearer token the witness API requires, read when --api-token is unset.
SWITCHTENDER_WITNESS_NOTIFY_SLACK witness serve Slack incoming webhook URL that receives every finding as a message, read when --notify-slack is not given. The URL is its own credential, and a flag shows in the process list.
SWITCHTENDER_MCP_TOKEN mcp API token the agent presents, read when --token is unset.
SWITCHTENDER_TOKEN mcp Fallback for SWITCHTENDER_MCP_TOKEN.
SWITCHTENDER_LICENSE serve, worker, license, audit Path to the license file, overriding switchtender-license.json beside the database.
SWITCHTENDER_DEMO_PORT docker compose Host port the compose demo profile publishes, 8081 by default so the demo and the stack can run side by side.

Every command that takes --db defaults to switchtender.db in the current directory. Only serve, init, demo, restore, and an import create a database there. The commands that work on an existing install, such as token, user, examples, backup, and the audit commands, refuse a SQLite path that does not exist and name the path they looked at. Opening it would create an empty database the server never reads, and the command would report success. An import that starts a new database says so, with the --db a server needs to read it.

init

Bootstraps a new deployment. It creates the database and the first admin account, writes an environment file, and optionally a systemd unit. Run it once, then start serve. Running it again, with --force to rewrite the environment file or after deleting it, leaves an admin account that already exists as it is, password included.

Flag Default Purpose
--db switchtender.db SQLite database path.
--config switchtender.env Environment file to write.
--addr 127.0.0.1:8080 Address the server listens on. Loopback by default.
--admin admin Username for the first admin account.
--systemd none Path to write a systemd unit to, empty to skip.
--force false Rewrite an existing config file, keeping the encryption key and salt it already holds so the credentials sealed under them still open. Delete the file instead to start over with new keys.

The admin password comes from SWITCHTENDER_ADMIN_PASSWORD, or is generated and printed once when that variable is unset.

serve

Runs the HTTP API, the in-process executor, the scheduler, the retention sweeper, and the web UI.

Flag Default Purpose
--addr 127.0.0.1:8080 Address the server listens on. Loopback by default. Set 0.0.0.0:8080 to expose it on the network.
--db switchtender.db SQLite file path, or a postgres:// DSN for the PostgreSQL backend.
--tls-cert none TLS certificate file, to serve HTTPS directly with no reverse proxy. Requires --tls-key.
--tls-key none TLS private key file. Requires --tls-cert.
--oidc-issuer none OpenID Connect issuer URL to enable single sign-on. Empty leaves SSO off.
--oidc-client-id none OIDC client id.
--oidc-redirect-url none OIDC redirect URL, for example https://host/auth/oidc/callback.
--oidc-default-role viewer Role granted to an account created on first SSO sign-in: admin, operator, or viewer.
--forge-oauth none A GitHub or GitLab OAuth application people link their forge account through, so a pull request comment can act as them. Comma-separated provider=github or provider=gitlab, client_id=ID, and the client secret from exactly one of secret_env=VAR or secret_file=PATH, never the command line. Add web_url for GitHub Enterprise Server or self-managed GitLab, and api_url when its API base is not the usual /api/v3 or /api/v4 under it. Repeatable, one per forge. Needs --public-url and SWITCHTENDER_ENCRYPTION_KEY, which signs each link request. See linking forge accounts.
--ldap-url none LDAP directory URL to enable directory sign-in, for example ldaps://ldap.example.com:636.
--ldap-bind-dn none Service account DN used to search for a user, empty for an anonymous search.
--ldap-base-dn none Search base for finding a user.
--ldap-user-filter (uid=%s) Search filter with one %s for the username.
--ldap-default-role viewer Role for an account created on first directory sign-in.
--ldap-role-map none Map a directory group to a role as groupDN=role. A matched group sets the role on every sign-in. Repeatable.
--public-url none Public base URL of this server, such as https://switchtender.example.com. A pull request review links its comment and commit status to the run under it. Empty posts run ids without links. Linking a forge account through --forge-oauth needs it, since the forge sends the person back to this address.
--saml-idp-metadata-url none SAML IdP metadata URL to enable SAML sign-in. Empty leaves SAML off.
--saml-base-url none Public base URL of this server, used to build the SAML entity id and ACS endpoint.
--saml-cert none Path to the service provider certificate, PEM.
--saml-key none Path to the service provider RSA private key, PEM.
--saml-username-attr NameID Assertion attribute used as the username. Empty uses the subject NameID.
--saml-groups-attr groups Assertion attribute holding the user's groups, used with --saml-role-map.
--saml-default-role viewer Role granted to an account created on first SAML sign-in.
--saml-role-map none Map an asserted group to a role as group=role. A matched group sets the role on every sign-in. Repeatable.
--jwt-jwks-url none JWKS URL to enable bearer JWT sign-in, so a service can present a JWT minted elsewhere.
--jwt-issuer none Expected token issuer, the iss claim.
--jwt-audience none Expected token audience. Left empty the audience is not checked, so every token the issuer signs is accepted, including one minted for a different application at the same issuer. Set it unless the issuer serves this install alone.
--jwt-username-claim sub Claim naming the account.
--jwt-groups-claim none Claim holding the user's groups, used with --jwt-role-map.
--jwt-agent-claim st_actor_type Claim that marks a token as an AI agent's when it holds the value agent. A marked token gets the agent ceiling: it can launch and propose work, but cannot manage identity, access, or secrets, and cannot approve its own held run.
--jwt-role-map none Map a token group to a role as group=role. Repeatable.
--jwt-default-role viewer Role granted to an account created on first JWT sign-in.
--ai-provider none Enable advisory AI features with a provider: ollama, anthropic, or openai. Empty leaves AI off.
--ai-model provider default Model name for the AI provider. Required for openai, which has no universal default.
--ai-url provider default Base URL for the AI provider, for a self-hosted Ollama, an OpenAI-compatible server, or a proxy.
--schedule-interval 15s How often the scheduler checks for due schedules.
--workers 4 Concurrent runs this process executes at once. At least 1: the server executes its own runs, so there is no value that makes it execute none.
--min-free-mib 256 Free space, in MiB, on a SQLite database's disk below which new runs are refused with 503 and /readyz reports degraded. Runs already admitted finish. The default is room for four runs at the 64 MiB log cap, which matches the default --workers of 4, so raise it by 64 MiB for each concurrent run you add. 0 turns it off. A PostgreSQL server has no floor.
--max-shards 512 Most groups a split fans out into. A split is always bounded by the host count.
--run-timeout 0 Default cap on how long a run may execute before it is canceled and failed, for example 1h. A run may set a shorter timeout. Zero leaves runs uncapped.
--module-fetch-timeout 2m How long one module download may run, from 1s to 1h. The approval gate downloads the registry and remote modules a Terraform or OpenTofu plan calls before it reads the plan, while the submission waits, and a run downloads them again when the gate's copy is not kept where it executes. Past the bound the download stops and the plan stays unclassified, so it is held or refused.
--module-fetch-max-mib 512 How many MiB one module download may write, from 1 to 65536. The download also stops past 50,000 files. Past either bound the plan stays unclassified, so it is held or refused.
--module-keep-for 168h How long the module trees the gate downloads are kept for the run it judged, from 1h to 2160h. A run held for approval longer than this downloads its modules again when it starts, and is refused unless they match what the gate read.
--module-keep-max-mib 2048 How many MiB the kept module trees may occupy together, from 1 to 1048576. Past it the oldest tree is dropped first, and its run downloads its modules again when it starts.
--notify-webhook none URL that receives a JSON notification when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-slack none Slack incoming webhook URL that receives a message when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-mattermost none Mattermost incoming webhook URL that receives a message when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-rocketchat none Rocket.Chat incoming webhook URL that receives a message when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-discord none Discord incoming webhook URL that receives a message when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-teams none Microsoft Teams incoming webhook URL that receives an Adaptive Card when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold. Repeatable.
--notify-ntfy none ntfy topic URL that receives a notification when a run finishes, is held for approval, waits at a workflow approval step, or needs attention past its alert threshold, such as https://ntfy.sh/my-topic. Repeatable.
--notify-ntfy-token none Optional bearer token for a protected ntfy topic, applied to every --notify-ntfy URL.
--notify-pagerduty none PagerDuty Events API routing key that triggers an incident when a run fails. Repeatable.
--notify-grafana none Grafana base URL that receives an annotation when a run finishes. Repeatable.
--notify-grafana-token none Bearer token for the Grafana annotations API, applied to every --notify-grafana URL.
--notify-twilio-sid none Twilio Account SID for SMS notifications on a failed run.
--notify-twilio-token none Twilio Auth Token, paired with --notify-twilio-sid.
--notify-twilio-from none Twilio sender phone number that texts run failures.
--notify-twilio-to none Phone number that receives an SMS when a run fails. Repeatable.
--allow-container-ee false Allow runs whose project pins a container image to execute inside it. Needs Docker on the executor.
--default-image none Fallback execution image for runs that pin none at the run, template, or project level. It is resolved when a run is submitted and pinned onto it, so an approval covers it. Empty leaves an unpinned run on the host.
--image-digest-lookup true Resolve a submitted run's image tag to the digest its registry serves, so the run executes by that digest and an approval covers that exact image. A run whose registry does not answer is refused at submission unless --allow-unpinned-image is set. A registry that does not answer within 10 seconds is not asked again for a minute, so a dead registry delays one submission a minute rather than every one. The lookup ends when the request submitting the run ends, and the submission stops with it. Turn off where no registry is reachable from the server.
--allow-unpinned-image false Bind a run to its image tag when the tag cannot be pinned to a digest, because the registry did not answer or --image-digest-lookup is off, rather than refusing the submission. The approval view says the run is bound to a tag, and the outcome records the digest that was pulled. An approval of a tag covers whatever the tag names when the run executes.
--require-image-digest false Reject a container run whose image is not pinned to an @sha256: digest.
--container-memory 2g Memory cap for containerized runs, as docker --memory. Empty removes the cap.
--container-cpus 2 CPU cap for containerized runs, as docker --cpus. Empty removes the cap.
--container-pids-limit 2048 Process cap for containerized runs, as docker --pids-limit. Zero removes the cap.
--container-network bridge Network mode for containerized runs, as docker --network, for example bridge or none. A run with network access can reach the host's cloud metadata service and read the instance identity, which on a cloud host is a credential. Use none for runs that do not need the network, and block the link-local metadata address at the host firewall where they do.
--container-runtime docker Container CLI for containerized runs: docker or podman.
--container-pull-policy missing Image pull policy for containerized runs, as docker --pull: always, missing, or never.
--container-runfiles-size 64m Size of the in-memory filesystem a containerized run's private directory is mounted as, nosuid and nodev with exec allowed. It counts against --container-memory as it fills. See Run files.
--runfiles-dir none Private directory each run stages its keys, tokens, and secret files under. Empty picks the systemd runtime directory, then a private XDG_RUNTIME_DIR, then the temporary directory, which the doctor warns about. It must be on a known local filesystem, a tmpfs for preference, and startup refuses one that is not. Also SWITCHTENDER_RUNFILES_DIR. See Run files.
--galaxy-server none Private Ansible Galaxy or Automation Hub URL for project collection installs. Token from SWITCHTENDER_GALAXY_TOKEN.
--ansible-bin none Directory holding the ansible-playbook and ansible-inventory runs use, or system for the ones on PATH. A relative value is resolved against the working directory at startup. Empty uses the managed Ansible runtime when one is in use, then PATH. Also SWITCHTENDER_ANSIBLE_BIN.
--ansible-runtime-dir ansible/ in the data directory Managed Ansible runtime directory. Also SWITCHTENDER_ANSIBLE_RUNTIME_DIR.
--federation-issuer none External https URL this install is an OpenID Connect issuer at, so a run reaches AWS, Google Cloud, or Azure with short-lived federated credentials and nothing durable is stored. Serves the discovery document and public keys under it and needs the encryption key and salt, which seal the signing key. Falls back to SWITCHTENDER_FEDERATION_ISSUER. See Federated cloud credentials.
--strict-grants false Deny non-admins access to an object that has no grants, instead of deferring to the global role. Off by default, which means separation between organizations is not enforced until you turn it on: see below.
--read-only false Reject every mutating request, for a safely exposable instance.
--matrix-cap 50000 Largest host matrix, in cells, the UI draws before showing a notice. 0 means no limit.
--plugins-dir none Directory of extension plugin binaries loaded at startup. Also SWITCHTENDER_PLUGINS_DIR. See Extend in Go.
--worker-token none Bearer token that authenticates mesh relay workers and enables the relay endpoints. Also SWITCHTENDER_WORKER_TOKEN. Keep it secret. On its own, every worker holding it may lease from every queue.
--worker-pools none YAML file binding each worker token to the queues it may lease from, so a queue is a boundary rather than a routing hint. A pool may also register a delivery_key to receive its runs' secrets sealed to it: see Delivering secrets to relay workers.
--retain-runs none Delete terminal runs older than this, for example 90d. Empty keeps them forever. Deleting a run does not make what it left behind unreadable: summaries, drift, and host state history outlive it, and the purge retains the record of who could read the run so those rows stay readable to exactly the same people. That record is dropped automatically once nothing references it. A run whose outcome is not yet on the audit chain is kept, with its steps or shards and everything they hold, until the outcome is committed.
--retain-events none Drop run events and logs older than this, for example 30d. Empty keeps them forever. A run whose outcome is not yet on the audit chain keeps its events and logs until the outcome is committed, since the outcome commits to its log.
--retain-history none Keep only this many per-host and per-task summaries for each host and each task, for example 500. Summaries outlive the runs they came from, so this is the only bound on them. Zero keeps every summary forever. A smaller value is raised to 500, the deepest window the fleet views will answer. The summaries of a run whose outcome is not yet on the audit chain are kept until it is.
--facts-interval 24h Minimum spacing between retained host state snapshots. The estate history keeps the newest gather in each period, which is what answers what a host looked like on a date. Zero keeps every gather, at roughly a hundred times the disk.
--retain-facts 400 Keep only this many host state snapshots for each host. A fact set is hundreds of kilobytes, so unlike summaries this is bounded by default. Zero keeps every snapshot forever.
--retention-interval 1h How often the retention sweeper runs.
--evidence-dir none Directory for periodic change registers. Set together with --evidence-cadence. Team: the server refuses to start with a cadence set on a Community license.
--evidence-cadence none How long each change register covers and how often one is written, for example 2160h for a quarter. Minimum 1h. Zero writes none. Progress is read from the archive, so a restart resumes from the newest pack rather than starting the period again.
--forward-url none HTTP endpoint audit events stream to as NDJSON, one JSON object per line, each carrying its seq:link receipt. Splunk HEC raw, Elastic, and log routers ingest it directly. A redirect is refused and counts as a failed delivery, so point it straight at the collector.
--forward-header none Header set on every forwarded batch, as Name: value, for example an HEC token. Repeatable.
--forward-syslog none TCP syslog collector (host:port) audit events stream to as RFC 5424, octet-counted, one message per event with the JSON event as the body.
--forward-syslog-tls false Wrap the syslog connection in TLS.
--forward-state switchtender-forward.json Durable cursor recording the last position every sink accepted. The cursor advances only on delivery, so an outage delays events rather than dropping them, and a restart resumes without restreaming.
--forward-interval 5s How often the forwarder polls the chain when caught up. Minimum 1s.
--smtp-addr none SMTP server host:port for run notification emails. Empty disables email.
--smtp-from none Sender address for notification emails.
--smtp-to none Recipient address for notification emails. Repeatable.
--smtp-username none SMTP username. The password comes from SWITCHTENDER_SMTP_PASSWORD.
--notify-on failure When to email: failure for failed runs only, or finish for every finished run, every run held for approval, and every workflow waiting at an approval step. An attention alert is emailed under either setting.
--policy-file none YAML file holding the approval policies and naming any Rego modules they load, read relative to the file. When set, the file is the source of truth and the API refuses policy edits. See Approval policies.
--attention-file none YAML file setting when waiting work shows as blocked on the overview's Needs attention panel and when it alerts, as organization defaults with per-organization, per-queue, and per-template overrides. Empty uses the built-in thresholds. A malformed file stops the server. See Attention thresholds.
--trusted-proxy none CIDR of a reverse proxy whose client IP header to believe, repeatable. Required behind a proxy: without it every request appears to come from the proxy itself, so the failed sign-in budget, the webhook rate limit, and the per-client stream budget all become one budget shared by everyone behind it, and one stranger's failed guesses can lock sign-in for the whole install.
--client-ip-header none Header carrying the real client address from a trusted proxy. Defaults to the leftmost X-Forwarded-For entry.
--callback-rate-limit 30 Provisioning callbacks one client address may make in a minute, the native and the AWX-compatible callback address counted together. A refusal answers 429, names the limit and this flag, and is logged once per address a minute. See callback rate limits for hosts behind one NAT address.
--callback-key-failure-limit 10 Wrong host config keys one client address may present in a minute. Past it, every callback from that address is refused for the rest of the minute, the right key included, so a key cannot be guessed at a useful rate.
--fact-cache-admin-only false Restrict reading cached facts to admins. By default an operator with read on the inventory and on the run that gathered the facts may read them, with secret-looking values masked.
--span-cadence 0 Append a span beat to the audit chain this often, for example 60s. Whole seconds only. Zero appends none.
--anchor-tsa-url none RFC 3161 timestamp authority the chain anchors against. Empty anchors nothing.
--witness-enrollment none Enrollment file to push each span beat to its witness under. Empty reads witness-enrollment.json beside the license file, and an install with neither pushes nothing. An enrolled install refuses to start without --span-cadence or without a producer identity, since it would run unwitnessed while its enrollment says otherwise. At start it holds the audit chain to the head the witness countersigned, and refuses to start on a chain that does not reach it or on a statement the enrollment does not accept. A witness that does not answer with its signed statement is a warning, and the server starts. A witness outage never blocks the timestamp anchor, and the reverse.

Retention windows accept a whole number of days with a d suffix, such as 30d, or Go duration syntax such as 720h.

Approval binding needs the producer identity

A server that loads the install's producer identity derives an approval binding key from it, held in memory and never written anywhere. It seals every run it creates or releases under that key and checks the seal before the run executes, so a run whose spec, approval fields, decision, or who decided was changed in the database after it was approved is refused rather than run. Every server of one install must load the same identity, from the identity directory or from SWITCHTENDER_AUDIT_KEY, since a run sealed under one key is refused under another. A relay control node checks each run before it hands it to a relay worker. A worker that shares the database holds no key, so the runs it claims are held only to the digests stored on them.

A server with no identity on a chain no install has signed holds no key. It still compares the digests stored on each run, and its startup warning says that approval binding is row-checked only, which a change written into the database together with matching digests passes. A server that finds the chain bound to an install whose key it does not hold refuses to start, as the backup page explains.

A server that loads an identity refuses to start while work an earlier release left carries no seal and the audit chain holds no adoption record, and it names the runs. Run adopt once to seal that work, then start the server. A server that loads an identity and finds nothing to adopt records the adoption itself at its first start, trusting no row. A start that finds sealed work and no adoption record is refused, since the record was removed.

AI providers

The advisory AI features run against one provider, chosen with --ai-provider: ollama for a local model, or anthropic and openai for a cloud model with SWITCHTENDER_AI_KEY. The provider is off until set, and no feature ever executes anything the provider suggests.

Cloud models see automation content: commands, playbook names, failed-run logs, and host drift. Because that content is security-adjacent, a model with strict safety classifiers can decline a benign request as a false positive. When the Anthropic model is a Fable or Mythos model, SwitchTender opts into server-side fallbacks, so a declined request is retried on claude-opus-4-8 in the same call and the feature keeps working. A Fable model also requires that the account keep 30-day data retention, or the API rejects every request.

desktop

Runs SwitchTender as a local desktop application. It serves on a private loopback port, stores its data in a per-user directory, and opens the web UI in the default browser. It takes no flags. Set SWITCHTENDER_DESKTOP_NO_BROWSER to skip opening a browser. See Desktop for packaging a macOS app or a Windows installer.

worker

Leases pending runs from the shared store and executes them. Point it and a server at the same database and they compete for work. Or start it with --server and it leases runs from the control node over the mesh relay, with no database access of its own.

Flag Default Purpose
--db switchtender.db SQLite file path, or a postgres:// DSN. With --server the worker opens no database, and the value only places the managed Ansible runtime, in ansible/ beside it.
--server none Control node base URL to lease runs from over the mesh relay, for example https://switchtender.example.com. When set, the worker needs no database and dials one outbound connection. Token from SWITCHTENDER_WORKER_TOKEN.
--name host and pid Worker name stamped on the runs it executes.
--queue none Queue this worker serves. Repeatable. Without any, it serves the default pool.
--workers 4 Concurrent runs this process executes at once. At least 1: a worker with no slots would lease nothing and sit idle.
--run-timeout 0 Default cap on how long a run may execute before it is canceled and failed, for example 1h. Zero leaves runs uncapped.
--module-fetch-timeout 2m How long one module download may run, from 1s to 1h. A run downloads its modules again here when the copy the gate read is not kept on this machine, and goes ahead only if they are the ones the gate read.
--module-fetch-max-mib 512 How many MiB one module download may write, from 1 to 65536, and at most 50,000 files.
--module-keep-for 168h How long a module tree kept in this worker's run-files root is offered to the run the gate read it for, from 1h to 2160h. A worker sharing its run-files root with a server executes the trees that server's gate kept.
--module-keep-max-mib 2048 How many MiB the module trees this worker keeps may occupy together, from 1 to 1048576, the oldest dropped first.
--facts-interval 24h Minimum spacing between retained host state snapshots. Applies only with --db: a worker using --server reports what it gathered to the control node, which spaces the history with its own setting.
--retain-facts 400 Snapshots kept per host. Applies only with --db, for the same reason.
--allow-container-ee false Allow container execution environments on this worker. Needs Docker.
--default-image none Fallback execution image for runs that pinned none when they were submitted. It never applies to an approved run: one approved to run on the host is refused rather than moved into this image.
--require-image-digest false Reject a container run whose image is not pinned to an @sha256: digest.
--plugins-dir none Directory of extension plugin binaries loaded at startup. Also SWITCHTENDER_PLUGINS_DIR.
--container-memory 2g Memory cap for containerized runs, as docker --memory. Empty removes the cap.
--container-cpus 2 CPU cap for containerized runs, as docker --cpus. Empty removes the cap.
--container-pids-limit 2048 Process cap for containerized runs, as docker --pids-limit. Zero removes the cap.
--container-network bridge Network mode for containerized runs, as docker --network.
--container-runtime docker Container CLI for containerized runs: docker or podman.
--container-pull-policy missing Image pull policy for containerized runs, as docker --pull: always, missing, or never.
--container-runfiles-size 64m Size of the in-memory filesystem a containerized run's private directory is mounted as, nosuid and nodev with exec allowed. It counts against --container-memory as it fills. See Run files.
--runfiles-dir none Private directory each run stages its keys, tokens, and secret files under. Empty picks the systemd runtime directory, then a private XDG_RUNTIME_DIR, then the temporary directory, which the doctor warns about. It must be on a known local filesystem, a tmpfs for preference, and startup refuses one that is not. Also SWITCHTENDER_RUNFILES_DIR. See Run files.
--galaxy-server none Private Ansible Galaxy or Automation Hub URL for project collection installs. Token from SWITCHTENDER_GALAXY_TOKEN.
--ansible-bin none Directory holding the ansible-playbook and ansible-inventory runs use, or system for the ones on PATH. A relative value is resolved against the working directory at startup. Empty uses the managed Ansible runtime when one is in use, then PATH. Also SWITCHTENDER_ANSIBLE_BIN.
--ansible-runtime-dir ansible/ in the data directory Managed Ansible runtime directory. Also SWITCHTENDER_ANSIBLE_RUNTIME_DIR.
--federation-issuer none The same issuer URL serve is given. The worker signs the identity tokens of the runs it executes with the keys every process on the database shares, under this URL. Falls back to SWITCHTENDER_FEDERATION_ISSUER.
--policy-file none YAML file holding the approval policies, the same file the control node reads, with the Rego modules it names beside it.
--delivery-key none Private key file a relay worker opens the run secrets its control node seals to the worker's pool with, made by switchtender worker key new. Repeatable, so a worker holds its pool's old and new key while the pool rotates. Only with --server. A file another account can read is refused. See Delivering secrets to relay workers.

worker key

switchtender worker key new --out FILE generates a delivery key for a relay worker pool. It writes the private key to --out, mode 0600, and never over an existing file, then prints the public key and its key id as one JSON line:

{"key_id":"f4e8a76a457da19cf3742de37986d439","delivery_key":"x25519:x9f9/kkB6Jq3Wqht/7TWzDGUeTRfbABrj1CO+8/6hHc=","file":"/etc/switchtender/delivery.key"}

switchtender worker key public FILE prints the same line for a key file that already exists, and refuses one another account can read. The file is a standard PKCS #8 X25519 private key, so openssl genpkey -algorithm X25519 -out FILE makes an equivalent one, and openssl pkey -in FILE -pubout -outform DER | tail -c 32 | base64 prints the base64 that follows x25519:.

ansible

Installs, lists, and removes the managed Ansible runtime: a Python virtual environment holding one pinned ansible-core release, installed with pip in hash-checking mode from a lock this binary carries. Runs use it when no --ansible-bin is configured. A python3 the release supports must be installed.

Every ansible subcommand takes --dir for the runtime directory, which defaults to SWITCHTENDER_ANSIBLE_RUNTIME_DIR, then ansible/ in the data directory of --db, and --pretty for indented JSON.

token

Manages API tokens. A public bind on an empty database mints an initial admin token at startup.

All token subcommands take --db and the global --pretty flag for indented JSON.

user

Manages accounts with roles: admin, operator, and viewer.

All user subcommands take --db.

license

Shows or installs this install's license. No license is Community, which is complete in itself. What each tier covers is listed at https://switchtender.com/pricing.

A license is read from the file SWITCHTENDER_LICENSE names, or from switchtender-license.json in the same directory as the database. A license this install cannot parse or that has lapsed reads as Community rather than failing the server, so an expiry never takes an install down.

assess

Reports what an automation export holds and what governing it would change, without writing anything and without a database. It reads an export the same way import does, then grades every template it found through the same risk and reversibility graders the product uses at run time, so a number in an assessment cannot disagree with what a run would later say.

switchtender assess awx awx-export.json
switchtender assess chef chef-nodes.json

Formats are awx, semaphore, chef, and puppet. An AWX-format export also covers Ansible Automation Platform, Tower, and Ascender.

An export saved as UTF-8 with a byte order mark, or as UTF-16 the way Windows PowerShell writes a file by default, is read the same as plain UTF-8, here, by import, and by the assessment page.

The output has three parts: what the import would create, what does not survive the move as it was, and what changes about how it is governed. Everything that does not come across is listed, one line each. The third part names the templates that cannot be undone, the ones carrying a destructive signal, the credentials more than one template shares, and the templates targeting no stored inventory.

Grades are a floor rather than a measurement. An Ansible template keeps its work in a playbook, and at assessment time that playbook is in a repository nothing has fetched, so reading one can only ever raise a grade. The report says how many templates that applies to rather than leaving the numbers looking more settled than they are.

import

Migrates from AWX, Semaphore, Chef, Puppet, Rundeck, Jenkins, or cron. Which objects each one carries across is in what each source brings over.

All seven take --db for the target database. Without --apply the command only reports what it would create. Rundeck, Jenkins, and cron import no credentials, so a job that needed a login needs one built by hand afterward.

audit

Audit trail tools.

Flags on the subcommands:

receipt

Writes a signed receipt for one finished run, which a third party verifies offline with verify. A receipt is the chain segment from the request that created the run through the entry recording what it did, signed with this install's key and carrying any anchors that fix its position.

Flag Default Purpose
--db switchtender.db Database holding the run and its chain.
--out stdout File to write the receipt to.
--sparse off Disclose only this run's own chain entries, proving each belongs to the log without carrying the entries around it.
--append-only-from unset With --sparse, prove the log only appended since this size. Use a size a reader already saw, such as an anchored head.

Publish the key fingerprint the command prints so a verifier can pin it.

verify

Verifies a receipt written by receipt. It trusts nothing this server says: it recomputes every chain link from the receipt's own claims, checks the signature covers the exact bytes, and confirms any anchors name an entry the receipt holds. It reads only the file, reaches no database and no network, and does not run the server, so a relying party can check a receipt on a machine that has never seen this install.

switchtender verify run.receipt --pubkey sha256:...

Pass --pubkey with the fingerprint the producer published to tie the result to a key obtained out of band. Without it the receipt is checked against the key it names, which proves it was not altered but not who signed it, and the verdict says so: INTACT, BUT UNIDENTIFIED rather than VERIFIED. A --pubkey given an empty value, which is what a failed key fetch leaves behind, is refused rather than treated as no pin.

An install enrolled with a witness carries the witness's countersignatures in its bundles and receipts. Pass --witness-pubkey with the key the witness publishes, in either the sha256 key id or the raw hex form, so only that witness's countersignatures are counted, and --witness with the statement fetched from the witness at GET /v1/installs/<key id>/attestation, where <key id> is the producer key id, so the file is held to the newest head the witness countersigned. A file that ends before that head, or a statement that holds nothing yet, is reported as not checkable and never reads VERIFIED. An empty --witness-pubkey is refused.

witness

Watches a server's span beat feed from outside it. A chain proves what it holds was not altered, but not that nothing was removed from the end, because the process running the chain also decides what gets written down. A witness on another machine remembers what the feed served, keeps that memory in a signed checkpoint, and raises a finding when a beat goes missing, an already-witnessed beat comes back rewritten, or the head regresses. Run it where the server's operator has no hand.

The server has to be writing beats for there to be anything to watch, and beats are off by default. Start the server with --span-cadence, for example --span-cadence 60s. Against a server with beats off, the witness reports empty_feed, and a --once run exits nonzero.

Flag Default Purpose
--server required Base URL of the server to watch.
--state switchtender-witness.json Signed checkpoint holding what this witness has seen.
--interval 1m How often to poll the feed.
--key-dir the state file's directory Directory holding the witness signing key.
--once off Run one check and exit nonzero on findings, for cron.
--webhook unset URL that receives each finding as a JSON POST.

witness serve

Runs the witness. Its flags are checked against that subcommand rather than witness itself. With --accept-enrollments it is also a push witness, the one the hosted witness page describes, and --enrollment-policy open runs it for installs on your own network.

Flag Default Purpose
--listen 127.0.0.1:9440 Address the witness API serves on.
--state-dir switchtender-witness Directory holding the signed checkpoints and the findings record.
--api-token none Bearer token the witness API requires. Prefer SWITCHTENDER_WITNESS_TOKEN. Required when --listen is not loopback.
--watch none Base URL of a server to watch, repeatable, at least one.
--interval 1m0s How often every watched server is checked. At least 10s.
--key-dir the state directory Directory holding the witness signing key.
--webhook none URL that receives every finding as a JSON POST.
--accept-enrollments off Accept enrollments from installs that push their own chain heads. --watch is then optional.
--enrollment-policy licensed Who may enroll: licensed (a license that includes the hosted witness) or open (any signed enrollment).
--max-installs-per-license 10 How many installs one license may enroll.

--notify-slack takes a Slack incoming webhook URL, is repeatable, and is unset by default. The witness posts every finding there as a message that names its kind, its class (refusal or note), the producer key id of the install it is about or the watched server, and its detail. When the flag is not given, the URL in SWITCHTENDER_WITNESS_NOTIFY_SLACK is used, which keeps the URL out of the process list. Messages are sent in order from a queue of their own, so an install's push is never answered late because Slack is slow. A post that fails is tried once more after two seconds, and a second failure is logged with the URL masked. The findings record stays the durable copy either way.

witness rotate-key

Retires the witness signing key and makes a new one current. Stop the witness first and start it again after, since a running witness keeps signing with the key it loaded.

switchtender witness rotate-key --key-dir /var/lib/switchtender-witness-key \
  --state-dir /var/lib/switchtender-witness

The witness publishes every key it has signed with at /.well-known/loomseal.json and in every enrollment answer, each with its valid_from and, once retired, its retired time. After a rotation the list is signed by the new key and endorsed by every retired key the key directory still holds, so an enrolled install that holds an earlier key accepts the new list on that key's word. An attestation a key made before it retired verifies against the list for as long as anyone keeps it. The witness keeps reading its own memory signed by a retired key, for memory written before that key retired, and signs it with the new key at the next push it adopts. A retried push gets the bytes it was first answered with while the witness still holds the key that answered it.

--key-dir is required and names the directory holding the key, the same one witness serve runs with. --state-dir names the witness state directory and defaults to switchtender-witness. The key directory must not be the state directory, lie inside it, or hold it, because the state directory is copied off the host. The retired key stays in the key directory, readable only by the account that runs the witness. Back up the whole key directory after a rotation, since its key history is part of what the witness publishes. Deleting a retired key's file stops that key endorsing anything again, which is the step after a compromise, and an install that held only that key then pins the new key by hand. The command prints the retired key and the new current key as JSON.

witness enroll

Enrolls this install with a push witness. The server then sends every span beat to the witness with the tree root at that beat and a consistency proof from the root the witness last signed, and stores the countersignatures the witness answers with. It needs a license that includes the hosted witness: Enterprise, or Team with the add-on. Without one the command refuses and sends nothing.

switchtender witness enroll --db /var/lib/switchtender/switchtender.db \
  --witness https://witness.switchtender.com

The command prints the witness key id. Compare it with the id the witness publishes at /.well-known/loomseal.json and with the one the hosted witness page carries before trusting what it countersigns, or pass --witness-key to refuse any other. The enrollment is written to witness-enrollment.json beside the license file, so a backup and a restore carry both or neither. It holds no secret. An install whose key was changed under its install id through SWITCHTENDER_AUDIT_KEY is refused, since the witness binds an install id to the key it was born to. A new install started after its signing key was lost enrolls as a new record under its new key, beside the old install's record, which keeps its last countersigned head.

Flag Default Purpose
--db switchtender.db Database of the install to enroll. The license, key, and enrollment are found from it.
--witness none Base URL of the witness. Required unless --refresh-keys is given. It must be https, or plain http to a loopback address (127.0.0.0/8, ::1, or localhost).
--witness-key none Witness key id, sha256:..., the witness must sign with. Anything else writes nothing.
--refresh-keys off Fetch the enrolled witness's key list after it rotates, kept only when a held key signed it.

witness verify-attestation

Verifies one attestation offline, for the relying party. It reads the JSON, recomputes the signature, and prints the verdict with the signer named in both published forms: signed_by is the raw hex public key the attestation carries, and signed_by_key_id is the sha256 key id the witness prints at startup and serves from its API.

switchtender witness verify-attestation attestation.json --pubkey sha256:...

The verdict carries pinned. Without a pin, ok means only that the document is internally consistent, which an attestation a forger signed with their own key also is, so an unpinned verdict carries a note saying so. An empty --pubkey is refused.

Flag Default Purpose
--pubkey none Pinned witness key, as either the sha256 key id or the raw hex public key.

Pass --pubkey with the key you pinned out of band. Either form is accepted, since the key id is what the witness tells operators to publish and the hex key is what the document carries. Without it the check proves only that the attestation is internally consistent, which a forger with their own key satisfies trivially.

mcp

Serves the Model Context Protocol over stdio, so an agent can list templates, propose a run, and read what happened. Every tool call is an ordinary authenticated API request carrying the token given here, so it passes the same authorization, the same approval policy, and the same fail-closed audit append as a request from a person. A run it proposes waits for a person's approval unless a rule with effect: exempt covers it, as agent runs are held by default describes. See Agents.

export SWITCHTENDER_MCP_TOKEN=swt_...
switchtender mcp --server https://switchtender.internal

The token is read from SWITCHTENDER_MCP_TOKEN, falling back to SWITCHTENDER_TOKEN. Prefer the environment variable to a flag, whose value is visible in the host's process list. The command refuses to start on an admin token. There is deliberately no approve tool, so an agent cannot release its own work, and no credential, account, token, grant, or policy tool, so it cannot widen its own reach.

Flag Default Purpose
--server required SwitchTender API base URL.
--allow-adhoc off Also expose the ad-hoc run tool, letting the agent compose a run rather than launch a template an operator defined. The default hold on agent runs and approval policy still apply.
--token none API token the agent presents. Prefer SWITCHTENDER_MCP_TOKEN.
--timeout 1m0s Bounds one API call.
--allow-admin-token false Start even when the token has admin rights. An agent should hold an operator-bound token, so this is a deliberate override.

demo

Seeds a fresh database with sample data and real runs, then serves it read-only, so a public instance is safe to expose. It needs ansible on the PATH to run the sample playbooks.

Flag Default Purpose
--addr 127.0.0.1:8080 Address the demo listens on. Loopback by default.
--db temporary file Database to seed and serve. Empty uses a fresh temporary SQLite file, removed when the demo stops.
--seed-only off Seed the database and exit without serving.
--no-seed off Serve the database as it already stands instead of seeding it.
--anchor-tsa a public authority RFC 3161 authority that anchors the seeded chain, so the demo shows a real anchor. Empty anchors nothing.
--trusted-proxy none As serve.
--client-ip-header none As serve.
--span-cadence 0 As serve.

Seeding runs real playbooks and takes a couple of minutes, which is a visible gap if a public demo reseeds in place. The two flags split that work in half so it can happen off to the side:

switchtender demo --db next.db --seed-only     # build the next database, serving continues
mv next.db demo.db                             # swap it in
switchtender demo --db demo.db --no-seed       # serves the prepared data in under a second

A host wired this way reseeds without a visible outage, since the running instance keeps answering the whole time the replacement is being built.

examples

Seeds a handful of starter templates, so a first launch works on the spot rather than opening on an empty list. They use the Bash tool and print or read something local, needing no project, inventory, or credential. Delete them once you have your own.

Run it against the same database serve uses. It skips a template whose name is already present, so it is safe to run twice. Takes --db.

backup

Writes an encrypted, portable backup of the control-plane configuration and secrets. The whole file is sealed with the deployment encryption key, so it stays confidential and tamper-evident, and it restores into either the SQLite or the PostgreSQL backend. See Backup and restore.

Flag Default Purpose
--db switchtender.db SQLite file path, or a postgres:// DSN, to back up.
--out stdout File to write the backup to.

Run history and the audit chain are not included. The audit chain has its own signed export, through audit bundle.

restore

Reads a backup and upserts its objects into the store by id. It needs the same encryption key the backup was written with, and it never deletes objects absent from the file.

Flag Default Purpose
--db switchtender.db SQLite file path, or a postgres:// DSN, to restore into.
--in stdin Backup file to read.

adopt

Seals, once per install, the work a release with no approval binding key left behind, so a server of this release accepts it. Run it after every server and worker of the earlier release has stopped and before this release's serve first starts. Upgrading gives the order.

Flag Default Purpose
--db switchtender.db SQLite file path, or a postgres:// DSN, of the install to adopt.
--dry-run false Print the rows adoption would seal and write nothing.
--yes false Seal without asking, for a script. The rows are still printed.
--fail-unsealed false Record the adoption trusting no row, and fail every unfinished run that carries no seal.
--witness-enrollment none Enrollment file of the witness whose countersigned head the audit chain is held to. Empty reads witness-enrollment.json beside the license file, and an install with neither is held to no witness. A named file that is missing or out of form is an error, as for serve. An enrolled adoption pushes a span beat after its record and exits non-zero when that push fails, with the adoption kept.

Adoption reads every run that has not finished, the finished steps a waiting workflow reads forward, and the decisions left in flight, and gives each the seal its kind is held to, from the row as it reads and the decision entries it names. A row it cannot seal, such as one whose decision entry is not on the chain, is listed with the reason and left unsealed, and a server refuses that row when it reaches it. Before the rows, adopt prints the chain's entry count, its newest anchor (type, sequence, and reference) or that it has none, and the install the chain is bound to or that this adoption binds it. Then it prints every row it seals, with the run id, the kind of seal, who asked for the run, who decided it, the decision entry, its spec digest, and its release entry, and a count of decision claims whose decisions are not on the chain yet. Compare the anchor with its external target, which is the git ref, the published head, or the witness, before answering. Adopt asks before it writes. --yes skips the question. Without it, adopt refuses to run where standard input is not a terminal.

--yes is for one run by hand. Never put adopt in an entrypoint, a chart hook, an init container, or a recurring job. A second adopt on the same install is refused and exits non-zero, which a script should expect.

It refuses, and writes nothing, in each of these cases. Each shows that keyed operation already began, so adopting again would seal whatever was written since as though the earlier release had left it.

An install refused for one of the last two reasons that has no copy to restore runs adopt with --fail-unsealed. It records the adoption with nothing adopted, so no row is trusted, and fails every unfinished run that carries no seal with the reason not adopted. A run that carries a seal is left for the server to check when it reaches it, and one it did not make is refused. A run still held under a lease, or with a decision or a cancel in flight, is listed and left as it is. It still refuses a chain that does not verify and an install whose adoption is already recorded.

Adopt appends one record to the audit chain, at /runs/adopted under an id the install's key fixes, before it writes any seal. A second adopt on the same install is refused and says when and by whom the first one ran. The rows it seals are the rows it showed, as they read when it showed them. A row changed while the list was on screen carries a seal over what was shown, which a server refuses, and a row created meanwhile is not sealed.

Adopt needs the install's producer identity, the same one serve loads, from the identity directory or SWITCHTENDER_AUDIT_KEY. An install with no identity has no key and nothing to adopt, and adopt says so. Adopt binds an unbound chain to the key it runs with and names where that key came from, so against a PostgreSQL DSN run it with the seed the servers will sign with in SWITCHTENDER_AUDIT_KEY. A server whose key names another install refuses to start.

version

Prints the SwitchTender version.

--verify fetches this version's published binary hashes and verifies the running executable.

help and completion

Both are the standard Cobra built-ins. help prints usage for any command, and completion emits a shell completion script for bash, zsh, fish, or PowerShell.

Output flags

JSON goes to stdout compact by default. Four commands take --pretty to indent it. It is not a global flag, so passing it elsewhere is an error rather than a no-op.

Flag Where Purpose
--pretty token and its subcommands, ansible and its subcommands, audit anchor, audit receipt Indent JSON output instead of the compact default.

Separation between organizations is opt-in

Organizations, teams, and grants exist, but with --strict-grants off an object nobody has granted falls back to the caller's global role. On that default any operator may use any project, inventory, or credential in the install, whichever organization it belongs to. That is deliberate: a small team should not have to grant every object before anything works, and an upgrade should not lock people out of what they were already using.

It does mean an install with several organizations on it is not separated until --strict-grants is on. If you are running work for more than one team, more than one customer, or anything where one group must not reach another's credentials, turn it on and grant deliberately. Objects created before you do carry no grants, so plan to assign them.

A refusal from these rules says so: the body reads forbidden: this object requires a grant you do not hold, which is the same sentence whether the object is delegated elsewhere, ungranted under strict grants, or does not exist. It tells you a grant is what is missing rather than a role or an account, and nothing about the object itself.

What --strict-grants decides is only the default for an object nobody has granted. An object that carries a grant is access-controlled in both modes: writing a grant on it is what declares it so, and a caller who does not hold one is denied whether or not strict grants are on. Every listing answers the same rule the by-id read does, so a run you are refused by name is also absent from the run list, the fleet and drift views, the host pages, and the change log.

One consequence is worth knowing before you write your first grant. Install-wide figures that carry no per-row id are served whole only to a caller nothing is hidden from, because such a total is every other group's volume in one number. On an install with no grants at all that is everybody, which is the common case and is unaffected. It stays unaffected by a grant on a template or a worker queue, since neither can hide a run. What changes it is a grant on a project, an inventory, or a credential that the caller does not hold.

For a caller in that position each surface answers differently, and the difference is worth knowing before you point a dashboard at one:

Surface What a restricted caller gets
The run-count cards on the run list Their own visible totals, with summary.scope reading visible rather than install.
The task-duration table at /v1/tasks No rows, and withheld: true beside them, so an empty table is not mistaken for a quiet install.
The Prometheus exposition at /metrics An empty body with status 200. Every series is absent rather than zero, so give the scrape an admin token if you want the install's counters.

The empty /metrics body is deliberate: a scraper reads a 5xx as the install being down and pages somebody. It does mean an alert written against a series going to zero will not fire for a scrape token that has become restricted, because the series is gone rather than zero. Scrape with a token that holds everything, or alert on the scrape's own staleness as well as its values.

Directory sign-in and existing accounts

An account records what created it: an administrator, or the directory that provisioned it. A directory identity signs in to an account only when that account is its own, so an identity provider asserting the username of a local administrator is refused rather than handed that administrator's account and role.

This matters most when the username does not come from a stable subject. With --jwt-username-claim email or a SAML username attribute pointing at an email, against an issuer that lets a user set their own address, matching on username alone was account takeover. OIDC has always refused the equivalent when the provider does not vouch for the address.

An account created before this was recorded carries no source, so it cannot be told apart from a local one of the same name. Those are still signed in to, because refusing them would lock out every directory user on upgrade, and each is logged so an administrator can set the source and remove the ambiguity.

Attention thresholds

The overview's Needs attention panel, GET /v1/attention, sorts waiting work into four answers to what is stopping it: a lost worker, no worker for its queue, an approval, or a block behind another run. Five thresholds decide when work shows as blocked and when each answer alerts. An alert is listed by the doctor and told, once per condition, to the server-wide chat channels, webhooks, ntfy, and email, to a run's own targets of those kinds, and to the named targets attached for the attention event, on every kind including PagerDuty. The API reference describes the answer.

Threshold Default What it sets
blocked_after 15m How long a run waits behind another run or a full worker before it shows as blocked at all. It cannot be off.
alert_no_worker 15m How long a run waits with no connected worker serving its queue before it alerts.
alert_blocked 15m How long a run stays blocked before it alerts.
alert_worker_lost 1m How long after a lost worker last reported its run alerts, when the lease sweep has not reclaimed it by then. The default is two lease periods.
alert_approval off How long an approval waits before it alerts. Off unless a file turns it on, since an approval waiting is the gate working.

Each value is a Go duration such as 90s, 15m, or 4h, or off. Zero is refused rather than read as off. The file states the organization's defaults and may override them for one organization on the install, one queue, or one template, and the most specific wins: a template over a queue, a queue over an organization, an organization over the defaults. Each override replaces only what it states. The default queue is written as "".

defaults:
  alert_no_worker: 10m
  alert_approval: 8h
orgs:
  org_ab12cd34ef56:
    alert_approval: 2h
queues:
  prod:
    alert_no_worker: 2m
    blocked_after: 5m
  "":
    alert_blocked: 30m
templates:
  tpl_0123456789ab:
    alert_approval: 30m

An unknown key, a value that is not a duration, and blocked_after: off are refused when the server starts, so a typo never silently leaves an alert on its default or off.

Confining relay workers to their queues

A relay worker runs in a segment the control node cannot reach, which means the least trusted machine in the estate holds a worker token. With a single --worker-token, that machine may name any queue it likes and lease from it, so a compromised host in a DMZ can take a production run and execute it with production credentials.

--worker-pools binds each token to the queues it may serve. The file stores the SHA-256 of each token, never the token itself, the same way a webhook secret is stored:

workers:
  - name: dmz
    token_sha256: 9f2c...           # sha256 of that pool's bearer token
    queues: [dmz]
  - name: production
    token_sha256: 41ab...
    queues: [prod, canary]

A pool that declares no queues may lease from all of them, which is the single-token shape stated out loud. A pool that declares queues is refused anything else, including the default queue when it names none, so confinement cannot be escaped by omission.

Generate a digest with printf %s "$TOKEN" | shasum -a 256. A malformed file stops the server rather than falling back to no confinement, because an install that believes it is segmented and is not is worse than one that refuses to start.

That confines the lease side. The submit side is confined by granting the queue, which is a grantable object named queue:<name>:

curl -X POST localhost:8080/v1/grants \
  -H "Authorization: Bearer $ST_TOKEN" \
  -d '{"subject": "team_sre", "object": "queue:prod", "access": "use"}'

A queue nobody has granted follows the same rule every other object does: the global role decides, unless --strict-grants is on, in which case an ungranted queue is refused. Granting a queue makes it access-controlled, so only the subjects named may route work to it. The grant is checked wherever a queue is chosen: on a run, on a template, on an inventory, and at launch against the template's own queue.

An install that has not turned strict grants on can gate a queue with a rule instead, since a policy matches on queue:

policies:
  - name: hold anything headed for production
    queue: prod
    require_distinct_approver: true

A policy with queue holds only what is routed to that queue, and a run on a named queue executes only when a worker serving that queue claims it. The control node's own runner takes unqueued runs alone. On an install without relay workers, a queued run therefore waits as pending indefinitely, even after approval. To gate runs the server itself executes, write the policy without queue.

Delivering secrets to relay workers

A relay worker holds no database and no encryption key, so on its own it cannot open a credential, and a run that needs one fails on it. A pool opts in to sealed delivery by registering a delivery key. When one of its workers claims a run, the control node opens that run's secrets and nothing else: its credentials and those of the inventory it targets, the registry login its image is pulled with, its custom credential types' field values, from which the worker renders their files, its secret survey answers, and, for a federated credential, an identity token minted for the run. It seals them to the pool's key, bound to that claim, and sends them with the claim. The worker opens them in memory when the run first needs one, writes them into the run's private directory exactly as a worker with database access does, and removes them when the run ends, whether it succeeded, failed, was canceled, timed out, or never started.

To opt a pool in:

  1. Generate its key, on a worker or wherever you then copy the private key from:

    switchtender worker key new --out /etc/switchtender/delivery.key
    
  2. Register the printed public key on the pool in the worker pool file every control node reads with --worker-pools:

    workers:
      - name: dmz
        token_sha256: 9f2c...
        queues: [dmz]
        delivery_key: x25519:x9f9/kkB6Jq3Wqht/7TWzDGUeTRfbABrj1CO+8/6hHc=
    
  3. Restart each control node so it reads the file, and start every worker of the pool with the private key:

    switchtender worker --server https://switchtender.example.com --queue dmz \
      --delivery-key /etc/switchtender/delivery.key
    

The rules it follows:

The seal is HPKE (RFC 9180) in base mode with DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, and ChaCha20-Poly1305, from the Go standard library. HPKE was chosen over NaCl's anonymous sealed box because it authenticates associated data, so the binding is checked by the decryption itself. Each delivery uses a fresh ephemeral key, and the run id, a hash of the claim's lease, the worker's lease name, the pool, the key id, and a random delivery id are all authenticated. So:

The worker holds the opened values only until it has written them where the tool reads them, then drops them. Go cannot overwrite a string in place, so what is dropped is the reference, and the decrypted bytes themselves are zeroed. Disable core dumps on worker hosts, as on any host that runs credentialed work, and see Secrets for how the run's private directory is handled.

Rotating a pool's key

A worker can hold several keys, and a delivery names the key it was sealed to, so a rotation needs no moment where runs fail:

  1. Generate the new key and install it on every worker of the pool beside the old one, restarting each with both: --delivery-key old.key --delivery-key new.key.
  2. Replace the pool's delivery_key with the new public key in the worker pool file on every control node, and restart them. Deliveries are sealed to the new key from then on, and every worker opens them.
  3. Remove the old key from the workers and restart them with the new one alone.

Each control node logs, at startup, the key id every pool registered, and each worker logs the key ids it holds, so the step a fleet has reached shows in the logs. Every delivery's audit record names the key id it was sealed to.

When a key may be compromised, replace the pool's delivery_key and restart the control nodes first, then install the new key on the workers. Until a worker has it, credentialed runs on that pool fail rather than run. Treat the secrets of runs the pool executed as exposed and rotate them at their source, and rotate the pool's worker token too, since whoever read the key file could read the token beside it.

What a relay worker writes into the audit trail

A relay worker runs where the control node cannot see it, so two moments are recorded: the run leaving for that machine, and the outcome coming back. A run whose secrets were delivered records a third, between them, naming which pool and worker received which credential ids and secret answer names, under which key, and never a value:

RELAY  /relay/claim/run_4f21a9                       pool:dmz worker:build-dmz-01
RELAY  /relay/delivered/run_4f21a9/key/f4e8a76a457da19cf3742de37986d439/credentials/cred_1a2b3c,cred_4d5e6f/answers/db_password
                                                     pool:dmz worker:build-dmz-01
RELAY  /relay/finished/run_4f21a9/succeeded          pool:dmz worker:build-dmz-01

Captured output, structured events, per-host and per-task summaries, and heartbeats are not recorded. They are the content and liveness of a run that is already stored on the run itself, they arrive several times a second, and writing each into a hash chain would drown the record it exists to make readable.

The claim and finish records do not fail closed, unlike every mutation through the API. Refusing a worker's report because the audit store is unhealthy does not un-finish the run. It loses the outcome of work that already ran on real hosts, so a failure to record is logged loudly instead. The delivery record is the exception: it is written before anything is sent, and a delivery that cannot be recorded is not sent, so the run fails on the worker with that reason.