Production Phaze runs as two compose files on two (or more) hosts:
- Application server (
docker-compose.yml): API/UI, controller worker, Postgres, Redis. No music/model/output file mounts. HTTPS via an internal CA. Redisrequirepass+ LAN binding. - File servers (
docker-compose.agent.yml, one per host): three per-lane agent workers (worker-analyze,worker-meta,worker-io) and a watcher. Holds music/video files locally; reaches the app-server over HTTPS for every state change.
This guide walks through bringing up a fresh two-host deployment from a clean checkout, then covers the build pipeline, rollback, and monitoring.
Optionally, the control plane can offload analysis to cloud backends declared in a backends.toml β a cloud-compute agent and/or one-or-more Kueue clusters. Cloud offload is entirely additive: with no backends.toml mounted, the deployment is the local-only two-host topology below.
flowchart TB
subgraph app["Application server (docker-compose.yml)"]
api["api<br/>FastAPI + admin UI (TLS :8000)"]
ctl["worker<br/>PHAZE_ROLE=control"]
pg[("postgres :5432<br/>metadata + SAQ broker")]
rd[("redis :6379<br/>cache / counters")]
cfg{{"backends.toml<br/>(optional, mounted)"}}
api --- pg
api --- rd
ctl --- pg
ctl --- rd
cfg -. loaded by .-> api
cfg -. loaded by .-> ctl
end
subgraph fs["File server(s) (docker-compose.agent.yml, one per host)"]
aw["worker-analyze/-meta/-io<br/>PHAZE_ROLE=agent (3 lanes)"]
wt["watcher"]
media[("/data/music (ro)")]
aw --- media
wt --- media
end
subgraph cloud["Cloud-compute agent (docker-compose.cloud-agent.yml)"]
ca["worker (arm64, kind=compute)<br/>network_mode: host over Tailscale"]
end
subgraph kueue["N Kueue clusters (kind=kueue)"]
k1["Cluster A<br/>LocalQueue"]
s1[("S3 staging A")]
k2["Cluster B<br/>LocalQueue"]
s2[("S3 staging B")]
k1 --- s1
k2 --- s2
end
fs -->|"HTTPS :8000 API (DIST-04)"| api
fs -->|"Postgres :5432 SAQ broker"| pg
fs -->|"Redis :6379 cache"| rd
cloud -->|"HTTPS :8000 API + Postgres :5432 broker"| api
api -->|"submit/watch Jobs (kube API)"| k1
api -->|"submit/watch Jobs (kube API)"| k2
k1 -->|"callback HTTPS :8000 API"| api
k2 -->|"callback HTTPS :8000 API"| api
The repo ships three deployment compose files plus a dev overlay:
| File | Host | Services | Notes |
|---|---|---|---|
docker-compose.yml |
Application server | api, worker (control role), postgres, redis |
Built locally from Dockerfile. No file mounts on api/worker except ./certs/ on api (DIST-01). |
docker-compose.agent.yml |
File server (one per host) | worker-analyze, worker-meta, worker-io (three per-lane agent-role workers, plus an off-by-default worker-drain profile service), watcher |
All services pull from GHCR via PHAZE_IMAGE_TAG (ghcr.io/simplicityguy/phaze). See agent-queue-lanes.md for the lane split. |
docker-compose.cloud-agent.yml |
OCI A1 (cloud) | worker (agent role, kind=compute) |
arm64 image, no media, named scratch. Cloud-burst compute agent over Tailscale. See cloud-burst.md. |
docker-compose.dev.yml |
Application server (dev only) | overlays api + worker |
Explicit opt-in only β included via just up-dev (-f docker-compose.yml -f docker-compose.dev.yml), NEVER auto-merged (phaze-476w: it was formerly docker-compose.override.yml, which docker compose auto-merged and silently hijacked the production just up). Mounts ./src for live reload, runs uvicorn --reload, sets PHAZE_DEBUG=true, and deliberately skips the cert-bootstrap entrypoint. just up (base compose only) is unaffected. |
| Service | Image / build | Command | Ports | Role |
|---|---|---|---|---|
api |
build Dockerfile |
uv run python -m phaze.entrypoint |
${API_PORT:-8000}:8000 |
FastAPI + admin UI behind TLS. Mounts ${CA_PATH:-./certs}:/certs:rw for the cert bootstrap. |
worker |
build Dockerfile |
uv run saq phaze.tasks.controller.settings |
β | Control-role SAQ worker (PHAZE_ROLE=control). Fileless; no volume mounts. |
postgres |
postgres:18-alpine |
β | ${POSTGRES_BIND_IP:-127.0.0.1}:5432:5432 |
Primary database. Loopback-only by default; set POSTGRES_BIND_IP to the app-server's private LAN IP in production so agents reach the SAQ broker (mirrors REDIS_BIND_IP). POSTGRES_PASSWORD is ${POSTGRES_PASSWORD:?} β compose parse fails if unset (phaze-rnh7). shm_size: "256m" (phaze-knwk) β Docker's 64 MB /dev/shm default starves Postgres's parallel dynamic-shared-memory allocations, which surfaces as failed parallel queries and index builds rather than as an obvious out-of-memory; justfile:21 mirrors the same value for the test container. Data on the pgdata named volume mounted at /var/lib/postgresql. |
redis |
redis:8-alpine |
redis-server --requirepass ${REDIS_PASSWORD:?...} |
${REDIS_BIND_IP:-127.0.0.1}:6379:6379 |
Cache / rate-limit / counters (no longer the SAQ broker β Postgres is, via PHAZE_QUEUE_URL). --requirepass fails fast at compose-parse time if REDIS_PASSWORD is unset. |
api and worker are built from the same Dockerfile and differ only by their command: api runs the cert-bootstrap entrypoint then uvicorn; worker runs the controller SAQ worker with PHAZE_ROLE=control.
The pinned Postgres image lives in exactly three places, and all three must move together:
| # | Site | Form |
|---|---|---|
| 1 | justfile:14 |
postgres_image := "postgres:18-alpine" (test container) |
| 2 | docker-compose.yml:84 |
image: postgres:18-alpine (production) |
| 3 | .github/workflows/tests.yml:48 |
image: postgres:18-alpine (CI service) |
tests/agents/deployment/test_postgres_image_pin.py fails the build if they disagree, so a
partial bump is caught in CI rather than in production. This is the phaze-tcqq outcome: the tag
was previously hardcoded in 10 places β four of them echo strings, so a partial bump would
happily print the old tag while running the new one. That was consolidated to these three
sites plus the guard test, not to a single source of truth, because Compose and GitHub Actions
cannot read a justfile variable.
| Service | Image / build | Command | Role |
|---|---|---|---|
worker-analyze |
ghcr.io/simplicityguy/phaze:${PHAZE_IMAGE_TAG:-latest} |
uv run saq phaze.tasks.agent_worker.settings |
Agent-role SAQ worker (PHAZE_ROLE=agent) consuming the analyze lane (process_file; default concurrency 4). Heartbeats with lane=analyze (PHAZE_AGENT_HEARTBEAT=true β as do all three lane workers, phaze-30fo). |
worker-meta |
ghcr.io/simplicityguy/phaze:${PHAZE_IMAGE_TAG:-latest} |
uv run saq phaze.tasks.agent_worker.settings |
Agent-role SAQ worker consuming the meta lane (extract_file_metadata/scan_directory/execute_approved_batch; default concurrency 2). Heartbeats with lane=meta. |
worker-io |
ghcr.io/simplicityguy/phaze:${PHAZE_IMAGE_TAG:-latest} |
uv run saq phaze.tasks.agent_worker.settings |
Agent-role SAQ worker consuming the io lane (s3_upload/push_file; default concurrency 4). Heartbeats with lane=io. |
worker-drain (profile drain, off by default) |
ghcr.io/simplicityguy/phaze:${PHAZE_IMAGE_TAG:-latest} |
uv run saq phaze.tasks.agent_worker.settings |
Transitional all-mode consumer of the legacy un-suffixed phaze-agent-<agent_id> queue during the lane-split migration. Start with docker compose -f docker-compose.agent.yml --profile drain up -d worker-drain. The only worker with PHAZE_AGENT_HEARTBEAT=false: it is unlaned, so an untagged beat would wipe the per-lane breakdown the three lane workers maintain. |
watcher |
ghcr.io/simplicityguy/phaze:${PHAZE_IMAGE_TAG:-latest} |
uv run python -m phaze.agent_watcher |
Always-on directory watcher (PHAZE_ROLE=agent). |
The three lane workers and watcher all mount the music library read-only via ${SCAN_PATH:?SCAN_PATH required}:/data/music:ro. There is no postgres or redis service here β agents reach the app-server's Redis (cache) and, as of the Phase-36 queue-backend migration, the app-server's Postgres broker directly over the LAN via PHAZE_QUEUE_URL (Postgres:5432). Application/file metadata is still reached only via the HTTP API β DIST-04 β and there is no DATABASE_URL on any agent service; the agent's only Postgres connection is the SAQ broker pool. See agent-queue-lanes.md for the full lane topology, concurrency knobs, and the legacy-queue drain runbook.
Cloud offload is optional and additive. The control plane discovers its execution backends from a
backends.toml file mounted into the api + worker (control) services and loaded via
PHAZE_BACKENDS_CONFIG_FILE (default /etc/phaze/backends.toml). If no file is mounted, the app
resolves an implicit local-only registry and cloud offload is off β there is no env master toggle
to set. (As of Phase 67, the old PHAZE_CLOUD_TARGET=local|a1|k8s selector and the flat
kube_* / s3_* / compute_* fields are removed with no back-compat; the app-server compose
comments document the mount that replaces them.)
backends.toml is a typed list of [[backends]] entries, each with a kind, a cost-tier rank
(lower runs sooner), and a concurrency cap:
kind |
Purpose | Requires |
|---|---|---|
local |
On-prem file-server queues (the default). | nothing beyond rank/cap |
compute |
Cloud-compute agent (rsync/push) β e.g. an OCI Ampere A1 (arm64) over Tailscale for long sets that would time out on a file server. | agent_ref + scratch_dir |
kueue |
A Kubernetes Kueue cluster that runs one-shot Jobs. N clusters can be declared simultaneously, each with its own LocalQueue and S3 staging bucket. | a nested [backends.kube] table (api_url / namespace / local_queue + _file-mounted kubeconfig/SA-token) |
Per-backend S3 staging is declared in [[buckets]] entries bound to backends by id. Each bucket
carries a scope β shared (referenceable by any number of kueue backends) or cluster-specific
(the sharing-cardinality invariant D-09 fails fast if a cluster-specific bucket is referenced by
more than one kueue backend). Bucket endpoint_url values carry a per-bucket http(s) SSRF guard.
Cloud on/off is therefore derived from the registry (cloud_enabled is true iff at least one
non-local backend resolves), never set by an env flag.
The current configuration surface for every cloud path is this one backends.toml. The two
sibling runbooks below are the operational deep-dives (provisioning, transport, credentials, deploy
ordering); both are being updated to the registry model, so treat backends.toml as authoritative
where they differ:
- cloud-burst.md β the
kind=computeagent: thedocker-compose.cloud-agent.ymlwalkthrough, the OCI A1 provisioning runbook (OpenTofu spec, Tailscale grants ACL, least-privilegephaze_brokerPostgres role), and the smoke test. On the file-server/agent host, bring the compute agent up/down withjust cloud-agent-up/just cloud-agent-down(standalonedocker-compose.cloud-agent.yml). - k8s-burst.md β the
kind=kueueclusters: the cluster-admin Kueue objects (ResourceFlavor / ClusterQueue / LocalQueue), the namespaced RBAC Role phaze's ServiceAccount needs to submit/watch Jobs, the_FILE-mounted kubeconfig/SA-token + S3-credential Secret, the S3 staging bucket + lifecycle rule, and the transport (Tailscale or WireGuard) to the kube API and S3 endpoint.
Because on/off is derived from the registry, disabling cloud offload means removing the non-local
entries from backends.toml (or unmounting the file entirely, which resolves the implicit
local-only registry), then restarting the control services:
docker compose up -d --force-recreate worker apiThe registry is read from the import-time settings singleton, so the change takes effect only after a
restart β like every other knob. After the restart, every file routes to the local file-server
queues exactly as it did before any cloud backend existed; in-flight work drains rather than aborting
(files already dispatched finish on their current backend). You do not need to tear down the kube
API, LocalQueues, S3 buckets, mounted Secrets, or the OCI A1 host to disable β they can all be left in
place, inert. Re-enabling later is just re-adding the [[backends]]/[[buckets]] entries plus a
restart, with no re-provisioning.
Phaze selects its settings class at process boot from the PHAZE_ROLE env var (default control), via phaze.config.get_settings():
PHAZE_ROLE=controlβControlSettings(LLM proposal generation, Discogs matching, fileless tasks). Used by the app-serverapi+worker.PHAZE_ROLE=agentβAgentSettings(HTTP client to the app-server, file-bound tasks). Used by the file-server's three lane workers (worker-analyze/worker-meta/worker-io) +watcher. The validators inAgentSettingsraise at construction time ifPHAZE_AGENT_API_URL,PHAZE_AGENT_TOKEN, orPHAZE_AGENT_SCAN_ROOTSis missing β agents fail fast with a clear error rather than emitting runtime 401s.
The api container does not start uvicorn directly. It runs uv run python -m phaze.entrypoint, which:
- Runs
phaze.cert_bootstrap.ensure_certs_present(/certs, ...)to generate (or no-op past) the internal CA + leaf cert before uvicorn binds. os.execvp-replaces the process withuvicorn phaze.main:app --ssl-keyfile /certs/phaze-server.key --ssl-certfile /certs/phaze-server.crt, so signals and PID 1 propagate cleanly.
The entrypoint reads three env vars, all with safe defaults so a plain docker compose up works in dev: PHAZE_CERTS_DIR (default /certs), PHAZE_API_HOST (default localhost, baked into the leaf CN), and PHAZE_API_TLS_SANS (default localhost,127.0.0.1,api). The bootstrap is idempotent β restarts against a populated /certs/ skip regeneration.
phaze.cert_bootstrap generates a self-signed ECDSA P-256 CA (10-year validity) and a CA-signed leaf cert (2-year validity) into /certs/ on the app-server's first start:
| File | Mode | Distribution |
|---|---|---|
phaze-ca.crt |
0644 | Public. Copied to every file server; agents point PHAZE_AGENT_CA_FILE at it. |
phaze-ca.key |
0600 | Private CA signing key. Never leaves the app-server host. |
phaze-server.crt |
0644 | Leaf cert presented by uvicorn over TLS. |
phaze-server.key |
0600 | Leaf private key. |
On actual generation (not the idempotent no-op path) a loud banner is emitted via both print() (interactive docker compose up) and logger.warning() (docker compose logs api). The banner references only the public CA path. Agents trust the app-server by validating its TLS chain against the operator-distributed phaze-ca.crt; the agent's outbound bearer token (PHAZE_AGENT_TOKEN) is what authenticates the agent to the app-server.
The env vars that gate this bootstrap on the agent side are PHAZE_AGENT_CA_FILE (default /certs/phaze-ca.crt), PHAZE_AGENT_API_URL (must be https:// when PHAZE_AGENT_ENV=production), and PHAZE_AGENT_TOKEN. See docs/configuration.md for the full env-var reference.
- Docker Engine 20.10+ and
docker composev2.x on both hosts justinstalled on both hosts (or rundocker composedirectly)- Both hosts on the same private LAN; no firewall blocking ports 6379 (Redis cache), 5432 (Postgres SAQ broker β agents reach it directly as of Phase 36), or 8000 (API) between them
- Postgres + Redis are NOT directly exposed to the public internet (LAN-only; the agentβPostgres:5432 edge is private-LAN scoped)
- On the app-server host:
./certs/(materialized bygit clonevia a committed.gitkeep, then cert-populated on first start; must be owned by uid 1000 β phaze-he8m),.env - On each file-server host:
./certs/(CA only, scp'd from app-server),./models/(materialized bygit clone, weights auto-download on first agent start; both owned by uid 1000 β phaze-he8m),.env
On the app-server host:
git clone https://github.com/simplicityguy/phaze.git
cd phaze
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD to a strong unique value β compose parse FAILS if unset
# Edit .env: set POSTGRES_BIND_IP to the app-server's private LAN IP (e.g., 192.168.1.10) so
# agents can reach the SAQ broker; at the 127.0.0.1 default they cannot
# Edit .env: set REDIS_PASSWORD to a strong unique value (>= 32 chars)
# Edit .env: set REDIS_BIND_IP to the app-server's private LAN IP (e.g., 192.168.1.10)
# Edit .env: set PHAZE_QUEUE_URL (raw libpq postgresql:// DSN) β the SAQ Postgres broker.
# Defaults to the in-compose `postgres` service; treat as a secret (PHAZE_QUEUE_URL_FILE).
mkdir -p certs # phaze-he8m: own ./certs as the operator (uid 1000) BEFORE `up`
just upjust up runs docker compose -f docker-compose.yml up -d (base compose only, so the dev overlay
is never auto-merged β phaze-476w; it also runs mkdir -p certs for you). The api container
bind-mounts ./certs:/certs:rw and runs as the non-root phaze user (uid 1000). The ./certs
dir must exist and be owned by uid 1000 before up (phaze-he8m): on a rootful Linux docker engine
a missing bind-mount source is auto-created by the daemon as root:root, and the uid-1000 cert
bootstrap then dies with PermissionError writing /certs/phaze-ca.crt before uvicorn binds β an
opaque crash-loop. A fresh git clone already materializes ./certs/ (it ships a committed
.gitkeep) owned by the cloning operator, and just up re-creates it defensively; the explicit
mkdir -p certs above is belt-and-suspenders. If you hit the crash anyway, the entrypoint now prints
the exact fix: sudo chown -R 1000:1000 certs.
On first start, the api container's entrypoint generates the internal CA + leaf cert into
./certs/. Watch the logs:
docker compose logs -f apiYou will see a multi-line banner:
==============================================================
GENERATED NEW PHAZE INTERNAL CA at /certs/phaze-ca.crt
COPY THIS FILE TO EVERY FILE SERVER and point each agent's
PHAZE_AGENT_CA_FILE env var at it. EXISTING AGENTS WILL FAIL
TO CONNECT UNTIL THEY HAVE THIS NEW CA.
==============================================================
After the banner, uvicorn binds port 8000 with TLS.
Verify: curl --cacert ./certs/phaze-ca.crt https://localhost:8000/docs returns the OpenAPI UI, and curl --cacert ./certs/phaze-ca.crt https://localhost:8000/health returns {"status":"ok"} once Postgres is reachable.
The CA private key (./certs/phaze-ca.key) stays on the app-server host (mode 0600). Only the public CA cert (./certs/phaze-ca.crt, mode 0644) is distributed to file-server hosts.
From the app-server host, for each file server:
scp ./certs/phaze-ca.crt operator@fileserver-east:/home/operator/phaze/certs/phaze-ca.crtOr use rsync, ansible, or any one-time file transfer mechanism. The operator-distributed CA is a public cert; non-secret.
On the app-server host, run the bundled phaze agents add management CLI (it
ships with the application image; run it inside the API container, or any
environment that has the phaze package installed and DATABASE_URL configured):
docker compose exec api uv run phaze agents add \
--id fileserver-east \
--name "File Server East" \
--scan-roots /data/music,/data/concerts
uv runis required, not optional (phaze-u5k0d). Theapi/workercontainers'CMDisuv run ...β the project resolves through uv's environment rather than an installed system Python β so a baredocker compose exec api phaze agents add ...fails withexecutable file not found in $PATH(verified against a deployed container, image2026.8.4, 2026-08-17; same defect asdocs/runbook.md's "StrandedactiveSAQ jobs" section). The image also now puts the venv'sbin/onPATH, so the bare form works too if you type it from memory βuv runis kept here anyway because it's correct regardless of thatPATHenv change. Outside the container (a host or CI environment withphazepip/uv-installed directly andDATABASE_URLreachable), drop thedocker compose exec api uv runprefix and just runphaze agents add ....
The CLI mints a strong bearer token, stores only its sha256 hash in the agents
table, and prints two things you need:
- The cleartext token β printed exactly once. Save it now β it is NOT
recoverable from the database (only the hash is stored). This is the value
you put in
PHAZE_AGENT_TOKENon the file-server side (Step 4). - The derived queue name
phaze-agent-fileserver-eastβ put this inPHAZE_AGENT_QUEUEon the file-server side (Step 4). The queue name is alwaysphaze-agent-<agent_id>; the agent worker asserts this at startup and exits if it disagrees, so copy the value the CLI printed.
The --id must match ^[a-z0-9]+(-[a-z0-9]+)*$ (the agents.id charset
constraint); the CLI rejects an invalid id (or a non-absolute scan root) with a
non-zero exit before touching the database, and reports a friendly error if
the id already exists. --name defaults to the titleized id when omitted.
A sentinel LIVE ScanBatch row is auto-created the first time the agent posts a file.
Under the hood / SQL fallback (no CLI access)
phaze agents add is equivalent to inserting the row by hand. If you only have a
psql session, generate a token and insert its sha256 hash (the full wire
string, prefix included) yourself:
python -c "import secrets; print('phaze_agent_' + secrets.token_urlsafe(32))"INSERT INTO agents (id, name, token_hash, scan_roots, created_at)
VALUES (
'fileserver-east',
'File Server East',
-- token_hash is sha256() of the chosen plaintext token (prefix included)
encode(sha256('phaze_agent_REPLACE_WITH_RANDOM_32_URLSAFE'::bytea), 'hex'),
'["/data/music", "/data/concerts"]'::jsonb,
now()
);Same rules apply: save the plaintext token (only the hash is stored), and the
agent's queue name is phaze-agent-fileserver-east.
On the file-server host, get the compose file and the .env template. All agent images are pulled from GHCR, so the checkout is only needed for docker-compose.agent.yml + .env.example.agent β not to build anything locally for a normal deployment:
git clone https://github.com/simplicityguy/phaze.git
cd phaze
cp .env.example.agent .env
mkdir -p models certs # phaze-he8m: own ./models (weights auto-download) and ./certs as uid 1000The mkdir -p models certs (also run for you by just up-agent) is required for the same
reason as Step 1 (phaze-he8m): the uid-1000 worker auto-downloads essentia weights into
./models:/models:rw and reads the CA from ./certs, so both bind-mount sources must exist
owned by uid 1000 before up β otherwise rootful dockerd creates them root:root and the
download fails with EACCES. A git clone already materializes both via committed .gitkeep
files.
Edit .env to set the required variables. The agent stack uses ${VAR:?msg} interpolation on SCAN_PATH, so docker compose fails fast at parse time if it is unset:
PHAZE_AGENT_API_URL=https://<app-server-lan-ip>:8000PHAZE_REDIS_URL=redis://<app-server-lan-ip>:6379/0+REDIS_PASSWORD=<the app-server's REDIS_PASSWORD>(cache only β no longer the SAQ broker). Prefer this split form over hand-typingredis://default:<REDIS_PASSWORD>@<app-server-lan-ip>:6379/0:config.py's_apply_redis_passwordpercent-encodesREDIS_PASSWORDinto the URL for you (phaze-1g89i), so a strong password containing/,#,?, or%can't corrupt the DSN the way pasting it directly into the URL would.PHAZE_QUEUE_URL=postgresql://<user>:<password>@<app-server-lan-ip>:5432/phazeβ the SAQ Postgres broker DSN (raw libpq form, NOTpostgresql+asyncpg://). The agent now opens a psycopg3 pool to the app-server Postgres, so its host must be reachable on 5432 (new firewall edge relaxing D-25). Treat it as a secret β preferPHAZE_QUEUE_URL_FILE=/run/secrets/phaze_queue_url.PHAZE_AGENT_ID=fileserver-eastPHAZE_AGENT_TOKEN=<the plaintext token from Step 3>PHAZE_AGENT_QUEUE=phaze-agent-fileserver-eastβ by convention this MUST equalphaze-agent-<agent_id>(the valuephaze agents addprinted in Step 3). There is no queue column on theagentstable; the worker derives the expected name from the token's agent_id and exits non-zero on mismatch.PHAZE_AGENT_CA_FILE=/certs/phaze-ca.crtPHAZE_AGENT_ENV=productionSCAN_PATH=/path/to/your/music/libraryMODELS_PATH=./modelsβ a HOST path only; it picks the bind-mount source. Every worker + watcher service pins its own container-sideMODELS_PATH=/models(phaze-bvkah), so this value never changes where the container looks for models β only which host directory backs/models.CA_PATH=./certsPHAZE_AGENT_SCAN_ROOTS=/data/music,/data/concertsPHAZE_IMAGE_TAG=2026.8.4(orlatestfor first-time setup)
See docs/configuration.md for the complete env-var reference and defaults.
On the file-server host:
just up-agentjust up-agent runs docker compose -f docker-compose.agent.yml up -d, which brings up all three lane workers (worker-analyze, worker-meta, worker-io) plus watcher (the off-by-default worker-drain profile service is not included). On first start, the lane workers call /api/internal/agent/whoami to verify their token and validate ~3.1 GB of essentia weights (68 model/config files) in the shared ./models/ volume. A file lock lets one lane perform the download while its siblings wait and then revalidate; a fresh multi-GB transfer can take many minutes. The watcher comes up in parallel.
Watch any lane's logs. Every active lane worker publishes its own heartbeat; worker-analyze is
usually the most useful stream when checking audio-analysis startup:
docker compose -f docker-compose.agent.yml logs -f worker-analyzeYou should see (per lane worker; lane= varies by service):
phaze.tasks.agent_worker startup role=agent api=https://... auth_id_prefix=phaze_agent_a1b2... queue=phaze-agent-fileserver-east-analyze lane=analyzevalidating model weights -- essentia weights at /models (~3.1 GB across 68 files); ...another worker holds the model download lock -- waiting for it to finish before re-validating(the sibling lanes on a fresh start)models validatedphaze.tasks.agent_worker startup complete agent_id=fileserver-east queue=phaze-agent-fileserver-east-analyze lane=analyze
After each lane completes startup, its heartbeat β an asyncio background task in each of the three lane workers (every 30s, each beat tagged with its own lane; see agent-queue-lanes.md) β starts firing against POST /api/internal/agent/heartbeat.
Run both stacks on one host (dev convenience):
just up-allrunsdocker compose -f docker-compose.yml -f docker-compose.agent.yml up -d. This is for development only β production keeps the app-server and file-server stacks on separate hosts to preserve filesystem isolation (DIST-01).
From any browser on the LAN (or via SSH tunnel from your laptop):
# Trust the CA in your local browser, or use curl:
curl --cacert ./certs/phaze-ca.crt https://<app-server-lan-ip>:8000/admin/agentsThe /admin/agents page renders an agent table and self-refreshes via an HTMX poll every 5 seconds. Each agent row shows a liveness status derived from agents.last_seen_at (phaze.services.agent_liveness.classify):
| Status | Condition |
|---|---|
| alive | now - last_seen_at < 90s (3x the 30s heartbeat cadence) |
| stale | 90s <= now - last_seen_at < 300s (one or more missed beats) |
| dead | now - last_seen_at >= 300s (~10 missed beats) |
| never | agent registered but has never sent a heartbeat (last_seen_at IS NULL) |
| revoked | agent has a revoked_at timestamp |
You should see the agent reach alive within ~60s of just up-agent.
If the row shows never: the agent worker has not completed startup yet. Check the worker logs.
If the row shows stale then dead: the worker is up but heartbeats are not reaching the app-server. Check the agent worker logs for heartbeat failed: ... WARNING lines, and verify the agent can reach https://<app-server>:8000/api/internal/agent/heartbeat with the correct CA cert and token. If a busy worker (high CPU, actively analyzing) still appears stale/dead, confirm the agent image carries the Phase 46 build β before it, the heartbeat ran as a SAQ cron job that competed for the worker_max_jobs dispatch slots and was starved by multi-hour process_file jobs; the fix runs the heartbeat as an in-process asyncio background task that cannot be starved.
The watcher service (src/phaze/agent_watcher/, runnable via python -m phaze.agent_watcher) is an always-on asyncio process β not a SAQ worker. On startup it:
- Loads
AgentSettingsviaget_settings()(raises ifPHAZE_ROLE != agent). - Calls
/api/internal/agent/whoamiwith bounded retry to resolve the calling agent's identity and scan roots. A bad token short-circuits immediately (fail fast, no restart loop). - Schedules one
watchdogObserver per scan root and posts each settled file to the app-server.
Tunables (see AgentSettings in docs/configuration.md): PHAZE_WATCHER_SETTLE_SECONDS (default 10), PHAZE_WATCHER_SWEEP_INTERVAL_SECONDS (default 2), PHAZE_WATCHER_MAX_PENDING_SECONDS (default 3600), and PHAZE_WATCHER_POLLING_MODE (default false β set true for macOS Docker bind mounts where inotify does not propagate).
Images are built and published to the GitHub Container Registry (GHCR) by two reusable GitHub Actions workflows, both invoked from .github/workflows/ci.yml via workflow_call.
Called from the CI docker job (after quality, only when non-markdown files change). It:
- Builds each Dockerfile via a two-entry matrix and lints them with hadolint (
failure-threshold: error):Dockerfile(api) andDockerfile.agent-arm64(the cloud-compute arm64 agent image β lint-only here, itsbuild:/push:steps are guarded off because a full QEMU C++ essentia compile on the x86 runner is forbidden; the real native build runs indocker-publish.yml). - Validates both compose files parse cleanly:
docker compose -f docker-compose.yml config --quiet(with placeholderREDIS_PASSWORD/REDIS_BIND_IP) anddocker compose -f docker-compose.agent.yml --env-file .env.agent config --quiet(with placeholder agent vars).
No images are pushed by this workflow β it is a gate.
Called from the CI docker-publish job, which runs only after aggregate-results passes and only when code changed. It:
This workflow produces three GHCR artifacts across three build stages (push is true for non-PR events):
- A single-image matrix (
build-and-push) buildsDockerfile(api) onlinux/amd64. - A native-arm64 stage builds
Dockerfile.agent-arm64on anubuntu-24.04-armrunner. Itloads (does not push) the image, runs an import smoke test, and hands the resolved-arm64tags + OCI labels to a parity-gated pusher (parity-guard) β the arm64 image reaches GHCR only after its analysis output matches the x86 golden byte-for-byte, so a parity-divergent image can never publish ahead of the guard. - A
build-job-runnerstage buildsDockerfile.jobFROM the freshly-pushed x86 api image (aneeds: build-and-pushdependent, not a matrix row) β the Kueue one-shot Job image.
| Artifact | Dockerfile | Platform | Consumed by |
|---|---|---|---|
ghcr.io/simplicityguy/phaze |
Dockerfile |
amd64 | app-server api/worker (built locally) and docker-compose.agent.yml's three lane workers (worker-analyze/worker-meta/worker-io) + watcher (pulled) |
ghcr.io/simplicityguy/phaze:*-arm64 |
Dockerfile.agent-arm64 |
arm64 | docker-compose.cloud-agent.yml cloud-compute agent (kind=compute); parity-gated push |
ghcr.io/simplicityguy/phaze/job |
Dockerfile.job |
amd64 | Kueue backend (kind=kueue) one-shot Jobs submitted by the control plane |
- The
apiimage publishes to the bare repo URLghcr.io/simplicityguy/phaze(no sub-path) sodocker-compose.agent.yml's three lane workers +watchercan pull it directly; the arm64 variant is the same bare URL with a-arm64tag suffix.ghcr.io/simplicityguy/phaze/apiis a deprecated/orphaned path from a pre-D-15 convention β it is no longer published and must NOT be pulled or referenced. - Tag strategy (via
docker/metadata-action):lateston the default branch, plus{{version}}and{{major}}.{{minor}}semver tags,ref-based tags (tag/branch/PR), and a dated schedule tag. Tagged releases therefore produce both:latestand:<version>. - Release tags MUST be 3-part CalVer (
YYYY.M.REVISION, e.g.2026.7.0) βci.ymltriggers the publish pipeline onpushof a[0-9]+.[0-9]+.[0-9]+tag, and the{{version}}/{{major}}.{{minor}}image tags are only produced for a 3-part ref. A 2-part tag (2026.7) will not match the trigger and will not publish version-pinnable images, so the 3-part shape is still required. - Builds with
provenance: trueandsbom: truefor supply-chain attestation, onlinux/amd64.
Release version scheme (CalVer). Releases use CalVer YYYY.M.REVISION with a bare tag (no v prefix β the first CalVer tag is 2026.7.0) and a no-leading-zero month (2026.7.0, not 2026.07.0). REVISION is a per-month zero-based counter: the Nth release within a given YYYY.M, starting at 0 and resetting each calendar month, so same-month patch releases just increment REVISION (2026.7.0 β 2026.7.1). Milestones are named; versions are dated β the two are decoupled.
Publish trigger (invariant). GHCR publish fires on the push of an annotated tag (git tag -a 2026.7.0 -m "β¦" then git push origin 2026.7.0) β creating the tag locally publishes nothing; the push of the tag ref is the sole trigger. If a tag was pushed wrong (bad SHA, premature), delete-and-recreate it: git push --delete origin 2026.7.0, fix, then re-tag and push again to re-fire the pipeline. docker-publish.yml's metadata-action is scheme-agnostic (type=semver parses 2026.7.0 β {{version}}=2026.7.0, {{major}}.{{minor}}=2026.7), so no workflow edit is needed to adopt CalVer.
flowchart LR
df1["Dockerfile"] --> api["ghcr.io/β¦/phaze<br/>(amd64)"]
api -->|"docker-compose.yml + agent.yml"| use1["api / worker / watcher"]
api -.->|"FROM (needs build-and-push)"| dfjob["Dockerfile.job"]
dfjob --> job["β¦/phaze/job (amd64)"]
job -->|"kind=kueue Jobs"| kueue["Kueue backend"]
df4["Dockerfile.agent-arm64"] --> loadarm["build + load (arm64)<br/>import smoke"]
loadarm --> gate{"parity guard<br/>arm64 == x86 golden?"}
gate -->|"pass"| arm["β¦/phaze:*-arm64"]
gate -->|"fail"| block["push blocked"]
arm -->|"docker-compose.cloud-agent.yml"| compute["kind=compute agent"]
The Dockerfile is multi-stage. A css-builder stage (FROM python:3.14-slim AS css-builder) compiles assets/src/app.css β src/phaze/static/css/app.css with the pinned standalone Tailwind v4 binary (TAILWIND_VERSION=v4.3.2, kept in sync with the justfile tailwind recipe β no Node, no CDN); the final base stage (FROM python:3.14-slim AS base) copies only the generated CSS. base installs deps with uv sync --frozen --no-dev in cached layers, copies src/, alembic/, and alembic.ini, runs as the non-root phaze user, and exposes port 8000. The api and worker containers share this image and diverge only by command.
You can also build/push manually with just: just docker-build, just docker-validate (hadolint), just docker-compose-validate, and just image-push (requires a gh token with packages:write).
Every /static/... URL the app's templates emit (app.css, favicons, site.webmanifest, og_image.png) carries a ?v=<content-hash> query parameter β phaze.web.static.STATIC_VERSION, a SHA-256 fingerprint over the content of every file under src/phaze/static/, computed once at process import time. base.html / shell.html build these URLs via the static_url(...) Jinja global (registered per-router in shell.py, pipeline.py, execution.py, admin_agents.py β the four routers that render a full page extending base.html or rendering shell/shell.html).
RevalidatingStaticFiles (src/phaze/web/static.py, mounted at /static in main.py) answers a request whose ?v= matches the CURRENT STATIC_VERSION with Cache-Control: max-age=31536000, immutable β safe forever, because that exact URL can never later serve different bytes: if the content changes, the fingerprint changes too, and the browser fetches the new URL instead of reusing anything. Any other request (a stale fingerprint from a previous deploy, or a direct request with no ?v= at all β e.g. a browser's automatic /favicon.ico probe, or the icon paths inside site.webmanifest itself, which is static JSON and not template-rendered) falls back to Cache-Control: no-cache, forcing a cheap conditional-request/304 revalidation instead of a stale heuristic hit.
This replaces phaze-mw9l's no-cache-only fix (every hit, even an unchanged file, paid a round trip) and closes the defect it was filed against: a browser that had visited the site before a CSS-changing deploy previously kept rendering the OLD stylesheet against the NEW server HTML on an ordinary reload, because stock StaticFiles sent no Cache-Control at all and browsers applied heuristic freshness across deploys. Because the compiled src/phaze/static/css/app.css is produced by the Dockerfile's css-builder stage and COPY --from=css-builder'd into the final image (never a stale local copy β see .dockerignore), STATIC_VERSION computed at container startup always reflects exactly what that specific deployed image serves.
The full environment-variable reference, including required-vs-optional status and defaults, lives in docs/configuration.md. The two templates in the repo are .env.example (app-server) and .env.example.agent (file-server agent).
Production-critical variables:
| Variable | Host | Why it matters |
|---|---|---|
POSTGRES_PASSWORD |
app-server | ${POSTGRES_PASSWORD:?} β compose parse fails if unset (phaze-rnh7); there is no weak silent default. Use a strong unique value and keep DATABASE_URL / PHAZE_QUEUE_URL in sync with it. |
POSTGRES_BIND_IP |
app-server | Interface the published :5432 binds to. Must be the app-server's private LAN IP so agents can open their PHAZE_QUEUE_URL psycopg3 pool to the broker β at the 127.0.0.1 default they cannot reach it. Never 0.0.0.0, never a public IP. Mirrors REDIS_BIND_IP. |
REDIS_PASSWORD |
app-server | redis-server --requirepass; compose parse fails if unset. Use a unique high-entropy value (>= 32 chars). |
REDIS_BIND_IP |
app-server | Must be the app-server's private LAN IP so agents on other hosts can reach Redis. Never 0.0.0.0, never a public IP. |
PHAZE_QUEUE_URL |
app-server + file-server | The SAQ Postgres broker DSN (raw libpq postgresql://β¦, NOT +asyncpg). On agents it points at the app-server Postgres LAN IP:5432 β open that firewall edge (relaxes D-25). Carries DB credentials; use the _FILE secret form. Keep the per-queue pool budget under Postgres max_connections. |
PHAZE_AGENT_ENV=production |
file-server | Activates the AgentSettings guards: refuses non-https:// agent_api_url (CR-01) and passwordless redis_url (D-06). Note: there is no production credential guard on PHAZE_QUEUE_URL yet β protect it via the LAN-scoped firewall + a strong DB password. |
PHAZE_AGENT_TOKEN |
file-server | The plaintext bearer token; must match the token_hash row in agents. Generate via secrets.token_urlsafe(32). |
PHAZE_AGENT_CA_FILE |
file-server | Path to the operator-distributed phaze-ca.crt; the agent's HTTP client verifies the app-server TLS chain against it. |
PHAZE_IMAGE_TAG |
file-server | Pin to a specific version (for example, 2026.8.4) in production rather than latest. |
SCAN_PATH |
file-server | The music-library root, bind-mounted read-only into all agent services. Compose parse fails if unset. |
Every secret-bearing variable accepts a <VAR>_FILE sibling that points at a file holding the value, so you can mount a Docker/Swarm secret (or a Kubernetes secret / SOPS-decrypted file) instead of inlining cleartext into the environment. Supported: DATABASE_URL, REDIS_URL, PHAZE_QUEUE_URL (the SAQ Postgres broker DSN β queue_url), OPENAI_API_KEY, ANTHROPIC_API_KEY (each also via its PHAZE_* alias where one exists), and PHAZE_AGENT_TOKEN / AGENT_TOKEN. See docs/configuration.md β Secrets via files for the precedence and newline-stripping rules.
Example β mount the Anthropic key as a Docker secret on the app server and reference it by path (no ANTHROPIC_API_KEY in the environment):
# docker-compose.yml (app server)
secrets:
anthropic_api_key:
file: ./secrets/anthropic_api_key # contents: the raw sk-ant-... key
services:
api:
secrets: [anthropic_api_key]
environment:
ANTHROPIC_API_KEY_FILE: /run/secrets/anthropic_api_keyThe same pattern works for the agent's bearer token on a file server β mount the secret and set PHAZE_AGENT_TOKEN_FILE=/run/secrets/phaze_agent_token. The file's trailing newline is stripped, so the hashed wire string still matches the token_hash row (a stray \n would otherwise cause a permanent 401). A _FILE path that is missing or unreadable fails fast at startup with a clear error.
There is no automated rollback in CI β rollback is a manual re-deploy of a previously published image tag.
File servers (agent stack) pull from GHCR, so rolling back is a tag swap:
# On the file-server host:
# 1. Edit .env: set PHAZE_IMAGE_TAG back to the last-known-good version, e.g.
# PHAZE_IMAGE_TAG=2026.7.0
# 2. Re-pull and recreate the agent containers:
docker compose -f docker-compose.agent.yml pull
docker compose -f docker-compose.agent.yml up -dBecause docker-publish.yml tags both :latest and :<version>, every release remains pullable by its version tag β keep PHAZE_IMAGE_TAG pinned in production so a rollback is just editing one line.
Application server is built locally from the checkout, so rolling back means checking out the previous git tag and rebuilding:
# On the app-server host:
git checkout 2026.7.0 # the last-known-good CalVer release tag (a pre-CalVer rollback legitimately still uses its old v4.0.0 tag β mechanism unchanged)
just rebuild # docker compose -f docker-compose.yml up -d --buildTo stop and restart cleanly without rebuilding: just down (docker compose down) then just up. The pgdata named volume and ./certs/ persist across down/up, so no data or cert state is lost.
Do not
rm -rf ./certs/as part of a rollback β that triggers a full CA regeneration and breaks every agent until the newphaze-ca.crtis re-distributed (see CA Rotation below).
Builds before Phase 46 registered the heartbeat as a SAQ CronJob with unique=True, which parks a deterministic row keyed cron:heartbeat_tick in the Postgres broker (saq_jobs). After you redeploy the new agent image, nothing re-schedules that row (the heartbeat now runs as an in-process asyncio background task), so it lingers as a stale parked entry. Run this one-time cleanup after the new agent image is deployed, against the queue database (PHAZE_QUEUE_URL):
DELETE FROM saq_jobs WHERE key = 'cron:heartbeat_tick';It is harmless if the row is already absent (e.g. on a fresh broker or after a saq_jobs truncate). This mirrors the prior Redis orphaned-cron-purge runbook, adapted to the Postgres broker.
Prior revisions of this guide carried a one-time volume-ownership repair for the audfprint/
panako fingerprint sidecars (a uid-999-to-1000 mismatch on pre-7b9adec hosts). Both engines
and their sidecars were removed in full β 2026-07-28, epic phaze-0jpe; see
docs/design/0002-fingerprint-removal.md for why β so the
runbook no longer applies to a current checkout and has been deleted from this guide. The live
audfprint_data/panako_data Docker volumes on any already-deployed file server are not
touched by this removal; their disposal is a separate, explicitly-approved operator step (see
docs/runbook.md, phaze-0jpe.6) because they are the only surviving evidence for the two
production outages the ADR cites.
- API health endpoint:
GET /healthreturns{"status":"ok"}and checks database connectivity (SELECT 1). It requires Postgres to be reachable. Use it as the app-server liveness probe:curl --cacert ./certs/phaze-ca.crt https://<app-server>:8000/health. - Agent heartbeat / liveness: every lane worker (
worker-analyze/worker-meta/worker-io, all withPHAZE_AGENT_HEARTBEAT=true) runs an asyncio background task (every 30s βphaze.tasks.heartbeat._heartbeat_loop, gated byPHAZE_AGENT_HEARTBEAT) that POSTs to/api/internal/agent/heartbeatwith{agent_version, worker_pid, queue_depth, lane}. It is launched in the workerstartuphook and cancelled onshutdown, so it runs outside the SAQ job-dispatch pool and is never starved by long-running analysis jobs (Phase 46). Each beat carries its own lane tag and that lane's depth; the control plane keeps the per-lane breakdown and sums an honest all-lanequeue_depth, whilelast_seen_atis inherentlymax(last_seen)across lanes. This replaced the former single-heartbeat convention (phaze-30fo): pinning liveness toworker-analyzealone meant one stalled process marked the whole agent DEAD and cost it work-routing rank (select_active_agentorders bylast_seen_at DESC) while its other lanes were busy. Only the transitionalworker-drainsetsPHAZE_AGENT_HEARTBEAT=falseβ it is unlaned, and an untagged beat would wipe the per-lane breakdown (see agent-queue-lanes.md). The endpoint stampsagents.last_seen_atand persists the payload to theagents.last_statusJSONB column. The/admin/agentspage classifies each agent as alive/stale/dead/never/revoked fromlast_seen_at(thresholds: alive < 90s, dead >= 300s) and self-refreshes every 5s via HTMX. - Worker health:
just worker-healthruns the SAQ--checkagainst the controller worker;just worker-logsfollows its logs. - Logging: services log to stdout/stderr (
docker compose logs -f <service>). The cert-bootstrap banner additionally lands indocker compose logs apivialogger.warning(). No external metrics/tracing exporter (Sentry, Datadog, OpenTelemetry) is configured in this repo.
To verify DIST-01 (the app-server has no way to read or write music files), exec into the api container and try to read a file:
docker compose exec api ls -la /data/music
# Expected: ls: cannot access '/data/music': No such file or directoryOr trust the structural test that runs in CI:
uv run pytest tests/agents/deployment/ -vThe compose-parse tests assert that docker-compose.yml declares no SCAN_PATH, MODELS_PATH, or OUTPUT_PATH bind mounts on api or worker services β only ./certs/ is mounted on api (and that one is required for the cert bootstrap).
The CA + leaf cert generated in Step 1 is valid for 10 years (CA) / 2 years (leaf). If you ever need to rotate:
# On the app-server host:
rm -rf ./certs/ # destructive β all current cert state is lost
docker compose restart api # cert_bootstrap regenerates + prints the loud banner again
# Then repeat Step 2 (copy ./certs/phaze-ca.crt to every file server) and restart each agent.Every file-server agent will fail to connect until you re-distribute the new phaze-ca.crt. The loud banner is the only safeguard β do not delete the certs directory casually.
Kubernetes burst path (v6.0). The K8s one-shot Job does not bake the CA into its image β it mounts the public
phaze-ca.crtfrom an operator-createdcore/v1Secret read-only at/certs(KDEPLOY-06;PHAZE_KUBE_CA_SECRET_NAME, defaultphaze-internal-ca). After regenerating the CA above, re-create that Secret with the newphaze-ca.crt(kubectl create secret generic phaze-internal-ca --from-file=phaze-ca.crt=./certs/phaze-ca.crt) and let in-flight Jobs re-submit β no Job-image rebuild. See k8s-burst.md Β§6 for the full runbook.
For first-time setup, PHAZE_IMAGE_TAG=latest pulls the most recent tagged release from GHCR. For production, pin to a specific version:
# On the file-server host's .env:
PHAZE_IMAGE_TAG=2026.8.4Then just up-agent pulls exactly that version. The docker-publish.yml workflow tags both :latest and :<version> on tagged releases. The pin MUST be a 3-part CalVer YYYY.M.REVISION value (for example, 2026.8.4) matching a published release tag (ci.yml only publishes on push of a [0-9]+.[0-9]+.[0-9]+ tag).
To avoid the multi-GB model download on first agent boot:
# On the file-server host BEFORE just up-agent:
just download-modelsThis runs bash scripts/download-models.sh models, populating ./models/ directly; the agent's auto-download check then no-ops.
The 1001Tracklists lookup pipeline (epic phaze-fq9h) renders detail pages with a real, HEADFUL
Google Chrome (services/tracklist_render.py, TracklistRenderer/PatchrightLauncher) β the
only client spike phaze-dmvs found that clears the Cloudflare Turnstile widget without evasion
(fingerprint MATCHING: no proxies, no UA randomization, no CAPTCHA solvers β the epic's ethics
ceiling). That client-fidelity work is undone if the network path still looks like a bot.
This is a deployment constraint, not something the render engine's code can work around.
1001Tracklists' own FAQ states its anti-scraping measures block requests from datacenter, VPN, and
Tor IP ranges. Whatever host actually executes the render worker β a worker-* lane, a future
tracklist-drain service, or an ad hoc scripts/capture_tracklist_render.py run β must egress
through a residential (consumer ISP) IP address. A cloud VM, VPS, colo box, the OCI Always Free
tier used for cloud-burst compute (docker-compose.cloud-agent.yml), and hosted CI runners
(GitHub Actions ubuntu-latest included) are all datacenter ranges and are all in scope of this
block β none of them are a valid home for this worker, regardless of how the browser itself is
configured.
What happens if you get this wrong. The render engine cannot tell "IP-blocked" apart from an
ordinary flaky Turnstile challenge from its own vantage point β both look like the page never
delivering the .tlpItem track container. Every render will exhaust
tracklist_render_turnstile_attempts and report RenderOutcome.INTERSTITIAL_PERSISTED
(retryable, so nothing gets negative-cached β see RenderResult.is_retryable), forever, silently
spending the shared crawl-delay-8 host budget (phaze-hu8v) on requests that can never succeed no
matter how many attempts or how long the drain runs. There is no distinct "blocked" outcome to
alert on; the operator-visible signal is an unusually high, persistent INTERSTITIAL_PERSISTED
rate that does not improve across the corpus. Before trusting a bulk run's results, confirm the
render host clears the anchor URL at all (scripts/capture_tracklist_render.py --trials 6, run
manually, one process at a time, never wired into CI β see that script's own docstring).
For a future operator considering a VPS or cloud migration for this worker specifically: it
will stop working, silently in the sense above (no crash, no explicit "blocked" error β just a
100% INTERSTITIAL_PERSISTED rate), the moment the egress IP moves off a residential connection.
The epic's ethics bound rules out the usual fix (routing through a residential proxy service is
exactly the kind of evasion phaze-dmvs deliberately avoided), so the render worker's host placement
is a real, permanent constraint on where this feature can run β keep it on hardware with a
residential ISP connection (e.g. the same home-server host the file-server agent already runs on).
Before shipping a file-server host to production:
-
POSTGRES_PASSWORDset to a strong unique value βdocker composefails to parse without it (phaze-rnh7);DATABASE_URL/PHAZE_QUEUE_URLupdated to match -
POSTGRES_BIND_IPset to the app-server's private LAN IP β at the127.0.0.1default agents cannot reach thePHAZE_QUEUE_URLbroker on:5432(never0.0.0.0, never the public IP) -
REDIS_PASSWORDset to a unique high-entropy value (>= 32 chars) β never the default -
REDIS_BIND_IPset to the app-server's private LAN IP (never0.0.0.0, never the public IP) -
PHAZE_AGENT_ENV=productionβ enables the redis-password-required and https-required guards inAgentSettings -
PHAZE_AGENT_TOKENgenerated viasecrets.token_urlsafe(32), not a placeholder -
phaze-ca.crtdistributed via secure channel (scp over SSH, not email/chat) -
phaze-ca.keyNEVER copied off the app-server host -
PHAZE_IMAGE_TAGpinned to a specific release (for example,2026.8.4), notlatest -
SCAN_PATHpoints at the actual music library root (compose parse fails if unset) - Production bring-up uses
just up(basedocker-compose.ymlonly) β the dev overlaydocker-compose.dev.ymlis opt-in viajust up-devand is no longer auto-merged, so it cannot bypass the cert-bootstrap entrypoint (phaze-476w) - Filesystem-isolation smoke confirmed (see above) β
docker compose exec api ls /data/musicreturns "No such file or directory" -
/admin/agentspage shows alive status within ~60s ofjust up-agent - If this host runs the 1001Tracklists render worker: confirmed to egress through a residential IP (never a cloud VM/VPS/colo/CI runner) β see "1001Tracklists render worker: residential-IP constraint" above
.env.exampleβ app-server environment template.env.example.agentβ file-server agent environment templatedocker-compose.ymlβ app-server composedocker-compose.agent.ymlβ file-server agent composedocker-compose.cloud-agent.ymlβ OCI A1 cloud compute-agent compose (just cloud-agent-up/cloud-agent-down)docker-compose.dev.ymlβ dev-only overlay (live reload;just up-dev, explicit-f, never auto-merged)backends.tomlβ the cloud-backend registry (mounted atPHAZE_BACKENDS_CONFIG_FILE; absent β implicit local-only)- docs/cloud-burst.md β
kind=computeagent deploy + runbook (deep-dive; config surface isbackends.toml) - docs/k8s-burst.md β
kind=kueuecluster deploy + runbook (deep-dive; config surface isbackends.toml) - docs/configuration.md β full environment-variable reference
- docs/architecture.md β system architecture overview