Configuration
proxxx reads a single TOML file. Profiles, secrets, TLS, SSH, HITL, PBS, and alerts all live there.
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 |
The path follows the directories crate's project-dirs convention (dev.proxxx.proxxx).
Minimum viable
url = "https://pve1.lan:8006/"
user = "root@pam"
auth = "token"
token_id = "proxxx"
token_secret = "00000000-0000-0000-0000-000000000000"
verify_tls = falseThat's enough for read-only and most write operations against a single cluster.
Auth modes
auth = "token" # API token (recommended — scoped, revocable)
auth = "password" # username/password ticket flow (legacy)For auth = "token", set token_id and one of: token_secret, token_secret_file, PROXXX_TOKEN_SECRET env, or store it in the keychain.
For auth = "password", set password, PROXXX_PASSWORD env, or keychain.
Bootstrap a token (proxxx token bootstrap)
Minting a token manually with pveum user token add prints the secret to the terminal (scrollback, shell history, clipboard, or agent context), and proxxx token create requires an already-working API token. To onboard a node over SSH without the secret ever reaching the terminal (#330):
proxxx --profile pve1 token bootstrap \
[--userid root@pam] \
[--tokenid proxxx] \
[--role PVEAuditor] \
[--acl-path /] \
[--ssh-host 10.0.0.10:22] \
[--secret-file ~/.config/proxxx/secrets/pve1.token] \
[--rotate] \
--yesHow token bootstrap works:
- Profile & guardrails: loads
[profiles.<X>](with[defaults]merged) before constructing an API client.userandtoken_idcome from the profile unless overridden by--userid/--tokenid. Refusesread_only = trueprofiles (exit code8) and requires--yesbefore opening an SSH session. - SSH target: connects to
--ssh-host <host[:port]>if given, otherwise to the host parsed from the profile'surlon port22. Because no working API token exists yet,token bootstrapdoes not call/cluster/statusand does not run the remotehostnamenode-name check. Host key verification always goes through the profile'sknown_hostspolicy (tofuorstrict;strict_host_key_checking = "off"is refused). - Pre-check & rotation: runs
pveum user token list <user> --output-format json. If<tokenid>already exists, refuses unless--rotateis passed (which removes the old token viapveum user token remove <user> <tokenid>and recreates it, invalidating the previous secret immediately). - Create & ACL: runs
pveum user token add <user> <tokenid> --privsep 1 --output-format json, parsesvaluedirectly into aSecretString, immediately zeroizes the rawstdoutbuffer, and grants--role(defaultPVEAuditor) on--acl-path(default/) viapveum acl modify <acl-path> --tokens '<user>!<tokenid>' --roles <role>. - Store & rollback: stores the secret before reporting success, choosing the target in order: explicit
--secret-file <path>->token_secret_fileon the profile -> macOS Keychain (proxxx/<profile>/token_secret, when compiled in) -><config_dir>/secrets/<profile>.token. Secret files are opened with mode0600from the first byte inside a0700parent directory and renamed atomically; existing files or keychain entries are refused unless--rotateis set. If ACL assignment or local secret storage fails after token creation,proxxxremoves the newly created token over SSH (rollback) and never prints the secret as a fallback. - Verify & TOML snippet: builds a
PxClientwith the stored secret, runsGET /versionandGET /nodes(a verification failure keeps the stored secret and exits1), records write-ahead"sent"and outcome entries in the HMAC-chained audit log (withuser,tokenid,role,path, andnode— never the secret), and prints thetoken_id(plustoken_secret_filewhen a file was used) TOML lines without editingconfig.toml.
Secrets
The resolution order for PVE (token_secret, password), PBS (pbs.token_secret), and Telegram (telegram.bot_token) is:
CLI flag (
--token-secret VALUEor--token-secret-file PATH) — highest priority, takes precedence over everything else. It holds ONE secret for the selected profile: it is used for that profile's PVE credentials, and for PBS only byproxxx pbs ...commands (there is no separate PBS flag; elsewhere PBS resolves its own secret). Commands that contact every profile (ls --all-profiles,find,fleet) refuse it with exit 3, and after a profile switch in the TUI the next profile resolves its own secret.Environment variable (
PROXXX_TOKEN_SECRET,PROXXX_PASSWORD,PROXXX_PBS_TOKEN_SECRET, orPROXXX_TELEGRAM_BOT_TOKEN; capped at 64 KiB).Inline TOML key (
token_secret,password,pbs.token_secret; note that for Telegram,telegram.bot_token_fileis checked before inlinetelegram.bot_token).File reference —
token_secret_file = "~/.config/proxxx/secrets/pve.token"(orpbs.token_secret_file,telegram.bot_token_file). Leading~and~/...are expanded against$HOME. On Unix the file must have mode0600or stricter; proxxx refuses to read files with group or world permissions.macOS Keychain (macOS only) — last resort. Service is always
proxxx. For a named profile<profile>, proxxx queries the profile-scoped account first and falls back to the flat account name:- PVE API token secret:
<profile>/token_secret, thentoken_secret - PVE password:
<profile>/password, thenpassword - PBS API token secret:
<profile>/pbs_token_secret, thenpbs_token_secret - Telegram bot token:
<profile>/telegram_bot_token, thentelegram_bot_token
The keychain backend (
keyringwithapple-native/ Security.framework) is compiled on macOS only (--no-default-featuresdisables it on macOS; on Linux and other targets thekeychainCargo feature is a no-op and no keychain store is compiled in — usetoken_secret_fileor environment variables there). Verify whether a keychain store is compiled into your binary withproxxx version --json(keychain.compiled/keychain.store) orproxxx doctor.- PVE API token secret:
When no secret can be resolved, the error lists every source consulted in order and why each did not yield a secret (without ever printing a secret value).
Once loaded, secrets are held as SecretString: its Debug prints [REDACTED], it has no Display, and its heap allocation is wiped on Drop. The temporaries a secret passes through while the config loads are not wiped.
TLS
verify_tls = true # default in production (WebPKI + standard CA roots)
tls_pin_mode = "tofu" # Trust-On-First-Use leaf-certificate SHA-256 pinning (recommended for homelabs)
verify_tls = false # disable TLS verification (not recommended)Leaf-certificate pinning (tls_pin_mode = "tofu")
With verify_tls = true and tls_pin_mode = "tofu", proxxx pins the exact end-entity (leaf) certificate by its SHA-256 DER digest:
- First connect: if
<config_dir>/known_certs/<host>_<port>.derdoes not exist, proxxx fetches the server's leaf certificate, logs a warning with its colon-separated SHA-256 fingerprint, writes<host>_<port>.deratomically (0600inside0700known_certs/), and pins that leaf for the session. - Subsequent connects: every PVE HTTPS request and serial-console WebSocket connection (
wsterm) uses a customrustlsPinnedLeafVerifierthat accepts the handshake if and only ifSHA-256(end_entity DER)matches the pinned 32-byte digest in constant time and the server proves possession of the leaf's private key (CertificateVerify). This works with Proxmox VE's defaultpve-root-ca.pem→pve-ssl.pemhierarchy as well as self-signed certificates or IP literal URLs without weakeningverify_tlstofalse. - Re-trusting after a certificate renewal: if the server's leaf certificate changes, proxxx aborts immediately (no retry) and prints both the pinned and presented SHA-256 fingerprints along with the
<host>_<port>.derpath. Verify the new fingerprint on the Proxmox node (pvenode cert infooropenssl x509 -noout -fingerprint -sha256 -in /etc/pve/local/pve-ssl.pem), then delete<config_dir>/known_certs/<host>_<port>.derand reconnect. - Directory override (
PROXXX_KNOWN_CERTS_DIR): setPROXXX_KNOWN_CERTS_DIR=/path/to/dirto override the directory where TOFU<host>_<port>.derfiles are read and written (defaults to<config_dir>/known_certs).
Disabling verification (verify_tls = false)
Setting verify_tls = false (with tls_pin_mode unset or "disabled") enables danger_accept_invalid_certs on the reqwest client AND mirrors the bypass into the WebSocket client used for serial console (wsterm::tls::dangerous_no_verify_config).
It does NOT disable SSH host key verification. SSH uses its own TOFU known_hosts at <config_dir>/known_hosts (separate from ~/.ssh/known_hosts).
Profiles
Multiple clusters, one config:
default = "homelab"
[profiles.homelab]
url = "https://pve1.lan:8006/"
user = "root@pam"
auth = "token"
token_id = "proxxx"
token_secret = "00000000-0000-0000-0000-000000000000"
verify_tls = false
[profiles.prod]
url = "https://pve.example.org:8006/"
user = "ops@pve"
auth = "token"
token_id = "ci"
token_secret_file = "/etc/proxxx/prod-token"
verify_tls = trueSwitch with proxxx --profile prod ls nodes. The flat top-level profile (no [profiles.X]) is treated as the default if no default key is set.
Every subsystem uses the resolved profile label (ProfileConfig::profile_label()): the explicit --profile <name>, the default = "<name>" key, or the sole [profiles.<name>] section — falling back to "default" only when the configuration is a flat top-level file with no [profiles] table. That resolved label names the SQLite state/queue/dedup/drift cache file (<cache_dir>/proxxx/<profile>_state.db), the [meta] profile header written by proxxx state export, the "profile" field in audit.dbparams_json, the profile label on Prometheus reconcile metrics, and MCP reconciliation notifications.
Shared profile defaults ([defaults])
When several standalone Proxmox nodes or clusters share the same user, token_id, verify_tls, [ssh], [pbs], [telegram], [reconcile], [[policies]], or [[alerts]] settings, declare them once in a top-level [defaults] table (#331). Every [profiles.<name>] entry inherits from [defaults] before deserialization:
default = "pve1"
[defaults]
user = "root@pam"
token_id = "proxxx"
verify_tls = false
[defaults.ssh]
user = "root"
key_path = "~/.ssh/proxxx_ed25519"
[profiles.pve1]
url = "https://pve1.lan:8006"
token_secret_file = "~/.config/proxxx/secrets/pve1.token"
[profiles.pve2]
url = "https://pve2.lan:8006"
token_secret_file = "~/.config/proxxx/secrets/pve2.token"Merge rules:
- Profile always wins: any key set in
[profiles.<name>]overrides[defaults]. - One-level sub-table merge:
[defaults.ssh],[defaults.pbs],[defaults.telegram], and[defaults.reconcile]fill missing keys in the corresponding[profiles.<name>.<sub>]table. Nested maps inside a sub-table (such as[ssh.hosts]or[ssh.guests]) are not merged recursively — if the profile setshostsorguests, the profile's map replaces the default map whole; otherwise the default map is inherited whole. - Arrays replace:
[[defaults.policies]]and[[defaults.alerts]]are replaced wholesale when a profile defines its own[[profiles.<name>.policies]]or[[profiles.<name>.alerts]]. - Refusals (exit code
3):urlis forbidden in[defaults](each profile must declare its own endpoint).- Secrets (
token_secret,token_secret_file,password,mcp_token,pbs.token_secret,pbs.token_secret_file,telegram.bot_token,telegram.bot_token_file) are forbidden in[defaults]so a single credential is never silently shared across every node; store secrets per profile, via environment variables, or in the OS keychain. - Unknown keys in
[defaults]or its sub-tables fail immediately with aConfigError(exit code3) naming[defaults].
- Flat top-level configs (no
[profiles]):[defaults]is still schema-validated, then stripped with a warning because no named profile is active.
Read-only profiles (read_only)
Set read_only = true on a profile to make proxxx refuse every mutation (POST/PUT/DELETE) on it — enforced client-side, at the single API write chokepoint, before the request leaves the process. Reads (GET) are unaffected. The refusal is a typed error with exit code 8 (the same "mutation refused by a local lock" family as incident freeze).
It's declarative (lives in config, version-controllable, always on) — unlike [[policies]] (which only request approval) or incident freeze (a runtime lock you have to remember to set). Pair it with a read-only PVE API token (PVEAuditor role on /) for defence in depth:
read_only = true→ client-enforced: proxxx never sends the write.PVEAuditortoken → server-enforced: PVE returns403on any write.
Either alone blocks writes; together they're belt-and-suspenders for the production clusters you only ever observe.
Reloading a running daemon (SIGHUP)
daemon serve, mcp serve, mcp serve-http and alerts watch reload config.toml on SIGHUP (a reload that fails to parse keeps the previous config). Applied without a restart: read_only (checked before every write, so setting read_only = true and sending SIGHUP stops writes immediately), [reconcile] (re-read on every reconcile tick, including auto_converge), [[alerts]], [[policies]], the [telegram] structure and mcp_token. Restart required: url, user, token_id, the token secret, rate_limit, reconcile.interval_secs and Telegram bot credentials. The reload log line lists both. daemon serve starts its alerts, HITL and reconcile components only when [[alerts]] rules, [telegram] and [reconcile] are present at startup; adding one of them later needs a restart.
Multi-Proxmox pattern (prod read-only + writable test)
The intended fleet layout: production hosts get a PVEAuditor token and read_only = true; the one writable test cluster gets a privileged token and no flag.
# Production — observe only (client lock + server lock)
[profiles.prod-a]
url = "https://10.0.0.10:8006"
user = "proxxx@pve" # token bound to PVEAuditor on /
token_id = "auto"
token_secret_file = "/etc/proxxx/prod-a.token"
verify_tls = true
read_only = true
# Test cluster — full read+write
[profiles.lab]
url = "https://10.0.0.120:8006"
user = "proxxx@pve"
token_id = "auto"
token_secret_file = "/etc/proxxx/lab.token"
verify_tls = falseMint the read-only token on each production node:
pveum user add proxxx@pve
pveum acl modify / --user proxxx@pve --role PVEAuditor
pveum user token add proxxx@pve auto --privsep 0 # prints the secret onceView the whole fleet (read-only) in one screen with proxxx fleet — see the TUI reference. proxxx fleet ignores --profile: it always aggregates every configured profile.
Flat config or profiles
The SSH, PBS, Telegram, policy and alert sections below are written at the top level, as in a flat config. With [profiles.<name>] tables, put them under the profile ([profiles.homelab.telegram], [[profiles.homelab.alerts]]) or under [defaults] to share them; a top-level one is refused (exit 3) there, since no profile would read it.
SSH
For proxxx ssh <vmid>, proxxx perms, proxxx gpu inspect, proxxx logs tail, proxxx doctor, and the patching orchestrator (proxxx patch apply):
[ssh]
key_path = "~/.ssh/proxxx_homelab" # Ed25519, ECDSA or RSA >= 2048 bits (~ expanded against $HOME)
user = "root" # optional, defaults to "root"
[ssh.hosts]
pve1 = "10.0.0.1:22"
pve2 = "10.0.0.2:22"
[ssh.guests."100"]
host = "10.10.10.100"
user = "fab"
[ssh.guests."200"]
host = "10.10.10.200"
key_path = "~/.ssh/k8s_master" # optional, falls back to [ssh].key_path (~ expanded)For node-level SSH (proxxx perms, proxxx gpu inspect, proxxx logs tail, proxxx patch apply), SshPool resolves a target <node> in strict order:
[ssh.hosts]override (hosts.<node>) — always wins when present (no/cluster/statuscall is needed). Accepts<host>(defaults to port22) or<host>:<port>(including bracketed IPv6[::1]:2222).- Profile
urlhost fallback (port22) — used only when<node>is the node answering the PVE API (/cluster/statusentry withlocal = true, or the single node of a standalone install). On a session opened through this fallback,SshPoolrunshostnamebefore the first command and refuses the connection if the remote short hostname does not match<node>(protecting against a profileurlthat points at a VIP or load balancer). - Node-name DNS /
/etc/hostsfallback (<node>:22) — for every other member of a cluster (or if/cluster/statusis unavailable),SshPoolfalls back to<node>:22without an identity check. If that connection fails with a name-lookup error, the error addshosts = { <node> = "<ip>:22" }so operators whose non-API cluster node names do not resolve in DNS know the exact[ssh.hosts]entry to add.
Both key_path (at [ssh] and [ssh.guests."<vmid>"]) and known_hosts accept a leading ~ or ~/... (expanded via util::path::expand_tilde, shared with token_secret_file and proxxx doctor). SSH uses publickey only — no agent. An RSA key must have at least 2048 bits and a DSA key is refused (the NIST minimum through 2030); proxxx says so before it connects. Ed25519 is recommended. If your key is encrypted, set PROXXX_SSH_KEY_PASSPHRASE (or unlock it in the TUI prompt). The known_hosts file is TOFU on first connect with a warning log.
PBS
For proxxx pbs ...:
[pbs]
url = "https://pbs.lan:8007/"
user = "proxxx@pbs"
token_id = "reader"
token_secret_file = "~/.config/proxxx/secrets/pbs.token" # ~ expanded; 0600 on Unix (or inline token_secret)
verify_tls = true
fingerprint = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
rate_limit = 10 # max requests/secondWhen fingerprint is set (taken from proxmox-backup-manager cert info, either 32 colon-separated hex octets AA:BB:... or 64 hex chars), PbsClient and proxxx doctor pin the PBS leaf certificate's SHA-256 DER digest via the same PinnedLeafVerifier used by PVE TOFU pinning — even when verify_tls = true against a self-signed PBS certificate. Malformed fingerprints are rejected at config load with exit code 3.
WARNING
PBS uses PBSAPIToken=user!tokenid:secret — note the colon between token id and secret. PVE uses =. If you copy a PVE-style header to PBS you'll get a 401 with no useful diagnostic.
Telegram (HITL + alerts)
[telegram]
bot_token = "123456:ABC..."
chat_id = "-1001234567890"Get bot_token from @BotFather, chat_id from any message metadata to your bot (e.g. via https://api.telegram.org/bot<token>/getUpdates).
HITL policies
[[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 are evaluated in order; the first matching one wins. See HITL via Telegram.
Alerts
[[alerts]]
name = "node_down"
trigger = "node_offline"
for_secs = 60 # seconds offline before firing
route = ["telegram", "ntfy:proxxx-prod"]
dedup_secs = 600 # don't re-fire within 10 minSee Alerts for the full predicate set.
Schema reference
Full type-by-type schema with defaults: Configuration schema.