Production hardening and operations
This guide covers operating the Praxis control plane (Praxis itself) in production: supported deployment shapes, fresh install, upgrade and migration posture, backup/restore, Vault unseal and token rotation, environment validation, the bundled-vs-external data-tier boundary, and the deployment shapes that are explicitly out of scope for 1.0.
Managed-host platforms. This document covers the control-plane deployment (Praxis itself). For the supported Linux distributions, package-manager families, and architectures of the managed fleet, see the Linux Support Matrix.
Scope
In scope:
- Fresh install from
docker composeto first enrolled host. - Upgrade from representative earlier database states through the current Alembic head.
- Backup/restore, including documented RPO/RTO assumptions.
- Vault unseal and token rotation guidance.
- Database migration review and rollback posture.
- Production environment validation and startup checks.
- The bundled-vs-external Postgres/Vault support boundary.
Out of scope for 1.0:
- Remote-host deployment automation (no Ansible/Terraform/SSH-to-prod
flows are part of Praxis 1.0; operators run
docker composeon the control-plane host themselves). - Reboot/rollback of the control plane host OS.
- OpenSCAP execution against the control plane.
- Package-manager driven install (no
.deb/.rpmfor Praxis itself — only container images on GHCR).
Supported Deployment Shapes
The repository supports exactly three production deployment shapes,
all driven from docker-compose.yml plus the docker-compose.prod.yml
overlay.
1. Bundled (default)
COMPOSE_PROFILES=bundled (the default in .env.example) brings up
PostgreSQL 15, OpenBao 2.6.1 (the bundled secrets runtime; a drop-in for
HashiCorp Vault, driven via the bao CLI), the db_backup cron sidecar,
the backend, frontend, broker, and Prometheus inside a two-tier docker
network (frontend_net, backend_net).
Reference: docker-compose.yml, docker-compose.prod.yml.
2. External Postgres and External Secrets Service
Remove COMPOSE_PROFILES=bundled from .env and set DATABASE_URL,
VAULT_ADDR, and VAULT_TOKEN to point at the operator’s own
infrastructure (an external OpenBao or HashiCorp Vault — the API is
compatible). The db, vault, and db_backup services are all
gated behind the same bundled profile, so disabling the profile
turns off all three at once: the supported external path is
fully external (operator-owned Postgres and operator-owned
secrets service). External Postgres + bundled secrets, or bundled
Postgres + external secrets, are not supported deployment shapes
in 1.0 — those mixes require operator-authored compose changes that
this repo does not ship or verify.
Reference: docker-compose.yml (profile gating on db, vault,
db_backup).
3. Bundled (or external) + Caddy reverse proxy
Adding --profile proxy to compose brings up Caddy on ports 80/443
with three TLS modes (internal, acme, byo) selected by
PRAXIS_TLS_MODE.
Browser ingress is intentional and goes through Caddy only. Under the
prod overlay the backend and frontend publish no direct host ports
(ports: !override []); they are reachable only over the
Docker networks that Caddy proxies to. This fails secure in Compose
itself — a direct client cannot bypass Caddy/TLS — so --profile proxy
is required for the public browser path. Running the prod overlay
without --profile proxy yields a healthy but deliberately
unreachable stack (useful for headless smokes / API-over-network use,
not for browser access).
Reference: docker-compose.prod.yml, caddy/Caddyfile.
Deliberately unsupported shapes are consolidated under the Unsupported Deployment Shapes section below; see there for the operator-facing single-line rationales.
Fresh Install Path
The published path (from README.md) is (--profile proxy starts Caddy, the
browser ingress — backend/frontend publish no direct host ports, so omit it only
for a headless / external-reverse-proxy deployment):
git clone https://github.com/cytechlabs/praxis.gitcd praxiscp .env.example .env# Edit .env: set SECRET_KEY, ADMIN_PASSWORD, and POSTGRES_PASSWORDdocker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled --profile proxy pulldocker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled --profile proxy up -dGenerate the required secrets by hand into .env — e.g.
SECRET_KEY=$(openssl rand -hex 32), POSTGRES_PASSWORD=$(openssl rand -hex 24),
plus a strong ADMIN_PASSWORD. None of these have a default; no password ships
in the source tree.
Where each one is enforced differs, and the distinction matters when reading failures:
SECRET_KEYis required by Compose interpolation. Nothing is created at all;docker compose config,up, andpullall abort.POSTGRES_PASSWORDis enforced at startup, not at interpolation. Requiring it in Compose would also force external-database deployments to define a variable they never use, because Compose evaluates every interpolation eagerly regardless of the active profile. Instead the stack renders and containers are created, then fail immediately: the bundleddbentrypoint exits before the server accepts connections, the backend and broker exit during startup validation before binding a listener, and the backup sidecar exits before runningpg_dump. Nothing serves traffic and no data is written on a credential-less bundled deployment.
POSTGRES_PASSWORD applies to bundled mode only. External-database
deployments supply their credentials inside DATABASE_URL and must leave
POSTGRES_PASSWORD unset; there is nothing to duplicate.
Keep POSTGRES_PASSWORD in the unreserved URL character set (letters, digits,
- . _ ~): it is embedded in a connection string, so @ : / ? # [ ] % break
the URL, and a literal $ must be written $$ to survive Compose
interpolation. Local development uses this same production-parity stack
(there is no separate dev runtime); the only difference from a release is
building from source (up -d --build) instead of pulling a pinned image.
What is verified today:
- From-scratch cold build + boot is gated by
scripts/test-cold-rebuild.sh, which runs the canonical bundled production-parity stack (docker-compose.yml+docker-compose.prod.yml) throughdown -v→build --no-cache→up -dand waits for the backend/health. There is no separate dev runtime, so this gate builds and boots the SAME production images a release runs; the full pytest suite is covered by CI’s backend-test lanes (and the local venv workflow), and the app-level auth round trip by the fresh-install smoke below. - The production compose overlay bring-up is gated by
scripts/test-fresh-install-smoke.sh. The smoke brings updocker-compose.yml+docker-compose.prod.yml+scripts/fresh-install-smoke.override.ymlin an isolated compose project (praxis-fresh-install-smoke) under--profile bundled, generates ephemeralSECRET_KEY/ADMIN_PASSWORD/POSTGRES_PASSWORDto amktemp -denv file (never to.env), builds the prod images locally, waits for backend/health, then drives an authenticated/auth/login+/auth/meread, an admin-RBAC-gated/agent/activation-tokenslist, and the anonymous/agent/bootstrap.sh+/agent/ca-bundleroutes. On success it tears its own project down; on failure it leaves the project up with logs. - The first-enrolled-host redemption path is gated by
scripts/test-first-enrolled-host-smoke.sh. The smoke seeds a synthetic credential + system via direct SQL against the smoke-owned db, mints an activation token viaPOST /agent/activation-tokens, spins up a disposableubuntu:22.04container on the smoke project’sbackend_netacting as the dummy host, installs curl + openssl + jq (the same tools the committedbootstrap.shlists as host prereqs), generates an EC P-256 keypair + CSR with openssl, fetches/agent/ca-bundle, andPOSTs/agent/enrollwith theX-Praxis-Activation-Tokenheader. The smoke asserts the response carries an x509 certificate, the originalsystem_id, andagent_status=active, then re-reads the System row from Postgres to confirmagent_statusflipped andagent_cert_serialis populated. Successful round-trip produces a Vault-signed agent cert and persists serial + fingerprint + expiry on the backing System row.
What this smoke does NOT cover (intentional boundary):
backend/app/api/routes/_assets/bootstrap.shend-to-end. The script hard-requires systemd (systemctl required) and a realpraxis-agentGo binary from the pinned GitHub Release tag (agent-v0.0.0-rc1). Running it would either need a systemd-in-docker dummy host (privileged container) or a GitHub-Releases dependency in the smoke; both make the local drill slower and more brittle than the/agent/enrollredemption proof, which exercises the actual Praxis-specific surface (token redemption, Vault CSR signing,System.agent_statustransition).- The agent’s mTLS handshake against the broker
(
/agent/tunnel). That path is orthogonal to “first-enrolled-host” semantics and would require the real agent binary.
What is documented but not verified end-to-end:
- The GHCR-pull form of the prod overlay path
(
docker-compose.prod.ymlwith--profile bundled pull) is documented inREADME.md. The fresh-install smoke exercises--profile bundled up --build(local build) rather thanpull, so the pull path itself is not regressed against by CI today. - The Caddy
--profile proxypath withacmeandbyoTLS modes is documented inREADME.mdbut is not exercised by any CI job.
Gaps to close in later releases:
- The
--profile bundled pullpath could be added to a future optional smoke variant when a publishedPRAXIS_VERSIONGHCR tag is the actual ship target. The fresh-install smoke already exercises the harder local-build path. - Caddy
internal/byoTLS modes could be added to a future smoke variant;acmerequires live DNS and stays out of scope.
Upgrade And Migration Posture
The migration story today:
- A single linear Alembic chain lives under
backend/alembic/versions/. The earliest revision is20260402_2146_240b0ad2f12f_initial_schema.py; the most recent revision at the time this baseline was written is20260520_0001_pra175_compliance_dispatch_details.py. The current head should be confirmed withalembic headsrather than read from this doc, because M19 will keep adding revisions. docker compose exec backend alembic upgrade headapplies migrations;docker compose exec backend alembic downgrade -1rolls back one. There is no scripted multi-revision rollback path.- The dev cold-rebuild gate runs
init.sqlagainst a fresh Postgres and then runsalembic upgrade headimplicitly through the backend startup path. This proves the migration chain reaches head from empty. - The upgrade smoke at
scripts/test-upgrade-smoke.shproves the chain also converges on head from a representative pre-M13 (revisionpra149_review) and pre-M15 (revisionpra156_lifecycle_notif_state) production-shaped state. Committed fixtures live underbackend/tests/fixtures/upgrade/pre_m13.sqlandpre_m15.sql; the script can regenerate them in place viascripts/test-upgrade-smoke.sh --regenerate. - There is one repeating live-data gotcha:
init.sqlseeds rows with explicit ids (e.g.groups.id=1,distros.id=1..5,credentials.id=1) but does not advance the sequences. The cold-rebuild script papers over this with explicitsetval()calls (seescripts/test-cold-rebuild.sh); a fresh prod install hitting Alembic-only schema creation will not have this problem, but any operator who relies oninit.sqlfor seed data outside the bundled compose will. This belongs in the install runbook.
What is verified today:
- Migration from a representative pre-M13 (revision
pra149_review) and pre-M15 (revisionpra156_lifecycle_notif_state) database snapshot through the current head, including the M13 thin-agent identity columns, the M14 facts/lifecycle tables, and the M15 mirror/content schema. Verification runs viascripts/test-upgrade-smoke.shagainst committed fixtures; it is not in CI (same reason as the cold-rebuild gate — it is slow and hostile to parallel pytest).
What is unverified today:
- The upgrade smoke covers two representative boundaries but does
not yet capture every operator-shaped intermediate state. If
later milestones introduce destructive schema changes (column
drops, table renames, data backfills), a new fixture at the
matching boundary should be added via
--regenerateand committed. - No down-revision is verified beyond
alembic downgrade -1working in principle. Down-revisions are not part of CI.
Migration-rollback posture for Praxis 1.0:
- Forward migrations are the supported path. The supported recovery story is restore the dump that was taken before the upgrade (see Backup/Restore Drill below), not “downgrade through Alembic”.
- This is the rollback contract that should be made explicit in the install runbook in a later release.
Backup / Restore Drill
scripts/backup.sh:
- Runs inside the
db_backupalpine sidecar container under cron at0 2 * * *(02:00 daily, container time). - Connects to the bundled
dbservice over the docker network using${POSTGRES_USER:-postgres}and the deployment’s requiredPOSTGRES_PASSWORD(the script has no password fallback and exits with a named error if the sidecar receives none). - Writes a custom-format pg_dump to a restrictive (0600) same-directory
temp file that does not match
*.dump, validates the completed dump withpg_restore --list, then atomically renames it to/backups/${POSTGRES_DB}-YYYYMMDDHHMMSS.dumpon thebackup_datanamed volume. A failed, interrupted, or validation-failed dump is cleaned up and never leaves a final-looking.dumpthat restore selection could pick. - Retains backups for 30 days (
find ... -mtime +30 -exec rm), scoped to final*.dumpfiles only — in-progress temp files never match. - Has no off-host destination. The dump never leaves the docker host unless the operator copies it.
Restore path:
- Restore the newest dump with:
Terminal window docker compose exec db sh -c "ls -t /backups/*.dump | head -n 1 \| xargs -I {} pg_restore --verbose --clean --if-exists \-U postgres -d praxis {}" - This picks the most recent dump, runs
pg_restore --clean --if-existsagainst the livepraxisdatabase. There is no scripted point-in-time selection and no scripted post-restore verification. Backups are integrity-checked at creation time —backup.shvalidates each dump withpg_restore --listbefore publishing it — but the restore itself is not independently verified.
Full-app-state encrypted bundle
scripts/backup.sh above covers only PostgreSQL. For full bundled-deployment
recovery — after operator error, host loss, or volume corruption — use the encrypted
full-bundle path, which wraps scripts/backup.sh and additionally captures
vault_data, recordings_data, mirror_data, and (opt-in) the vault_recovery
unseal keys, all encrypted under an operator passphrase with a checksummed manifest and
atomic publish:
- Back up:
PRAXIS_BACKUP_PASSPHRASE=... scripts/backup-bundle.sh --include-recovery -o <off-host-staging> - Restore:
PRAXIS_BACKUP_PASSPHRASE=... scripts/restore-bundle.sh --bundle <file> --env-file <.env> - Smoke:
scripts/test-bundle-backup-restore-smoke.sh
The bundle is created on the host; moving it off-host is the operator’s
responsibility in 1.0. A working Vault restore needs the unseal keys (in the bundle
via --include-recovery, or supplied out of band). See
docs/backup-restore.md for the full contract, recovery-material
handling (the secrets-volume separation is preserved), required downtime, supported target shape,
and what is not covered in 1.0.
What is not backed up by scripts/backup.sh (the DB-only cron path — all of these
ARE covered by scripts/backup-bundle.sh above, except Caddy/Prometheus):
- The Vault
vault_dataandvault_recoverynamed volumes.vault_data(runtime material, mounted read-only into backend + agent-broker) holds the backend service token, the SSH CA public key, the agent CA certificate, the broker server cert/key, all KV secrets, and PKI lease metadata; the SSH CA private key and PKI roots stay inside Vault’s own file storage on this volume.vault_recovery(operator-only, mounted only into the Vault container) holds the unseal-key file (/vault/recovery/init-keys.json) and the init root token (/vault/recovery/root-token). Losing either volume is fatal — see the Vault section. - Caddy
caddy_data/caddy_config(ACME account keys and issued certificates ifacmemode is used). recordings_data(session recordings).mirror_data(mirrored repository content).
What is verified today:
scripts/test-backup-restore-smoke.shexercises the round trip end-to-end in an isolated compose project: insert a synthetic sentinel intoapp_settings, invokescripts/backup.shvia thedb_backupsidecar, assert the produced dump is non-empty, delete the sentinel, runpg_restore --clean --if-exists(the restore invocation above), assert the sentinel returned with its original value, and re-verify backend/health. Custom-format dumps are not grep-able as plain text; the round trip is what proves the dump’s contents are restorable.
Praxis 1.0 RPO / RTO contract (bundled mode)
- RPO ≈ 24 hours.
scripts/backup.shruns once a day at 02:00 container time. Any writes between two cron runs are lost if the host disk dies and no off-host backup was taken between them. - RTO: minutes to single-digit hours for the bundled
small-deployment shape, depending on dump size, image
availability, and operator involvement (the operator runs
pg_restore --clean --if-existsagainst a fresh stack). The The backup/restore smoke completes the full restore round-trip in well under five minutes on a developer laptop; production sizes dominated by the application database scale linearly with the dump. - Praxis 1.0 produces the dump only on the shared
backup_dataDocker volume. Copying the dump (and thevault_data/vault_recovery/recordings_data/mirror_datavolumes —vault_recoveryholds the unseal keys needed to bring Vault back up) off-host is the operator’s responsibility. The repo ships no off-host shipping automation.
Fatal failure modes (operator must know)
- Losing the most recent dump together with
vault_data/vault_recoveryis fatal for the bundled deployment.vault_dataholds the SSH CA private key, the agent and broker PKI roots, and all KV secrets;vault_recoveryholds the unseal keys, without which the Vault onvault_datacannot be unsealed on the next restart. There is no recovery path from a Postgres dump alone. The supported recovery story is “restore all three from off-host backup,” not “regenerate from migrations.” Operators running the bundled shape must capture the DB dump,vault_data, andvault_recoveryon the same off-host cadence.
Backup correctness notes
The bundled backup path is wired so the deployment’s POSTGRES_PASSWORD cannot
silently produce empty dumps:
docker-compose.yml’sdb_backupservice declaresPOSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DBin itsenvironment:block, reading the same requiredPOSTGRES_PASSWORDthedbservice is provisioned with, so the sidecar and the database can never drift apart.scripts/backup.shruns underset -euo pipefail, so a failedpg_dumppropagates immediately instead of printing a “Backup completed” success message over a zero-byte file.
Rotating the bundled PostgreSQL password
POSTGRES_PASSWORD provisions the superuser only on first initialization of
an empty postgres_data volume. On an existing deployment, changing it in
.env alone leaves the database with the old password and every service unable
to authenticate. Rotate inside the database first, then in .env. The volume is
preserved throughout; do not down -v.
Use psql’s interactive \password. It prompts twice, hashes the new password
client-side, and sends only the hash, so the cleartext never appears in argv,
in shell history, or in the server’s statement log. Do not use
ALTER USER ... WITH PASSWORD '<literal>' for this. exec db psql reaches the
database over the container’s local socket, so it connects regardless of which
password the volume currently holds.
# 1. Open an interactive psql session on the bundled database. Note `-it` and# no `-T`: the prompt needs a TTY.docker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled exec -it db \ psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-praxis}"
# 2. At the psql prompt, rotate and quit:# \password# (enter the new password twice, e.g. from `openssl rand -hex 24`# generated in a separate shell)# \q
# 3. Put the same value in .env (replace the POSTGRES_PASSWORD line).# Everything that connects reads it from there.
# 4. Recreate the services that hold a connection string. This restarts# containers only; named volumes, including postgres_data, are untouched.docker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled --profile proxy up -d \ backend agent-broker db_backup
# 5. Confirm the stack still authenticates.docker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled exec -T backend python -c \ "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).status == 200 else 1)"Recovery ordering matters. Step 1 and step 3 must agree:
- Steps 1-2 done, step 3 skipped: the database holds the new password while
.envstill holds the old one. Backend, broker, and the nightly backup fail to authenticate. Fix by completing step 3. - Step 3 done, steps 1-2 skipped:
.envholds a password the database was never given. Same failure. Fix by running steps 1-2 with the value now in.env, or by restoring the previous value in.env. - In both cases the data is untouched. Nothing here drops or reinitializes
postgres_data.
Upgrading a deployment whose postgres_data volume was initialized with the
old built-in postgres password: rotate before starting the app tier. The db
entrypoint refuses to start on that value, and the backend and broker refuse to
serve on it. Because POSTGRES_PASSWORD is only read when an empty volume is
initialized, the database still holds the old password internally while you
bring it up with the new one:
# 1. Put the new password in .env, then start ONLY the database.docker compose -f docker-compose.yml -f docker-compose.prod.yml \ --profile bundled up -d db# 2. Run steps 1-2 above so the stored password matches .env.# 3. Start the rest of the stack normally.Secrets Service (OpenBao) Unseal And Token Rotation
The bundled secrets service is OpenBao (a Vault-compatible service); the vault
Docker service name and /vault paths are kept for compatibility, and the bundled
runbook commands below use the bao CLI inside that container. What ships today
(vault/config/vault.hcl, vault/scripts/):
- File storage at
/vault/dataon thevault_datanamed volume. - Single-listener, TLS disabled on the listener (network is
docker-internal
backend_net; backend reaches Vault ashttp://vault:8200). disable_mlock = true(acceptable in a container, and documented as such by both OpenBao and HashiCorp Vault).- Init script (
vault/scripts/init-vault.sh) runs once: initializes Vault, writesinit-keys.jsonandroot-tokento the operator-only recovery dir (/vault/recovery, thevault_recoveryvolume), unseals using the keys it just wrote, enableskv-v2atpraxis/, enables the SSH CA atssh-client-signer, enables the agent PKI atpraxis-agent-ca, and enables the broker PKI atpraxis-broker-ca. A scoped, long-lived backend service token is written to/vault/data/backend-token(runtime material). - On every subsequent start,
startup.shre-runsinit-vault.sh, which detects the already-initialized state and re-uses the saved/vault/recovery/init-keys.jsonto unseal. This is auto-unseal-by-disk-file: it makes the bundled stack restart cleanly without operator prompting. The unseal keys and root token live onvault_recovery, mounted only into the Vault container, so read access to the app-mountedvault_datavolume (backend + agent-broker) yields only scoped service credentials and public cert material — not root or unseal material.
Token rotation today:
- The backend service token is created once at init and is not rotated by any timer or script.
- The Vault root token is created once at init and is not rotated.
- The agent PKI role and the broker server cert role are reconciled
every start (
init-vault.shrewrites the roles idempotently). - Broker server cert hostname drift triggers a loud warning but the
cert is not auto-rotated; the operator must rm the cert files
and restart the Vault container (documented in-line in
init-vault.sh).
What is not documented and not scripted:
- Manual rekey procedure for
init-keys.json. There is no operator workflow for “rotate unseal keys” or “rotate the root token” in this repo. The Vault docs cover it (vault operator rekey,vault token revoke) but applying it cleanly to the bundledinit-keys.jsonworkflow has not been written down here. - Backend token rotation. There is no scripted “issue a new backend-service token, swap it onto the volume, restart backend” flow.
- Recovery procedure if
init-keys.jsonis lost. This case is fatal for the data in that Vault — there is no key escrow.
Bundled-vs-external Vault support boundary:
- Bundled Vault is supported only as part of the fully-bundled deployment shape (bundled Postgres + bundled Vault together). Operators who need HA, KMS-backed auto-unseal, audit log shipping, or compliance-grade key management should switch the entire data tier to the fully-external shape and bring their own Vault (and their own Postgres). Bundled Vault paired with external Postgres is not a supported 1.0 shape.
- The repo guarantees the bundled init script provisions the
required mounts/roles for any Vault it controls; it does not
guarantee any feature parity for external Vault beyond
KV-v2 + the documented SSH/PKI roles being reachable under the
same paths. External-Vault operators are responsible for
provisioning
praxis/,ssh-client-signer/,praxis-agent-ca/, andpraxis-broker-ca/with the same role definitions found invault/scripts/init-vault.sh.
Bundled Vault: manual unseal-key rotation
The bundled Vault stores its unseal keys in
/vault/recovery/init-keys.json on the operator-only vault_recovery
volume (mounted only into the Vault container). The Vault
container reads them on every start to auto-unseal. Rotation is
manual; the repo ships no scheduled rekey job.
Procedure (operator runs these from the host):
# 1. Make sure the bundled stack is up and Vault is unsealed.docker compose ps vault # State: Up (healthy)
# 2. Take a fresh dump + copy the vault_data AND vault_recovery volumes# off-host BEFORE rotation. If rekey fails partway, you restore from# these (vault_recovery holds the current unseal keys).docker compose exec db_backup /scripts/backup.shdocker run --rm -v praxis_vault_data:/data -v "$PWD":/out alpine \ tar czf /out/vault_data-pre-rekey-$(date -u +%Y%m%dT%H%M%SZ).tgz -C /data .docker run --rm -v praxis_vault_recovery:/data -v "$PWD":/out alpine \ tar czf /out/vault_recovery-pre-rekey-$(date -u +%Y%m%dT%H%M%SZ).tgz -C /data .
# 3. Start a rekey ceremony. Defaults are 5 shares with threshold 3# (same as the original init); ``-init`` mode prints the new# unseal keys on completion.docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao login "$(cat /vault/recovery/root-token)" >/dev/null; \ bao operator rekey -init -key-shares=5 -key-threshold=3'
# Note the "Nonce" value printed. You will need to pass an OLD# unseal key for each of the 3 threshold steps below.
# 4. Submit the threshold-many existing unseal keys.docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao operator rekey -nonce <NONCE> "<old-unseal-key-1>"'# Repeat with old-unseal-key-2, old-unseal-key-3. The third# submission prints the NEW unseal keys (5 of them, plus a new# threshold and a new root token IF you also ran rekey-recovery).
# 5. Rewrite /vault/recovery/init-keys.json with the new unseal keys# so the next startup.sh re-unseal succeeds. The shape that# init-vault.sh expects is the same JSON Vault prints on init:# {"unseal_keys_b64":[...], "unseal_threshold":N, ...}.docker compose exec vault sh -c \ 'umask 077; cat > /vault/recovery/init-keys.json' < /path/to/new-init-keys.json
# 6. Verify by sealing + restarting.docker compose exec vault bao operator sealdocker compose restart vault# Wait for healthcheck to pass; startup.sh auto-unseals with the# new keys.docker compose ps vault # State: Up (healthy)
# 7. Destroy the off-host pre-rotation backup taken in step 2# AFTER you have confirmed the new keys work.If rekey fails partway: restore vault_recovery (and, if needed,
vault_data) from the off-host tarballs taken in step 2 and try again.
Do not lose the old keys until the new keys are confirmed working.
Bundled Vault: manual root-token rotation
The init-time root token in /vault/recovery/root-token (operator-only
vault_recovery volume, not app-readable) is also manually
rotated.
# Issue a new root token, then revoke the old one.docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao login "$(cat /vault/recovery/root-token)" >/dev/null; \ NEW_ROOT=$(bao token create -policy=root -orphan -ttl=0 -field=token); \ umask 077; echo "$NEW_ROOT" > /vault/recovery/root-token.new'
# Verify the new token works.docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao login "$(cat /vault/recovery/root-token.new)" >/dev/null; \ bao token lookup'
# Swap files atomically and revoke the old one.docker compose exec vault sh -c \ 'OLD=$(cat /vault/recovery/root-token); \ mv /vault/recovery/root-token.new /vault/recovery/root-token; \ bao login "$(cat /vault/recovery/root-token)" >/dev/null; \ bao token revoke "$OLD"'init-vault.sh reads /vault/recovery/root-token on every start to
log in for the idempotent role/policy reconciliation, so the swap
is picked up automatically on the next container restart.
Bundled Vault: backend service-token rotation
The backend reads /vault/data/backend-token (runtime material) at
startup (backend/scripts/start.prod.sh). Rotating it requires issuing a
new token under the backend-service policy, rewriting that file, and
restarting the backend so it re-reads it. Note the file the backend reads
stays in vault_data, but issuing the token requires logging in with the
root token from the recovery volume (/vault/recovery/root-token).
# 1. Issue a new backend-service token (login uses the recovery root token).docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao login "$(cat /vault/recovery/root-token)" >/dev/null; \ NEW_BE=$(bao token create -policy=backend-service -field=token); \ echo "$NEW_BE" > /vault/data/backend-token.new'
# 2. Swap files.docker compose exec vault sh -c \ 'OLD=$(cat /vault/data/backend-token); \ mv /vault/data/backend-token.new /vault/data/backend-token; \ echo "$OLD" > /tmp/old-backend-token'
# 3. Restart the backend so start.prod.sh re-reads the file.docker compose restart backend agent-broker
# 4. After confirming the stack is healthy, revoke the old token.docker compose exec vault sh -c 'export VAULT_ADDR=http://127.0.0.1:8200; \ bao login "$(cat /vault/recovery/root-token)" >/dev/null; \ bao token revoke "$(cat /tmp/old-backend-token)"; \ rm /tmp/old-backend-token'Backend and broker containers both read the same shared
vault_data volume, so restarting both is required when rotating
the backend service token.
External OpenBao / Vault-compatible: provisioning checklist
To run Praxis with an operator-owned external OpenBao (or HashiCorp
Vault) service, that service must provide the same mounts, roles, and
policy paths the bundled vault/scripts/init-vault.sh provisions. The
checklist below mirrors init-vault.sh so an operator can replay it
against their service without reading the script. The commands are
identical whether you drive them with bao (OpenBao) or vault
(HashiCorp Vault) — the CLI and API are compatible; vault is shown here.
Required secrets-engine mounts:
vault secrets enable -path=praxis kv-v2vault secrets enable -path=ssh-client-signer sshvault secrets enable -path=praxis-agent-ca pkivault secrets enable -path=praxis-broker-ca pki
vault secrets tune -max-lease-ttl=43800h praxis-agent-ca # 5 yearsvault secrets tune -max-lease-ttl=43800h praxis-broker-ca # 5 yearsSSH CA:
vault write -field=public_key ssh-client-signer/config/ca \ generate_signing_key=true > /path/to/ssh-ca-public-keyvault write ssh-client-signer/roles/praxis-user - <<'EOF'{ "key_type": "ca", "allow_user_certificates": true, "allowed_users": "*", "allow_user_key_ids": true, "allowed_extensions": "permit-pty,permit-user-rc", "default_extensions": {"permit-pty": ""}, "default_user": "", "ttl": "30m", "max_ttl": "1h"}EOFAgent PKI:
vault write -field=certificate praxis-agent-ca/root/generate/internal \ common_name="Praxis Agent CA" issuer_name="praxis-agent-root" \ ttl=43800h > /path/to/agent-ca-cert.pemvault write praxis-agent-ca/roles/agent \ allowed_domains="agent.praxis.internal" \ allow_subdomains=true allow_any_name=false \ allow_bare_domains=false allow_glob_domains=false \ allow_wildcard_certificates=false allow_ip_sans=false \ allow_localhost=false use_csr_common_name=false \ use_csr_sans=false enforce_hostnames=true \ allowed_uri_sans="praxis://system/*" \ client_flag=true server_flag=false \ key_type=ec key_bits=256 \ ttl=1h max_ttl=1h no_store=falseBroker PKI:
vault write -field=certificate praxis-broker-ca/root/generate/internal \ common_name="Praxis Broker CA" issuer_name="praxis-broker-root" \ ttl=43800h > /path/to/broker-ca-cert.pemvault write praxis-broker-ca/roles/server \ allowed_domains="<comma-separated-broker-hostnames>" \ allow_subdomains=false allow_any_name=false \ allow_bare_domains=true allow_glob_domains=false \ allow_wildcard_certificates=false allow_ip_sans=true \ allow_localhost=true use_csr_common_name=false \ use_csr_sans=false enforce_hostnames=false \ client_flag=false server_flag=true \ key_type=ec key_bits=256 \ ttl=8760h max_ttl=8760h no_store=falseThen issue a broker server cert/key/CA bundle and mount them at
/vault/data/broker/{server.crt, server.key, ca.crt} for the
agent-broker process to pick up (or override via
PRAXIS_BROKER_TLS_CERT / _KEY / _CA_CLIENT).
Backend service policy:
path "praxis/*" { capabilities = ["create", "read", "update", "delete", "list"]}
# SSH CA operationspath "ssh-client-signer/config/ca" { capabilities = ["read"] }path "ssh-client-signer/sign/praxis-user" { capabilities = ["create", "update"] }path "ssh-client-signer/roles/praxis-user" { capabilities = ["read"] }
# Agent CA operationspath "praxis-agent-ca/cert/ca" { capabilities = ["read"] }path "praxis-agent-ca/sign/agent" { capabilities = ["create", "update"] }path "praxis-agent-ca/roles/agent" { capabilities = ["read"] }
# Broker CA operationspath "praxis-broker-ca/cert/ca" { capabilities = ["read"] }path "praxis-broker-ca/issue/server" { capabilities = ["create", "update"] }path "praxis-broker-ca/roles/server" { capabilities = ["read"] }Write the policy, then issue a long-lived token bound to it and
hand the token to the backend via VAULT_TOKEN:
vault policy write backend-service /path/to/backend-policy.hclBACKEND_TOKEN=$(vault token create -policy=backend-service -field=token)# Set in the operator's .env:# VAULT_ADDR=https://vault.your-corp.com:8200# VAULT_TOKEN=<BACKEND_TOKEN>External-Vault operators are responsible for token renewal /
rotation cadence; the repo’s bundled rotation procedures above
do not apply, because there is no shared vault_data volume to
rewrite.
Fatal Vault failure modes (operator must know)
- Losing
init-keys.json(on thevault_recoveryvolume) means the bundled Vault cannot be unsealed on next restart. There is no key escrow; recovery requires restoringvault_recoveryfrom an off-host backup that captured/vault/recovery/init-keys.json. Capturevault_recovery(alongsidevault_dataand the Postgres dump) on the same off-host cadence (see the Backup/Restore section). - Losing the root token (
/vault/recovery/root-token) is recoverable if you still have the unseal keys: run a Vault root-token regeneration ceremony (vault operator generate-root) using the threshold of unseal keys to mint a new root token. - Losing
vault_dataANDvault_recoveryoff-host backups is terminal for the bundled deployment — the SSH CA private key, agent/broker PKI roots, and KV secrets (onvault_data) and the unseal keys (onvault_recovery) are gone. The supported recovery story is “restore both volumes from an off-host backup that captured them together.”
Production Env Validation And Startup Checks
What is enforced today on startup (backend/app/api/main.py,
backend/app/core/auth.py):
backend/app/api/main.pyraisesValueErrorif any ofSECRET_KEY,ALGORITHM,ACCESS_TOKEN_EXPIRE_MINUTESis unset.backend/app/core/auth.pyraisesRuntimeErrorifSECRET_KEYis unset (redundant with above).backend/app/core/auth.pyrejects a known-weakSECRET_KEYonly whenENVIRONMENT=production. In any other environment the same weak secret is logged as a warning and accepted.docker-compose.ymluses the${SECRET_KEY:?...}compose-substitution form on thebackendandagent-brokerservices, so a missingSECRET_KEYshort-circuitsdocker compose upwith a clear error.backend/app/api/main.pyflipsdocs_url/redoc_url/openapi_urltoNonewhenENVIRONMENT=production, hiding the OpenAPI surface from production deployments.docker-compose.prod.ymlforcesENVIRONMENT=productionon the backend as a literal value, so applying the prod overlay cannot silently inherit the base dev default (ENVIRONMENT=${ENVIRONMENT:-development}) and leave the production-only gates above disabled. This holds even if the operator’s.envsetsENVIRONMENT=development.
Production fail-clear validation
backend/app/core/startup_validation.py is called from
backend/app/api/main.py after the existing required_env_vars
check. Production-only rejections fire only when
ENVIRONMENT=production; the closed-set ENVIRONMENT check fires
in every mode (so the common ENVIRONMENT=prod typo can’t
silently demote a production deployment to dev behavior).
Each rejection raises StartupValidationError with the variable
name and the corrective action:
ENVIRONMENToutside{development, production, test}— fails before any other check so a typo likeENVIRONMENT=prodis caught immediately.- Missing, empty, or
postgresbundled database password:validate_database_credentialsinspects the password carried inDATABASE_URLand runs in every mode, not only production, because that URL is the sole credential the backend and broker containers receive. It is the enforcement point for the bundled credential, since Compose deliberately does not requirePOSTGRES_PASSWORDat interpolation. Production additionally rejects aPOSTGRES_PASSWORDthat is empty or the retired default for deployments that assemble their own URL. External-Postgres operators set their ownDATABASE_URLand are exempt everywhere. - Empty
VAULT_TOKENwith non-bundledVAULT_ADDR— external-Vault deployments must supply a token. Bundled-Vault deployments read the token from the shared/vault/data/backend-tokenat runtime, so empty is correct there. - Empty
ADMIN_PASSWORDon a fresh deployment — enforced insidebackend/scripts/create_admin_user.py(which knows the current user count); in production withuser_count == 0and emptyADMIN_PASSWORD, the script now raises instead of silently skipping admin creation and leaving a production stack with no usable login. Subsequent boots that already have users skip the gate.
Test coverage:
backend/tests/services/test_pra179_startup_validation.py (13
tests) covers each rejection plus the bundled/external-mode
asymmetries.
Documented but not validated today (future candidates or explicit no-validate):
CORS_ORIGINSandTRUSTED_HOSTShave permissive defaults that are appropriate for dev but are not production-validated. LockingTRUSTED_HOSTSto a non-default value in production is a future follow-up; see Known Limitations & Future Work below.SECRET_KEYweak-value rejection lives inbackend/app/core/auth.py(unchanged); the new validator does not duplicate it. No-validate is intentional.
Stored-email serialization (UserResponse)
backend/app/api/schemas/auth.py previously declared
UserResponse.email as EmailStr. EmailStr requires
email-validator, which rejects RFC 2606 reserved TLDs (.invalid,
.test, …). An operator who set
[email protected] — a defensible choice for
internal-only deployments — saw every endpoint that returned
UserResponse 500 with a Pydantic ValidationError at
response-model serialization time, including /auth/me and
GET /users.
The response schemas were loosened (UserBase,
UserResponse) to plain str for email. Input-side schemas
(UserCreate, UserUpdate) keep EmailStr so self-service
signup/edit still validates strictly; only already-stored values
get the relaxed serialization path.
Regression test:
backend/tests/api/test_pra179_user_response_reserved_tld.py
creates an admin with [email protected], hits
/auth/me and /users, asserts both return 200 with the email
round-tripped unchanged.
Audit File Sink Confinement
A file audit sink appends newline-delimited JSON to local disk. The delivery
worker writes that path from inside the trust boundary, so every file sink is
confined to one operator-approved directory:
AUDIT_FILE_SINK_ROOTsets the root. It defaults to/data/praxis/audit-sinks, backed by the dedicatedaudit_sink_datanamed volume thatdocker-compose.ymlmounts into the backend only. Sink output therefore survives backend recreation, and no other service can read or write it. The production image pre-creates the mount point owned by the non-root runtime user (UID 1000), so the volume keeps that ownership on first mount.- Sink targets are relative paths beneath the root, for example
exports/audit.jsonl. Missing intermediate directories are created on first delivery. - Absolute paths, empty or directory-only targets,
.and..segments, repeated or trailing separators, and any symlinked path component are rejected. Rejection happens when the sink is created or updated (HTTP 400) and independently on every delivery. Saving a sink also refuses an already-existing symlink in the target’s parents or at the target itself, a parent that is not a directory, and an existing target that is not a regular file. - Both checks walk the root and the target one component at a time from a descriptor on the filesystem root, with pinned directory descriptors and no-follow opens. Paths are never resolved before the policy is applied and are never reopened by name, so a symlink planted after the sink was saved cannot redirect the write outside the root.
- The root must be an absolute path with no
.or..segments, must not be the filesystem root, and must not be/app,/boot,/dev,/etc,/proc,/root,/run,/sys,/vault, or anything beneath them. A root that is itself a symlink, or that sits behind a symlinked ancestor, is refused rather than followed. Change the root only together with the compose mount.
Operator action for existing sinks: a file sink saved before this
confinement keeps its stored target and stays visible in Settings > Audit
Export, but its deliveries fail with an explanatory last_error and follow
the normal retry then dead-letter path. Nothing is rewritten or silently
redirected. Replace the target with a relative path under the root, then retry
the dead-lettered deliveries.
Bundled-vs-External Data Tier Boundary
The bundled db, vault, and db_backup services share a single
compose profile. The supported deployment shapes for the data tier
are therefore exactly two:
- Fully bundled — bundled Postgres + bundled Vault + bundled
db_backupsidecar (COMPOSE_PROFILES=bundled). - Fully external — operator-owned Postgres + operator-owned
Vault, no bundled data-tier services running, no
db_backupsidecar.
Mixed bundled/external data-tier shapes are not supported in Praxis 1.0. The repo neither ships a compose overlay nor exercises any test that brings up bundled Vault against external Postgres or the reverse; an operator who hand-rolls that mix is on their own.
Bundled Postgres:
- Single-node, single-volume, default credentials unless overridden, daily pg_dump at 02:00 with 30-day local retention.
- Suitable for small single-node deployments where the operator takes responsibility for off-host backup of the docker volumes.
External Postgres (only as part of the fully-external shape):
- Set
DATABASE_URL(andTEST_DATABASE_URLif running the test suite against external Postgres). The bundleddbservice does not run (gated bybundledprofile). - The operator owns: HA, replication, backups, point-in-time
recovery, monitoring, version pinning, and
pg_dumpschedule. - The repo guarantees: schema is managed via Alembic, the connection string is the only contract, no Praxis component bypasses SQLAlchemy to issue server-specific DDL.
- Praxis 1.0 expects PostgreSQL 15.x (bundled image is
postgres:15.17-alpine). External operators on newer majors are on their own for compatibility until that is explicitly verified.
Unsupported Deployment Shapes
The operator-facing list of deployment shapes that are explicitly out of scope for Praxis 1.0. None of these are “we forgot to ship support” — each is an intentional boundary with a one-line rationale below. Operators who need any of these shapes today are running outside the supported deployment contract.
Orchestration / topology
- Docker Swarm.
docker-compose.prod.yml’s preamble explicitly defers Swarm compatibility. No swarm-mode service definitions, no global / replicated mode stanzas, no swarm-routing mesh expectations. - Kubernetes. No manifests, helm charts, or kustomize overlays ship in this repo for 1.0. A future release can introduce them; nothing in 1.0 either ships or precludes that.
- Multiple agent-broker instances / broker HA. The agent
broker holds the agent tunnel registry (
AgentRegistry) and in-flight operation state (OperationManager) in memory, per process. 1.0 runs exactly one broker instance; scaling the broker to replicas would split agent connections and op state across processes with no shared registry, so an op dispatched to one broker could not reach an agent connected to another. The broker logs this invariant at startup. Horizontal broker scaling requires a future shared registry and is out of scope for 1.0. (This is independent of the single-backend-worker requirement for interactive SSH sessions, documented above.) - Mixed bundled/external data tier. The bundled
db,vault, anddb_backupservices share a single compose profile. The supported external shape is fully external Postgres and external Vault; bundled-Vault-with-external- Postgres (or the reverse) is not exercised by any test and is not a supported shape.
Data tier scale-out
- Multi-node Postgres replication or Patroni in bundled mode.
The bundled
dbis a singlepostgres:15.17-alpinecontainer. External-Postgres operators can run whatever their managed Postgres supports. - Vault HA, integrated storage (Raft), KMS-backed auto-unseal.
Bundled Vault uses
storage "file"with auto-unseal driven by an on-disk key file (see the Vault section above). Operators who need HA, KMS auto-unseal, audit log shipping, or compliance-grade key management must switch the entire data tier to the fully-external shape and bring their own Vault.
Backup, restore, durability
- Off-host backup shipping automation.
scripts/backup.shproduces dumps on the sharedbackup_datavolume only. Copying dumps (and the other named volumes —vault_data,vault_recovery,recordings_data,mirror_data) off-host is operator-owned. See the Backup / Restore Drill section for the RPO/RTO contract and the failure boundary. - Backup encryption at rest.
scripts/backup.shwrites custom-format pg_dump output unencrypted to thebackup_datavolume. Operators who need encryption at rest encrypt the volume (or the off-host destination) themselves. - Multi-revision Alembic downgrade. Forward migrations are
the supported path. The Praxis 1.0 rollback contract is
restore the pre-upgrade dump, not
alembic downgrade.
Edge / TLS / external trust
- Caddy
acmelive-DNS mode as a smoke-tested path. Theacmemode is supported as a configuration option (setPRAXIS_TLS_MODE=acme+PRAXIS_DOMAIN+PRAXIS_ACME_EMAIL) but exercising it requires live DNS + port 443 reachable, which is not feasible inside a local hermetic smoke. TheinternalandbyoCaddy modes have the same operator contract; onlyacmehas the live-DNS requirement. - Operator-owned external Vault automation. The
external-Vault provisioning checklist in this doc gives
exact
vaultCLI commands, but the operator owns running them against their Vault. Praxis 1.0 ships no automation for external-Vault rotation, token renewal, or audit-log shipping.
Agent-side surface
- **Activation-token agent bootstrap with the real Go binary
- systemd unit.** The first-enrolled-host smoke
verifies the
/agent/enrollredemption end-to-end against a docker-side dummy host, but does not load thepraxis-agentGo binary or run the agent’s mTLS handshake against the broker. Operators who need those paths exercised do so against their own host. See the Fresh Install Path section’s boundary note.
- systemd unit.** The first-enrolled-host smoke
verifies the
bootstrap.shcurl --fail-with-bodyon recent curl versions. The committedbootstrap.sh:147usescurl -fsS --fail-with-body -X POST ..., which conflicts with the-fin-fsSon curl 7.76+ (including the curl shipped in ubuntu:22.04). An operator running the script on a recent Ubuntu host hits this; the first-enrolled-host smoke bypasses it by re-implementing the enroll POST. Documented queued for a future change; not in scope for 1.0.
Compliance / scanning / OS surface
- Embedded OpenSCAP runs. Compliance probes are deferred to future compliance surfaces; Praxis 1.0 does not introduce or exercise OpenSCAP.
- Host package-manager mutation. Any change to the control-plane host’s apt/yum/dnf state is operator-owned. Praxis 1.0 ships container images; container internals install via the Dockerfiles and are out of operator scope.
Verification & Support Status
Legend: Verified = exercised by an automated test/script in this repo; Documented = an operator procedure described here but run manually; Unsupported = explicitly out of scope for Praxis 1.0.
Verified by repo tests/scripts:
- Dev cold rebuild (
down -v->up --build-> pytest) —scripts/test-cold-rebuild.sh. - Prod-overlay fresh-install bring-up (bundled profile, locally built images;
waits for
/health, exercises authenticated and RBAC-gated routes) —scripts/test-fresh-install-smoke.sh. - First-enrolled-host bootstrap (activation-token mint + redemption via
POST /agent/enrollagainst a docker-side dummy host; asserts the System row reachesagent_status=activewith a Vault-signed cert serial) —scripts/test-first-enrolled-host-smoke.sh. - Alembic
upgrade headfrom empty (cold-rebuild + CI) and from representative earlier snapshots (pre_m13.sql,pre_m15.sql) —scripts/test-upgrade-smoke.sh. - Daily
pg_dumpvia thedb_backupsidecar + apg_restoreround-trip (sentinel insert -> backup -> delete -> restore -> re-verify/health) —scripts/test-backup-restore-smoke.sh. - RPO/RTO targets — see the RPO/RTO contract above (RPO ~ 24 h, RTO minutes to single-digit hours).
- Startup validation: rejects missing/known-weak
SECRET_KEY, emptyADMIN_PASSWORDin production, a missing/empty/default bundled database password, an emptyVAULT_TOKENin external mode, and unknownENVIRONMENTvalues —backend/app/core/startup_validation.py(+ unit tests). - Bundled database credential contract: no password ships in the source; the
bundled
dbentrypoint, the backend/broker startup preflight, andscripts/backup.sheach exit before doing useful work when it is missing, empty, or the retired default; and externalDATABASE_URLmode renders and runs withoutPOSTGRES_PASSWORD—backend/tests/services/test_pra387_postgres_credential_contract.py.UserResponseserializes reserved-TLD emails asstr(input schemas keepEmailStr).
Documented operator procedures (manual, not automated):
- GHCR-pull form of the prod overlay (
--profile bundled pull); the smoke covers--build. - Caddy
internalandbyoTLS modes (caddy/Caddyfile). - Bundled Vault init / auto-unseal and the unseal-key / root-token / backend-service-token rotation procedures above.
- External Vault provisioning checklist above.
- Off-host shipping of the backup dump + named volumes.
Unsupported for 1.0 (see Unsupported Deployment Shapes):
- Docker Swarm, Kubernetes, multi-node bundled Postgres.
- Vault HA / integrated storage / KMS auto-unseal in bundled mode (external Vault only).
- Caddy
acmeTLS (needs live DNS + reachable :443). - Backup encryption at rest; embedded OpenSCAP runs.
- Multi-revision Alembic downgrade — the supported rollback is “restore the pre-upgrade dump”, not downgrade through Alembic.
Known Limitations & Future Work
backend/app/api/routes/_assets/bootstrap.shuses acurl --fail-with-bodyflag pair that conflicts on curl 7.76+; the conflicting flag should be dropped.TRUSTED_HOSTS(and optionallyCORS_ORIGINS) are not production-validated today — they carry permissive dev defaults. A startup-validation rule could require a non-defaultTRUSTED_HOSTSin production.- Optional automated coverage not yet present: a GHCR-pull install smoke, Caddy
internal/byoTLS smokes, and in-container Vault rotation dry-runs against a throwaway smoke-Vault.
References
README.md— public-facing install + deployment instructions.docker-compose.yml— production-parity base services (prod images/ entrypoints), two-tier network, health checks.docker-compose.prod.yml— prod overlay (pinned images, logging, ENVIRONMENT=production, Caddy profile).caddy/Caddyfile— TLS modes and edge headers.scripts/backup.sh— daily pg_dump.scripts/test-cold-rebuild.sh— from-scratch build + boot gate.scripts/test-fresh-install-smoke.sh,scripts/fresh-install-smoke.override.yml— hermetic prod-overlay bring-up smoke.scripts/test-upgrade-smoke.sh,backend/tests/fixtures/upgrade/pre_m13.sql,backend/tests/fixtures/upgrade/pre_m15.sql— upgrade smoke + committed pre-M13/pre-M15 schema fixtures.scripts/test-backup-restore-smoke.sh— backup/restore round-trip smoke against the shipped backup path (no compose override).backend/app/core/startup_validation.py,backend/tests/services/test_pra179_startup_validation.py— production env / startup fail-clear validator and its unit suite.backend/tests/api/test_pra179_user_response_reserved_tld.py— regression test for theUserResponse.email/ RFC 2606 reserved-TLD serialization (input schemas keepEmailStr; response schemas loosened tostr).scripts/test-first-enrolled-host-smoke.sh— first-enrolled-host redemption smoke. Spins up an ubuntu:22.04 dummy host on the smoke project’sbackend_net, generates a CSR with openssl, redeems the activation token viaPOST /agent/enroll, and asserts the System row transitioned toagent_status=activewith a Vault-signed cert serial.vault/config/vault.hcl,vault/scripts/init-vault.sh,vault/scripts/startup.sh— bundled Vault provisioning.backend/app/api/main.py,backend/app/core/auth.py— startup env validation today.backend/alembic/versions/— single linear Alembic chain, M0 through the latest revision named in the Upgrade And Migration Posture section above.- Operator commands are direct
docker compose ...invocations (there is no rootMakefile); see the README “Common operator commands” table.