Configuration schema
The full TOML schema, section by section. Defaults shown when applicable.
Top level
default = "homelab" # optional: name of profile to use when --profile omitted
url = "..." # if no [profiles.X] tables, top-level fields ARE the default profile
user = "..."
auth = "token" | "password"
token_id = "..."
token_secret = "..."
verify_tls = trueIf both default and [profiles.X] are present, default selects which profile to load. If only top-level fields are present, those fields are the implicit profile.
Where the sections below go
The sections on this page ([ssh], [telegram], [[policies]], [pbs], [[alerts]], ...) are shown at the top level, which is where they go in a flat config. In a config made of [profiles.<name>] tables (no top-level url), write them per profile ([profiles.<name>.telegram], [[profiles.<name>.alerts]]) or once for every profile under [defaults] ([defaults.telegram], [[defaults.alerts]]). There the top level may hold only default, profiles and defaults; any other key is a configuration error (exit 3), because no profile would read it.
[defaults] (shared profile base)
Optional top-level table merged under every [profiles.<name>] entry before deserialization (#331). Accepts any non-secret, non-url field or sub-table supported by [profiles.<name>]:
default = "pve1"
[defaults]
user = "root@pam"
auth = "token"
token_id = "proxxx"
verify_tls = true
tls_pin_mode = "tofu"
rate_limit = 10
read_only = false
[defaults.ssh]
user = "root"
key_path = "~/.ssh/proxxx_homelab"
[profiles.pve1]
url = "https://pve1.lan:8006/"
[profiles.pve2]
url = "https://pve2.lan:8006/"
read_only = trueMerge and validation rules:
| Rule | Behaviour |
|---|---|
| Precedence | A key set in [profiles.<name>] always wins over [defaults]. |
Sub-tables ([ssh], [pbs], [telegram], [reconcile]) | Merged one level deep: missing keys in [profiles.<name>.<sub>] are filled from [defaults.<sub>]. Nested maps inside a sub-table (ssh.hosts, ssh.guests) are taken whole from the profile if present, else whole from [defaults] (no recursive map merge). |
Arrays ([[policies]], [[alerts]]) | Not concatenated: if a profile defines policies or alerts, its array replaces the [defaults] array wholesale; if omitted, the profile inherits the [defaults] array. |
Forbidden keys (exit 3) | url and all secret keys (token_secret, token_secret_file, password, mcp_token, pbs.token_secret, pbs.token_secret_file, telegram.bot_token, telegram.bot_token_file) are refused in [defaults] with ConfigError (exit code 3). |
Unknown keys (exit 3) | Validated with deny_unknown_fields before merge; any unknown key in [defaults] or its sub-tables fails with a ConfigError naming [defaults]. |
Flat config (no [profiles]) | [defaults] is schema-validated, then stripped with a WARN log because no [profiles.<name>] entry is active. |
[profiles.<name>]
[profiles.homelab]
url = "https://pve1.lan:8006/"
user = "root@pam"
auth = "token" # token | password
token_id = "proxxx"
token_secret = "..." # plain string OR
token_secret_file = "/etc/proxxx/token"
password = "..." # only if auth = "password"
verify_tls = true # validate the cluster cert. DEFAULT true
# since v0.13.4 (was false). Proxmox ships
# a self-signed cert: set false deliberately
# for a homelab, or prefer tls_pin_mode.
rate_limit = 10 # max API requests/second (default 10).
# ALSO governs TUI refresh latency: a
# refresh costs ~3 requests per node, so
# on an N-node cluster a cycle takes
# roughly 3N/rate_limit seconds. The TUI
# targets a 5 s refresh and warns on
# screen when it cannot keep up (#277).
# 3 nodes ≈ 1 s; 20 nodes ≈ 6 s;
# 50 nodes ≈ 15 s at the default.
read_only = false # true → refuse all mutations on this
# profile client-side (reads still work);
# exit code 8. Default false. Pair with a
# PVEAuditor PVE token for server-side lock.
mcp_token = "..." # bearer token for `proxxx mcp serve-http`.
# Optional; an empty/whitespace value counts as
# ABSENT. serve-http binds loopback by default; on
# a non-loopback bind (e.g. 0.0.0.0) it REFUSES to
# start unless this is set — override consciously
# with --insecure-bind, in which case a network-
# exposed server with no token denies every request
# (fail-closed, and this survives a SIGHUP that
# clears the token). The --token CLI flag overrides
# this field.[profiles.<name>.reconcile] (GitOps controller)
Opt-in continuous reconciliation. When present, proxxx daemon serve runs the drift-watch pillar for this profile; absent → no watch. The one-shot reconcile run / reconcile converge commands take --source/--path on the CLI instead and don't read this block.
[profiles.homelab.reconcile]
source = "git@github.com:me/cluster.git" # file, dir, or git URL (shallow-cloned each tick)
path = "state.toml" # state file within a dir/git source (default "state.toml")
interval_secs = 300 # drift-watch poll interval, floored at 30 (default 300)
# ── Layer 3: auto-converge (unmanned mutation) — default OFF ──
auto_converge = false # true → after detecting drift each tick, apply it.
# ALWAYS force=false: a Severe-risk drift is NEVER
# auto-applied — it alerts for human review and
# mutates nothing. Respects read_only + incident
# freeze (skips quietly, no alert storm). Disable
# per-process with the `--no-converge` flag or the
# PROXXX_NO_CONVERGE env var.
converge_prune = false # true → auto-converge MAY execute deletes (maps to
# `state apply --prune`) — but ONLY when
# `max_unmanned_changes` is also set. converge_prune=true
# on its own HOLDS every unmanned delete: creates/updates
# converge while deletes stay previewed-but-held. Default
# false: deletes are previewed and held. Enable only
# against a repo with branch protection / atomic pushes.
# ── Unmanned-converge guardrails (both narrow the blast radius; default = none) ──
allowed_families = ["pool", "acl"] # restrict the UNMANNED converge to these state
# families (matched against a change's resource:
# pool, acl, storage, backup-job, firewall-*, ha-*,
# notification-matcher, mappings-pci/usb, …). Absent
# or empty = every family (current behaviour). Lets
# you auto-converge low-stakes families while keeping
# high-stakes ones human-only. The manual
# `reconcile converge` command is NOT restricted.
max_unmanned_changes = 5 # hard cap on changes-per-tick for the UNMANNED path,
# counted AFTER allowed_families filtering, regardless
# of severity. Absent = no cap. Above it → the daemon
# refuses and alerts "needs human review (too many
# changes)". Catches a Warning-tier flood (e.g. a
# partial git revert) that the Severe bulk-change
# circuit-breaker (≥50) would miss. ALSO the gate that
# unlocks unmanned prune: converge_prune only deletes
# while this cap is set. Size it SMALL (single digits) —
# a large cap (e.g. 49) re-opens the 10–49
# unmanned-delete band.[ssh]
[ssh]
key_path = "/home/fab/.ssh/proxxx_homelab" # ed25519 / rsa private key path
user = "root" # optional, default "root"
strict_host_key_checking = "tofu" # tofu, strict, off (OpenSSH: accept-new, yes, no); anything else is refused
known_hosts = "~/.config/proxxx/known_hosts" # optional, default to XDG path
[ssh.hosts]
pve1 = "10.0.0.1:22" # optional per-node override; the API-answering node falls back to profile url host:22 (with hostname verification), and other cluster nodes fall back to <node>:22 (required when <node> does not resolve via DNS or /etc/hosts)[ssh.guests.<vmid>] (optional override)
proxxx ssh <vmid> resolves a guest's connection details in two steps: it consults this section first, and on miss auto-discovers via QGA (network-get-interfaces for QEMU) or /lxc/{vmid}/interfaces (for LXC). Most operators don't need to populate this block at all.
Pin a per-guest entry only when:
- the guest has no qemu-guest-agent installed (or the agent is off / not running),
- QGA returns only loopback (
127.0.0.0/8) or link-local (169.254.0.0/16) IPs — i.e. the guest is on a private bridge with no usable address from your machine's perspective, - you want a stable DNS name (
web1.lab.example) instead of the rotating DHCP IP QGA would surface.
[ssh.guests."100"]
host = "10.10.10.100"
port = 22
user = "fab"
key_path = "~/.ssh/k8s_master" # optional, falls back to [ssh].key_pathVMIDs must be quoted (TOML key restriction). The wizard's proxxx init --interactive step 4 sub-prompt builds this section interactively; you can also hand-edit it later.
[telegram]
Used by HITL and alert routing.
[telegram]
bot_token = "123456:ABC..." # from @BotFather
chat_id = "-1001234567890" # from getUpdates response
allowed_approvers = [123456789] # REQUIRED. Telegram numeric user ids
# permitted to approve/deny. The callback
# HMAC proves proxxx minted the keyboard,
# not who pressed it — without this list
# any member of chat_id could approve a
# destructive op. Numeric ids only
# (usernames are mutable). Absent or
# empty => every callback is refused.[[policies]] (HITL)
[[policies]]
action = "delete" # delete | stop | restart | migrate | exec | move_disk | resize_disk | *
target = "tag:prod" # tag:<X> | <vmid> | *
channel = "telegram" # approval channel
require = 1 # approvals needed (2 or more = quorum)Multiple [[policies]] arrays are evaluated in order. The first matching one wins.
[pbs]
For Proxmox Backup Server browse and restore.
[pbs]
url = "https://pbs.lan:8007/"
user = "proxxx@pbs"
token_id = "reader"
token_secret = "..."
token_secret_file = "/etc/proxxx/pbs-token"
verify_tls = false
rate_limit = 10WARNING
PBS uses : between token_id and secret in the auth header, not = like PVE. proxxx handles this internally — you don't need to pre-format the secret.
[[alerts]]
[[alerts]]
name = "node_down"
trigger = "node_offline" # closed enum: see Alerts integration
for_secs = 60 # seconds
storage = "ceph-rbd" # filter for storage_above (optional)
threshold_percent = 85 # for storage_above
severity = "critical" # info | warning | critical
route = ["telegram", "ntfy:topic"]
dedup_secs = 600 # don't re-fire within N secondsThe trigger is a closed enum (node_offline, storage_above, replication_failing). New triggers require code changes — that is intentional, see Alerts.
Resolution order for secrets
For each of token_secret, password, pbs.token_secret:
- CLI flag (
--token-secret VALUE,--pbs-token-secret VALUE) - Env var (
PROXXX_TOKEN_SECRET,PROXXX_PASSWORD,PROXXX_PBS_TOKEN_SECRET,PROXXX_TELEGRAM_BOT_TOKEN) - Inline TOML value (
<...>_secret = "...") - File reference (
<...>_secret_file = "...") - macOS Keychain (macOS only; service
proxxx, account<profile>/<item>then<item>fortoken_secret,password,pbs_token_secret, andtelegram_bot_token)
The first one that resolves wins.
Inline beats the file reference
Steps 3 and 4 were documented in the opposite order until v0.13.4. If you are moving a secret out of the TOML into a 0600 file, delete the inline value — otherwise the stale inline secret keeps winning, your file is never read, and rotating it has no effect. proxxx logs a warning when both are set.
Loaded values live in SecretString: Debug prints [REDACTED] (not even the length, which would leak which credential class it is), there is no Display and no Serialize, so interpolating one into a string or a JSON dump is a compile error, and the wrapped value is zeroized on drop. Note the guarantee covers the value once constructed — the toml parse tree still holds an unwiped copy of any inline secret until config load completes, which is another reason to prefer the file or the keychain.
Environment variables
Beyond the secret env vars above and PROXXX_CONFIG (see File location), these tune runtime behaviour:
| Variable | Effect |
|---|---|
PROXXX_NO_CONVERGE | Disable auto-converge for this process (same as --no-converge): the drift-watch still detects and alerts, but mutates nothing. |
PROXXX_AUDIT_DIR | Relocate the audit DB and its HMAC key off the default path (e.g. onto a separate volume). The directory is created 0700. |
PROXXX_LOG_DIR | Relocate the rotating log files (proxxx.<date>.log, 14 kept; default: the platform data dir, the same directory as the audit DB). A directory proxxx creates is 0700; the default data dir is narrowed to 0700 on start, while an existing PROXXX_LOG_DIR keeps its mode, so keep it owner-only (log files take the process umask). |
PROXXX_MCP_TOKEN | Bearer token for mcp serve-http, kept out of the process listing. --token-file and --token win over it; it wins over mcp_token in the profile. |
PROXXX_CACHE_DIR | Relocate the TUI / HITL state cache (<profile>_state.db, default <platform cache dir>/proxxx). It is only a cache: deleting it loses nothing that is not re-fetched. |
PROXXX_AUDIT_KEY | Relocate only the HMAC key, off the DB volume. A group/world-readable audit.key is refused on load (unix); proxxx audit verify exits non-zero on tamper. |
Defaults summary
| Field | Default |
|---|---|
verify_tls | true |
rate_limit | 10 |
user (SSH) | "root" |
dedup_secs (alert) | 600 |
severity (alert) | "warning" |
auth | "token" |
File location
| Platform | Path |
|---|---|
| Linux | ~/.config/proxxx/config.toml |
| macOS | ~/Library/Application Support/dev.proxxx.proxxx/config.toml |
| Windows | %APPDATA%\dev\proxxx\proxxx\config.toml |
Set PROXXX_CONFIG=/path/to/file.toml to override.
Validation
proxxx validates the schema on load. A failed validation prints the section and key, then exits with code 3 (Configuration error). Common errors:
urlmissing trailing colon — proxxx adds/api2/jsonitself, so the URL stops at:8006/token_secrethas trailing whitespace — copy-paste from the web UI sometimes appends a newline; trim itauth = "token"withouttoken_id— both fields are required- A top-level section in a profile-only config — with
[profiles.<name>]tables and no top-levelurl, a top-level[[alerts]],[telegram],[ssh]or misspelled key is refused, naming the key; move it under[defaults]or[profiles.<name>] [ssh]referenced butkey_pathis missing —proxxx ssh <vmid>,proxxx perms, andproxxx patch applyall require it