ProxmoxVEx persists secrets in two places:
- Application database (PostgreSQL) — users, sessions, audit log, cluster credentials, scheduler state.
- Configuration files under
config/— TLS certificates, cluster JSON, plugin state, etc.
Both rely on a single 32-byte master key. Where that key lives is the biggest determinant of how much an attacker who steals a backup actually gets.
1. Master-key loader (ProxmoxVEx/core/keystore.py)
The keystore resolves the master key from a priority-ordered list and caches the first hit for the lifetime of the process.
| Tier | Source | Use case | Notes |
|---|---|---|---|
| 1 | PROXMOXVEX_DB_KEY env var |
Docker secrets, k8s, CI | Accepts urlsafe-base64 (44 chars) or hex (64 chars) |
| 2 | ${CREDENTIALS_DIRECTORY}/db-key |
systemd LoadCredentialEncrypted= |
TPM2- or host-key-bound. Strongest at-rest option |
| 3 | PROXMOXVEX_KEY_FILE env var → file |
Custom path, e.g. NFS mount | chmod 0600 (or 0640 root:ProxmoxVEx) |
| 4 | /etc/ProxmoxVEx/secret.key |
System-service install default | chmod 0640 root:ProxmoxVEx — group-read required so the systemd unit (running as ProxmoxVEx) can load it. 0600 root:ProxmoxVEx is unreadable for the service. |
| 5 | ~/.config/ProxmoxVEx/secret.key |
Single-user / dev install | chmod 0600 (owner = user running ProxmoxVEx) |
| 6 | config/.ProxmoxVEx.key (CONFIG_DIR) |
Legacy — pre-0.9.9.3 | Triggers deprecation warning on every boot |
The loader rejects any file-based tier whose permissions have group-write,
group-exec, or any other-user bits set — an accidentally chmod-755’d key file
never becomes the active key silently. The rejection is logged. Group-read is
allowed so the system-service install pattern (root:ProxmoxVEx 0640) works.
Why the order matters
The 0.9.9.2 audit flagged the legacy default (Tier 6) for storing the key
next to the encrypted data. With backups typically including the whole
config/ directory, key and DB travel together, defeating at-rest encryption.
Tiers 1–5 break that coupling: the key lives somewhere a config/ snapshot
won’t capture. Tier 2 is the strongest because the key is only
available to the running service unit and is wrapped against the host’s TPM2
chip or host key — not even root can read it directly without unsealing.
Inspecting the active tier
sudo -u ProxmoxVEx python3 ProxmoxVEx_multi_cluster.py --keystore-status
JSON output:
{
"keystore": {
"ok": true,
"source": "/etc/ProxmoxVEx/secret.key",
"source_path": "/etc/ProxmoxVEx/secret.key",
"is_legacy": false,
"tier": 4,
"recommendation": "Key is outside the DB directory which is good. Consider upgrading to systemd LoadCredentialEncrypted..."
},
"db": {
"backend": "postgresql",
"encrypted_at_rest": false,
"cipher": null
}
}
A green pill in the admin UI’s Security card consumes the same data via
GET /api/security/keystore-status.
2. SQLCipher full-DB encryption (ProxmoxVEx/core/dbcrypto.py)
When the sqlcipher3 Python module is importable, every connection acquired
through dbcrypto.connect() runs the SQLCipher v4 PRAGMA handshake before
the first query. The DB header is encrypted, all pages are AES-256-CBC, and
each page carries an HMAC-SHA512 tag.
Platform matrix
| Platform | sqlcipher3-binary wheel | Encryption status |
|---|---|---|
| Linux x86_64 | ✅ shipped | Full DB encryption |
| Linux aarch64 | ❌ no wheel | Plain SQLite + field-level Fernet |
| macOS (any) | ❌ no wheel | Plain SQLite + field-level Fernet |
| Windows | ❌ no wheel | Plain SQLite + field-level Fernet |
Docker python:3.14-alpine |
✅ (x86_64 base image) | Full DB encryption |
The fallback is not unencrypted — sensitive fields (cluster passwords, 2FA secrets, OIDC client secrets, API tokens) are still individually Fernet-encrypted with the same master key. The fallback gives up on metadata-at-rest (table names, audit-log timestamps, session IDs).
You can force-disable SQLCipher by uninstalling sqlcipher3-binary and
restarting — the connection layer detects the missing module and falls
back transparently. Note: any DB already encrypted will become
unreadable until the module is reinstalled — there is no automatic
decrypt-back-to-plain step.
Cipher parameters
PRAGMA cipher_compatibility = 4
PRAGMA key = x'<64-hex-chars>'
We pass the key as a 32-byte hex literal so SQLCipher skips its
PBKDF2 key-derivation — the master key already has full entropy and
running 256k PBKDF2 rounds on every connection acquire is pointless
overhead. This matches the documented cipher_default_kdf_iter = 0
posture for high-entropy raw keys.
3. Migrating an existing install (plain → encrypted)
ProxmoxVEx 0.9.9.3+ auto-encrypts on first boot when:
sqlcipher3is importable (Linux x86_64 default), and- the master key resolves to a usable tier, and
- the existing DB is in the
plainstate.
The same four-step process runs in-process during startup, gated by an
on-disk lock file (<db>.migration.lock, flock(2)) so a systemd
restart-storm cannot double-migrate. Boot output:
✓ DB auto-encrypted (8121 rows, 8.7s)
backup: config/ProxmoxVEx.db.plain.bak.1778613855
The plain backup is retained — delete it manually once you’ve verified the encrypted DB works for a day or two.
Opt-out
Set ProxmoxVEx_DISABLE_AUTO_ENCRYPT=1 in the service environment to skip
the auto-encrypt step on boot. Useful for: taking your own backup first,
running custom pre-flight checks, or staying on plain SQLite for a
debugging window. With opt-out enabled, the field-level Fernet
encryption still applies and you can run the manual CLI tool whenever
you’re ready.
Manual / explicit path
The --migrate-db CLI remains as the explicit / manual control:
Pre-flight
- Stop the service:
sudo systemctl stop ProxmoxVEx - Verify the keystore resolves a key:
sudo -u ProxmoxVEx python3 ProxmoxVEx_multi_cluster.py --keystore-status - Verify
sqlcipher3is importable:python3 -c 'import sqlcipher3; print(sqlcipher3.dbapi2.version)'
Dry-run
sudo -u ProxmoxVEx python3 ProxmoxVEx_multi_cluster.py --migrate-db --dry-run
Reports which DB will be encrypted, the current size, and the per-table row counts that will be verified post-migration. No files are touched.
Execute
sudo -u ProxmoxVEx python3 ProxmoxVEx_multi_cluster.py --migrate-db --yes
The four-step process:
- Backup —
config/ProxmoxVEx.db→config/ProxmoxVEx.db.plain.bak.<timestamp> - Encrypt — new file
config/ProxmoxVEx.db.encbuilt via SQLite’sATTACH … KEY … AS encrypted; SELECT sqlcipher_export('encrypted'); - Verify — every table’s row count is compared between the plain backup and the encrypted target. Any mismatch aborts the migration before the swap.
- Atomic swap —
os.replace(ProxmoxVEx.db.enc, ProxmoxVEx.db). POSIXrename(2)is atomic on the same filesystem.
The .plain.bak.<timestamp> file is kept indefinitely — clean it up
manually once you’ve confirmed the encrypted DB works end-to-end. Do
not delete it the same day unless you have your own backups; this is
the only path back if the encrypted DB becomes inaccessible.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Migration successful, encrypted DB is now active |
| 2 | sqlcipher3 not installed — install sqlcipher3-binary first |
| 3 | Existing DB already encrypted with a different key (refuses to clobber) |
| 4 | Source DB is corrupt — repair before retrying |
| 5 | Row-count verification failed — encrypted DB not swapped in |
4. Recovery / disaster scenarios
The master key is non-recoverable. ProxmoxVEx does not implement key escrow or shamir-shared rebuilds. If the key is lost and the DB is encrypted, the DB is permanently unreadable.
Backup discipline
If you take backups of config/, also back up the master key — but
to a different location and with different access controls. Examples:
- DB → encrypted nightly snapshot to S3 (versioned, KMS-encrypted bucket).
- Master key → printed once, sealed in a tamper-evident envelope, kept in the on-call lead’s safe. Plus: stored in your password manager’s shared vault under “Infra / ProxmoxVEx / master-key”.
If both live in the same backup tarball, encrypting the DB bought you nothing against backup theft.
Lost master key, encrypted DB
There is no path back. Restore from the most recent .plain.bak.*
emitted by the migrator, or from a pre-migration full backup.
Corrupted SQLCipher DB
ProxmoxVEx_multi_cluster.py --keystore-status will report
"db": {"backend": "sqlcipher", "encrypted_at_rest": true} even on a
corrupt DB — corruption manifests as OperationalError: file is not a database at first query. Use --migrate-db --dry-run to detect the
corrupt state.
If you can still get an old backup readable: stop ProxmoxVEx, swap the
corrupt DB out, restore the backup, re-run --migrate-db.
5. systemd LoadCredentialEncrypted (Tier 2) — strongest at-rest
This is the recommended posture for production Linux deployments.
Prerequisites
systemd >= 250(Debian 12+, Ubuntu 22.04+, RHEL 9+).- Either a TPM2 chip (
systemd-cryptenroll --tpm2-device=listsucceeds) or the default host-key (any systemd ≥ 250 has it).
Setup
-
Generate the key (one-time):
python3 -c 'import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())' \ | sudo tee /tmp/ProxmoxVEx-key.txt -
Wrap it with systemd-creds:
# Host-key bound (works everywhere): sudo systemd-creds encrypt --name=db-key /tmp/ProxmoxVEx-key.txt \ /etc/ProxmoxVEx/secret.key.cred # TPM2-bound (stronger, requires TPM2 chip): sudo systemd-creds encrypt --name=db-key --with-key=tpm2 \ /tmp/ProxmoxVEx-key.txt /etc/ProxmoxVEx/secret.key.cred -
Shred the plaintext copy:
sudo shred -u /tmp/ProxmoxVEx-key.txt -
Edit
/etc/systemd/system/ProxmoxVEx.serviceand add to[Service]:LoadCredentialEncrypted=db-key:/etc/ProxmoxVEx/secret.key.cred -
Reload + restart:
sudo systemctl daemon-reload sudo systemctl restart ProxmoxVEx -
Verify:
journalctl -u ProxmoxVEx -n 50 | grep '\[KEYSTORE\]' # → [KEYSTORE] master key source: systemd-credential (/run/credentials/ProxmoxVEx.service/db-key) -
Once verified, delete the legacy file if you migrated from Tier 6:
sudo shred -u /etc/ProxmoxVEx/secret.key # or config/.ProxmoxVEx.key
Recovering from a TPM2 reset
A motherboard swap, BIOS reset, or tpm2_clear will permanently
invalidate a TPM2-bound credential. Keep an offline copy of the
plaintext key (Tier 1-style backup) in your password vault for
exactly this case.
6. Known limitations
- No automatic key rotation. Auto-migration encrypts a plain DB once;
rotating an existing encryption key still means: stop service, re-encrypt
with new key via
--migrate-db --rotate-from=<old-key-file>, restart. The--rotate-fromflag is on the roadmap; for now it’s manual ATTACH +sqlcipher_export. - Field-level Fernet keys are not separately rotated. Master key rotation does not re-wrap existing Fernet-encrypted fields. This is acceptable since both rely on the same master key — when the master key changes, both layers change together.
- No second-factor at the DB layer. A compromised host running ProxmoxVEx can read the DB. Compartmentalization (running clusters in separate VMs with separate keys) is the only mitigation.
- Plugin compatibility. First-party plugins use
dbcrypto.connect()and work transparently. Third-party community plugins that callsqlite3.connect()directly will not be able to read an encrypted DB — they need to be updated.
Maintainers: / Last revised on the 0.9.9.3 SQLCipher rollout.