Configuration Reference
Every environment variable the Productify platform reads, across the Manager, the Proxy (Caddyfile global block + environment), and the Pocket ID integration, with its default, whether it is required in development versus production, and its security implications.
The Manager loads configuration through kkyr/fig from config.local.yml (falling back to config.yml, or the file named by PFY_CONFIG_PATH), and every value can be overridden by an environment variable. The variable name is the YAML key path, joined with _, uppercased, and prefixed with PFY_; the YAML key db.host is PFY_DB_HOST.
Precedence is environment variable, then file value, then struct default. The file is decoded first and each environment variable is then applied over the top, so a PFY_* variable always wins over the same key in config.yml. Containerized deployments are expected to configure the Manager from the environment alone and ship no config file at all; the tables below are the complete surface for doing that.
YAML files are not interpolated
The config file is plain YAML; there is no ${VAR} expansion. A line like password: ${DB_PASSWORD} sets the password to the literal seven-character string, it does not read the environment. Pass secrets as PFY_* environment variables (which do override the file), never as placeholders inside it.
Two defaults make the platform secure by default: env defaults to production, and in production the Manager refuses to start without a DB password, a Pocket ID API key, and an encryption key, and rejects sslmode=disable. Local development opts out of all of that explicitly with env: development.
Settings that are silently wrong when unset
Most unset settings either take a sensible default or make the Manager refuse to boot. These five do neither: leaving them unset produces a Manager that starts cleanly, logs nothing alarming, and is quietly broken or unprotected:
| Variable | What an unset value actually does |
|---|---|
PFY_PROXY_SECRET | In production the Manager refuses to start when it serves the API (run_mode api or both). In development it starts, logs a repeated WARNING, and rejects 100% of the Manager API with a 401 (outside the dev-user-override escape hatch). The process is healthy and /healthz is green while every request fails. |
PFY_ENCRYPTION_KEY | In production: refuses to start (loud, correct). In development: stores endpoint/backend secrets in plaintext with a single warning line. |
PFY_PROXY_MANAGER_UPSTREAM | With PFY_PROXY_ADMIN_URL set the Manager refuses to start, but with sync off (the default) nothing warns you that ESB and pilet delivery are being served by whatever config the proxy last got. |
PFY_ORCHESTRATOR_KIND | Defaults to noop: every scale-from-zero wake is recorded in memory and discarded. The wake endpoint returns success, the proxy waits, and no job is ever scaled. |
PFY_PILETS_SIGNING_KEY_ID | With a private key set but no key id, startup fails. With neither set in production, uploads are rejected at signing time, not at boot, so the break surfaces on the first upload, not on deploy. |
Manager
Core
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_ENV | production | development | production | Secure by default. Gates the /gql playground, the dev reverse proxy, and the dev-user-override escape hatch (all IsDev()-only). Must be exactly development or production; anything else fails validation. |
PFY_RUN_MODE | both | any | api, executor, or both | Which halves of the Manager run in this process: the API server, the trigger executor, or both. |
PFY_PORT | 8080 | Main HTTP/API port. Must differ from PFY_HEALTH_PORT; range 1–65535. | ||
PFY_HEALTH_PORT | 8081 | Health-check port. Serves /healthz (liveness, always 200 with {"status":"healthy","version":"…","build_time":"…"}) and /readyz (readiness: pings the database and counts the tenants and services tables; 200 {"status":"ready","warnings":[…]}, or 503 {"status":"not ready","reason":"…"} with a machine-readable reason such as schema_not_applied). It is a separate http.Server from the API port, with no authentication and no proxy-secret gate. The API port serves GET/HEAD /health with the same readiness verdict (ok/unavailable anonymously, the full /readyz body with X-Proxy-Auth); /healthz on the API port is answered by the SPA catch-all, not by this handler. Must differ from PFY_PORT. | ||
PFY_FRONTEND_DIR | /app/frontend | Directory the Manager serves the bundled frontend from. | ||
PFY_PROXY_TARGET | http://localhost:5173 | Dev-only reverse-proxy target for the Vite frontend dev server; used only when IsDev(). | ||
PFY_CONFIG_PATH | (unset) | optional | optional | Overrides which config file is loaded, ahead of the config.local.yml → config.yml search. |
Security & secrets
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_PROXY_SECRET | (empty) | recommended | required when the API is served | The shared secret the trusted proxy sends as X-Proxy-Auth on every forwarded request. It establishes the proxy-to-manager trust boundary; without it the Manager would trust identity headers (X-Token-*) from anyone who can reach its port. In production with run_mode api or both, an empty value is a fatal startup error (proxy_secret is required in production when the API is served); run_mode=executor has no HTTP surface and does not need it. In development an empty value makes the Manager fail closed (reject every gated request) unless the dev-user override is on. Must match the proxy's PFY_MANAGER_TOKEN. |
PFY_ENCRYPTION_KEY | (empty) | optional (warns) | required | Base64-encoded 32-byte (AES-256) key encrypting secret columns at rest (endpoint auth_password/auth_token, registered-backend token). Unset in production ⇒ the Manager refuses to start (secrets would be plaintext). Unset in development ⇒ those columns are stored unencrypted with a loud warning. A malformed key is a fatal error in any environment. Generate one with openssl rand -base64 32. |
An unset PFY_PROXY_SECRET breaks the whole API, silently
PFY_PROXY_SECRET is checked before any credential is read, on every route that carries a user identity: /query (GraphQL), /pilets/*, /api/audit/*, /api/access/*, /cli/authorize, and the entire /api/proxy/*, /api/frontend-config/*, /api/validate-*, /language-packs/*, /pilet-feed/* and /pilet-keys/* surface.
In production an empty secret on an API-serving process is caught at startup (Validate() refuses to boot). In development the check fails closed at request time: the middleware answers 401 with a JSON body naming the cause, {"error":"manager misconfiguration: no proxy secret is configured (PFY_PROXY_SECRET is unset and dev_user_override is not enabled) …"}, and the Manager logs a repeated WARNING at startup. The process stays up, /healthz stays green, and every gated request fails. The only exception is the dev-user-override escape hatch (env=development anddev_user_override.enabled), which is the supported way to run the Manager locally with no proxy in front of it.
A mismatch between the two sides is not caught at startup in any environment. It produces 401 {"error":"missing or invalid X-Proxy-Auth header: …"} on every gated request. If you are seeing 401s from a Manager that looks healthy, read the error field first, then confirm the value matches the proxy's PFY_MANAGER_TOKEN byte for byte.
The machine-user surface (/api/machine/*, /esb/*) is not proxy-secret gated and answers with a structured {"error": "..."} JSON body instead, so a working ESB call is not evidence that the proxy secret is right.
Database (db.*)
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_DB_HOST | localhost | PostgreSQL host. | ||
PFY_DB_PORT | 5432 | PostgreSQL port; range 1–65535. | ||
PFY_DB_USER | postgres | PostgreSQL user. | ||
PFY_DB_PASSWORD | (no default) | may be empty | required | Has no default on purpose: a production deployment must supply one explicitly rather than silently shipping the well-known postgres credential. Validation rejects an empty password in production. Never logged (kept out of DSN/URL error text). |
PFY_DB_NAME | postgres | Database name. | ||
PFY_DB_SSLMODE | require | disable (opt-in) | require or stronger | Secure by default. disable is rejected in production; local development opts out explicitly. |
PFY_DB_MAXIDLE | 100 | Max idle connections in the pool. | ||
PFY_DB_MAXOPEN | 1000 | Max open connections in the pool. | ||
PFY_DB_MAXLIFETIME | 1h | Max connection lifetime (Go duration, e.g. 30m, 1h). |
Dev-user override (dev_user_override.*)
An authentication bypass that assigns a fixed identity to unauthenticated requests. It is structurally unshippable: it requires both env=developmentand the explicit enabled flag, and the Manager refuses to start if enabled is set in any non-development environment. When active it logs a loud, repeated startup warning.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_DEV_USER_OVERRIDE_ENABLED | false | opt-in only | must be off | Master switch. Setting it outside env=development is a fatal startup error. |
PFY_DEV_USER_OVERRIDE_ID | (empty) | dev only | Fixed user ID assigned to unauthenticated requests. | |
PFY_DEV_USER_OVERRIDE_EMAIL | (empty) | dev only | Fixed email. | |
PFY_DEV_USER_OVERRIDE_NAME | (empty) | dev only | Fixed display name. | |
PFY_DEV_USER_OVERRIDE_USERNAME | (empty) | dev only | Fixed username. |
Pocket ID integration (pocket_id.*)
The Manager's client to the Pocket ID identity provider (used to resolve/search users). See Pocket ID below for the full picture.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_POCKET_ID_HOST | http://pocketid.localhost | Base URL of the Pocket ID instance. | ||
PFY_POCKET_ID_API_KEY | (empty) | may be empty | required | Sent as X-API-Key to the Pocket ID admin API. Validation rejects an empty key in production. A secret; never commit it. |
Cron / executor (cron.*)
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_CRON_BACKEND_HEARTBEAT_TIMEOUT | 1m | How long a registered backend may go without a heartbeat before it is considered down (Go duration). | ||
PFY_CRON_TRIGGER_CHECK_INTERVAL | 30s | How often the executor scans for triggers to fire (Go duration). | ||
PFY_CRON_METRICS_PORT | 9090 | Prometheus metrics port for the executor; range 1–65535. | ||
PFY_CRON_AUDIT_ANCHOR_TAIL_WINDOW | 24h | How far back each audit-chain verification sweep re-checks. Bounds the per-run cost on a growing table; older segments are covered by the periodic anchors rather than re-verified every pass. Raising it raises the cost of every sweep. | ||
PFY_CRON_AUDIT_RETENTION_DEFAULT | 8760h (1 year) | Retention for most audit rows (auth, credential, membership, config, admin) before the retention sweep prunes them. Deletes data: set it to your compliance retention before the first sweep runs, not after. | ||
PFY_CRON_AUDIT_RETENTION_VIEW | 2160h (90 days) | Shorter retention for high-volume, low-value view events. Also deletes data. | ||
PFY_CRON_CREDENTIAL_ROTATION_MAX_AGE | 2160h (90 days) | The age at which a machine-user or backend token must be rotated. The sweep reports, it does not rotate: a credential past this age keeps working, and each pass sets pfy_executor_stale_credentials{kind} and writes one other audit row per credential per lapse. Rotating is a deliberate act (rotateMachineUserToken, or a backend re-registration). 0 disables the sweep, and the gauge is then left untouched rather than zeroed. | ||
PFY_CRON_FAILED_TRIGGER_RUN_RETENTION | 168h (7 days) | How long a terminal (failed) pending trigger run is kept before it is pruned. 0 disables the sweep, and failed runs then persist forever; a per-second cron on a never-registered scale-to-zero app accrues roughly 86k rows/day. | ||
PFY_CRON_COMMS_OUTBOX_INTERVAL | 1m | How often the communications outbox worker delivers queued out-of-app notifications. Kept well inside the default 5-minute digest grace window so the grace, not the ticker, decides when mail goes out. | ||
PFY_CRON_COMMS_OUTBOX_TIMEOUT | 5m | Bounds one delivery pass. The pass runs off the cron select loop, so this does not protect the rest of the schedule; it stops a wedged pass from occupying the runner indefinitely. |
These cron settings run only where the executor runs
Every sweep above is started by startCronJobs, which runs only when PFY_RUN_MODE is executor or both. A deployment made entirely of run_mode=api instances never prunes audit rows, never reports stale credentials, never verifies the audit chain, and never delivers outbox mail, with no error anywhere. Make sure exactly one executor exists.
Scale-from-zero orchestrator (orchestrator.*)
The container-orchestrator client the scale-from-zero wake path uses to raise a job's replica floor. The Manager is the only holder of orchestrator credentials; the proxy never sees them, it calls the Manager over the proxy-secret trust boundary.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_ORCHESTRATOR_KIND | noop | noop | nomad if you use scale-to-zero | Selects the implementation. noop performs no I/O: it records each ScaleTo in memory and returns success, so POST /api/proxy/wake answers 202 Accepted, the proxy waits out its hold loop, and no job is ever scaled. The Manager logs a WARNING at startup and lists the condition under warnings on /readyz for as long as it holds. nomad talks to the Nomad job-scaling API. Any other value is a fatal startup error. |
PFY_ORCHESTRATOR_NOMAD_ADDRESS | (empty) | recommended | Nomad HTTP API address. Empty falls back to the Nomad client default ($NOMAD_ADDR, else http://127.0.0.1:4646), which on a containerized Manager is the container's own loopback, so wakes fail at call time rather than at boot. | |
PFY_ORCHESTRATOR_NOMAD_TOKEN | (empty) | required with ACLs | Nomad ACL token (sent as the client SecretID). A secret. Empty is only viable on an ACL-less cluster. | |
PFY_ORCHESTRATOR_NOMAD_NAMESPACE | (empty) | optional | Nomad namespace. Empty uses the client default (default). | |
PFY_ORCHESTRATOR_PENDING_FLOOR_TTL | 3m | How long a wake's sticky floor is retained. The floor has no reader: it is an in-process store in the Manager whose intended consumer is the optimizer, a separate service, and no transport carries it there. Changing this value changes nothing an autoscaler can observe. See Known Issues. |
kind=noop is a working-looking no-op
Nothing validates that nomad is configured when scale-to-zero deployments exist. Leave PFY_ORCHESTRATOR_KIND at its noop default on an install that uses scale-to-zero and the symptom is not an error; it is applications that never come back from zero, with a healthy Manager and successful wake responses.
Proxy vhost sync (proxy_*)
The Manager can render the full Caddyfile from its own database and push it to the proxy's Caddy admin API. See Proxy Sync for the operational picture.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_PROXY_ADMIN_URL | (empty) | (empty) | optional | The proxy's Caddy admin API base URL, e.g. http://127.0.0.1:2019. Empty disables the sync loop entirely and is a supported way to run: the Manager pushes nothing and the proxy keeps whatever pfy proxy sync or the deployment artifacts last put there. The startup log and the dashboard both say SYNC OFF explicitly. The admin endpoint must be bound to an internal network; it applies arbitrary configuration to the proxy fronting every application and tenant. |
PFY_PROXY_PRELUDE_FILE | (empty) | required when PFY_PROXY_ADMIN_URL is set | Path to the operator-owned Caddyfile prelude (global options, the caddy-security portal, the Manager's own vhost) that pfy platform render produces from install.yaml. A push carries the whole Caddyfile (Caddy's /load replaces everything atomically), so without it the pushed config would have no portal, and a config whose vhosts name a policy nothing defines is one the proxy cannot run (see Proxy Sync). The Manager refuses to start if it is missing while sync is on. | |
PFY_PROXY_MANAGER_UPSTREAM | (empty) | required when PFY_PROXY_ADMIN_URL is set | The host:port the proxy rewrites each application's /esb/* and /pilet-feed/* integration routes to: the Manager itself, as the proxy addresses it (e.g. manager:8080). The Manager refuses to start without it while sync is on, because an empty value omits those routes from every rendered vhost: ESB and pilet delivery break install-wide while the config loads cleanly and looks correct. | |
PFY_PROXY_METRICS_URL | (empty) | recommended when sync is on | The proxy's metrics base URL, e.g. http://proxy:2112, from which the Manager reads GET /config-hash to learn the hash of the config the proxy is actually running. Without it the Manager records the hash it pushed as the applied hash and compares it with the hash it rendered, two numbers it computed itself, so drift detection agrees by construction and a proxy loaded out of band or restarted from a stale on-disk Caddyfile reads as converged. Optional so that installs predating it still boot; the dashboard then marks the drift verdict unverified. Must be an internal address, like the admin URL. | |
PFY_PROXY_ADMIN_AUTH_HEADER | (empty) | recommended | Header name authenticating the admin call (and the /config-hash read when PFY_PROXY_METRICS_URL is set), e.g. X-Admin-Auth. Empty disables admin authentication. Defence in depth only; it is not a substitute for binding :2019 internally. | |
PFY_PROXY_ADMIN_AUTH_TOKEN | (empty) | recommended | Value sent in that header. A secret. Ignored when the header name is empty. | |
PFY_PROXY_SYNC_DEBOUNCE | 2s | Quiet window a burst of mutations must clear before one push. Coalescing keeps one logical edit (a multi-field form, a stack apply) from reloading the proxy a dozen times. | ||
PFY_PROXY_SYNC_INTERVAL | 5m | Periodic resync cadence. It is the safety net, not the delivery mechanism: mutation hooks make a change land promptly; this repairs a push that lost the cross-instance lock, a write from another Manager process, or a proxy that restarted with a stale config. An unchanged config hash makes a tick a no-op. | ||
PFY_PROXY_FAULT_INJECT_FILE | (empty) | dev only | must be unset | Development-only test seam: while the named file exists, its contents are spliced into every rendered Caddyfile so the proxy refuses the push. Never set it in a deployed install; the symptom is not an outage but proxy config that has silently stopped tracking the Manager. Two gates keep it out: the Manager refuses to boot when env is not development and it is set, and the single read point returns empty outside development regardless. |
ESB request bounds (esb.*)
Bound the memory and time an Enterprise Service Bus request may consume; unbounded reads and a timeout-less client are a memory/goroutine DoS surface.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_ESB_MAX_REQUEST_BYTES | 10485760 (10 MiB) | Inbound request body cap. | ||
PFY_ESB_MAX_RESPONSE_BYTES | 10485760 (10 MiB) | Upstream response body cap. | ||
PFY_ESB_FORWARD_TIMEOUT | 30s | Outbound request timeout (Go duration). |
Pilets (pilets.*)
The Ed25519 provenance key the Manager signs pilet bundle bytes with at upload. The key id is carried in the feed so the frontend loader can resolve the matching public key from the trusted injection.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_PILETS_MAX_UPLOAD_BYTES | 52428800 (50 MiB) | Maximum pilet upload size. | ||
PFY_PILETS_SIGNING_PRIVATE_KEY | (empty) | optional | required to upload | Base64-std Ed25519 private key, either a 32-byte seed or a 64-byte full private key. Anything else is a fatal startup error. A secret. Unset in development derives a deterministic, visibly-fake dev--prefixed key so the signed-load path is exercisable locally. Unset in production yields a signer that cannot sign: the server still starts, and every pilet upload is rejected rather than storing an unsigned bundle the loader would later refuse. |
PFY_PILETS_SIGNING_KEY_ID | (empty) | optional | required to upload | Identifies the key for rotation. Required whenever a private key is set; a key with no id is a fatal startup error. A dev--prefixed id is refused in production, so the development key can never ship. |
PFY_PILETS_SIGNING_PREVIOUS_KEYS | (empty) | set when rotating | Public halves of previously-active signing keys, so bundles signed before a rotation stay verifiable. A list of objects, so in practice set it in the config file rather than the environment; see below. |
An unset pilet signing key fails on first upload, not at boot
In production the Manager starts normally with no signing key and only refuses when someone uploads a pilet. Set both variables at deploy time; the alternative is discovering it during a release. Generate a key with openssl rand -base64 32 and give it a non-dev- id such as prod-2026-01.
Pilet signing and key rotation
Pilet bundles are immutable and carry the signing_key_id they were signed with. The trust anchor injected into the browser is built from the active key plus every retired key listed here, so a bundle signed before a rotation still finds its key id in the anchor. Rotation is additive: list the retired keys' public halves and old bundles keep verifying.
pilets:
signing:
key_id: pilet-2026-07
private_key: "" # base64-std 32-byte seed or 64-byte key
previous_keys:
- key_id: pilet-2026-01
public_key: "" # base64-std raw 32-byte Ed25519 public key
- key_id: pilet-2025-06
revoked: true # compromised: never published, never trustedThere is nowhere in previous_keys to put a private key, deliberately: a retired key is published so old bundles keep verifying, and must never be able to sign a new one.
Retired and revoked are different states, and the distinction is load-bearing:
| State | Spelling | Effect |
|---|---|---|
| Retired (rotated out on schedule) | key_id + public_key | Published in the anchor and trusted for verification. Omit it and every bundle signed with that key is refused by the loader. |
| Revoked (compromised) | revoked: true | Neither published nor trusted, and its public_key is not even validated, so you can name a bad key without keeping usable material for it. Publishing it would put an anchor entry in every browser for a key an attacker holds. |
Startup refuses configurations that would silently produce one of those two failures: a previous_keys entry with no key_id, a duplicate key_id, an entry reusing the active key id (its public half would replace the active one, refusing every new bundle instead of every old one), a dev- prefixed retired id in production (the dev key derives from a public constant, so publishing it would trust anything), and a malformed or wrong-length public key.
Retired keys are registered even when the Manager holds no active signing key: an install that has stopped signing, for instance after pulling a compromised key, must still be able to serve the bundles it already holds, or pulling the key is itself the outage.
Audit external anchor (audit.*)
The out-of-band witness for the tamper-evident audit hash chain. The in-DB chain alone cannot detect a privileged raw-SQL actor who rewrites history and recomputes every row_hash forward, so the verified head hash is also signed (Ed25519) and POSTed to an append-only external endpoint.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_AUDIT_EXTERNAL_ANCHOR_ENDPOINT | (empty) | optional | strongly recommended | URL of the append-only witness the signed head hash is POSTed to. Validated at startup and re-validated at dial through an SSRF guard, so it may not resolve to an internal address. |
PFY_AUDIT_EXTERNAL_ANCHOR_SIGNING_KEY | (empty) | optional | strongly recommended | Base64-std Ed25519 key (32-byte seed or 64-byte private key) the attestation is signed with. A secret. |
PFY_AUDIT_EXTERNAL_ANCHOR_KEY_ID | (empty) | optional | optional | Key id carried in the attestation so the witness operator can verify offline against the published public key. Not required, but rotation is unmanageable without it. |
It fails open, deliberately, on the witness, never on evidence. Leaving both the endpoint and the key empty disables external anchoring cleanly, and the Manager logs a WARNING at every startup saying the chain is tamper-evident only against partial tampering. Setting one of the two is a fatal startup error (a signature with nowhere to land, or an endpoint with nothing authenticating the record, is a misconfiguration, not a partial rollout).
Communications abuse ceilings (comms.*)
Bounds on things a valid session could otherwise do without limit. The defaults sit well above legitimate traffic rather than being tuned to it, so raising them should be a deliberate, measured decision.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_COMMS_WS_MAX_CONNS_PER_USER | 8 | Concurrent websockets one user may hold. Each connection costs goroutines, bus subscriptions and two queries on connect; a buggy client reconnect loop reaches an unbounded count by accident. | ||
PFY_COMMS_WS_MAX_CONNS | 5000 | Process-wide websocket ceiling behind the per-user cap. | ||
PFY_COMMS_WS_INBOUND_FRAMES_PER_SECOND | 20 | Sustained client frame rate metered per connection. | ||
PFY_COMMS_WS_INBOUND_FRAME_BURST | 96 | Burst allowance for the frame meter. It sits above the subscription cap on purpose, so a full resubscribe after a reconnect can never be what trips the meter. Lowering it below the subscription cap will break reconnects. | ||
PFY_COMMS_WS_SUBSCRIBE_CHURN_PER_SECOND | 64 | Sustained subscribe/unsubscribe rate, counted per topic per connection. | ||
PFY_COMMS_WS_SUBSCRIBE_CHURN_BURST | 192 | Burst allowance for the churn meter; same reconnect caveat as the frame burst. | ||
PFY_COMMS_EMAIL_FROM_DOMAINS | (unset) | optional | optional | Comma-separated allowlist of domains a tenant admin may set communications.email.from to, enforced at write time. Unset means "no opinion": any parseable bare address is accepted. Matching is exact and case-insensitive: acme.com admits x@acme.com and refuses x@mail.acme.com, because SPF/DKIM alignment is per-domain and suffix matching would quietly admit notacme.com. List subdomains explicitly. |
Logger (logger.*)
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_LOGGER_LEVEL | info | Log level (debug, info, warn, error, …). |
Invitations & email (invite.*, smtp.*)
Delivery for the tenant-invite flow. When PFY_SMTP_HOST is empty, no mail is sent; the Manager falls back to logging the plaintext invite link (and the mutation returns the token so the UI can surface it).
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_INVITE_BASE_URL | (empty) | optional | recommended | Public invite-link prefix the email/plaintext-fallback links point at: the proxy-fronted OIDC entry, e.g. https://portal.example.com/invite. |
PFY_SMTP_HOST | (empty) | optional | recommended | Outbound SMTP host. Empty ⇒ invite mail is not sent (plaintext-link fallback). |
PFY_SMTP_PORT | 587 | SMTP port. | ||
PFY_SMTP_FROM | (empty) | From address for invite mail. | ||
PFY_SMTP_USERNAME | (empty) | SMTP auth username. | ||
PFY_SMTP_PASSWORD | (empty) | SMTP auth password. A secret; keep it out of committed files. | ||
PFY_SMTP_DIAL_TIMEOUT | 10s | Bounds the TCP/TLS connect to the SMTP host. | ||
PFY_SMTP_SEND_TIMEOUT | 30s | Bounds the whole SMTP conversation. This is the one that saves the caller from a peer that connects and then goes silent. Delivery runs on a scheduled worker with no client to give up and disconnect, so an unbounded send is an unbounded stall; do not set either of these to 0. |
Migration tooling
Read directly from the environment by manager -migrate (it is not a fig setting, so it has no YAML equivalent).
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_MIGRATIONS_DIR | (the embedded migrations) | Applies an on-disk migration directory instead of the one compiled into the binary. It must hold the versioned SQL and its atlas.sum, and is read through the same code path, checksum verification included. Set it when you hold a newer migration set than the image. |
manager -migrate applies the migrations through the Atlas Go SDK, from SQL compiled into the binary. It needs no atlas executable, no mounted directory and no checkout, so the published ghcr.io/productifyfw/manager image migrates its own database. PFY_ATLAS_BIN is not read on this path; setting it logs a notice and changes nothing.
The migration set is verified against its atlas.sum before anything is applied, and the revision history is the same table the Atlas CLI writes, so a database migrated by either can be continued by the other. Concurrent manager -migrate runs are serialised by a Postgres advisory lock, which is what makes several init containers on one rollout safe. That lock does not serialise against the Atlas CLI, so do not run both at once.
Proxy
The proxy is a Caddy build with the productify plugin. Its settings live in the Caddyfile global block; a few are wired to the environment. Caddy resolves {$VAR} at load time and {env.VAR} at request time.
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_MANAGER_TOKEN | (none, required) | required | required | The proxy's token in the productify block: the shared secret it sends to the Manager as X-Proxy-Auth, and the key it derives the tenant-cookie signing key from. Must equal the Manager's PFY_PROXY_SECRET. A secret. |
PFY_OAUTH_CLIENT_SECRET | (none, required) | required | required | OAuth client secret for the caddy-security to Pocket ID (OIDC) handshake. A secret. |
PFY_ADMIN_LISTEN | 127.0.0.1:2019 | keep loopback | internal only | Address of Caddy's admin API (:2019/load), which the Manager's config renderer POSTs rendered proxy config to for a graceful reload. Never expose :2019 publicly: bind it to an internal interface reachable only by the Manager, and firewall/authenticate that hop. |
PFY_METRICS_LISTEN | 127.0.0.1:2112 | keep loopback | internal only | Address of the proxy's Prometheus /metrics server, which also carries /healthz, /readyz and the /config-hash drift endpoint the Manager reads to learn what config the proxy is actually running. Wired to metrics_address in the Caddyfile's productify block; without that directive the plugin defaults to :2112 on every interface. Prometheus must scrape it over an internal network. |
Both addresses are read by the Caddyfile, not by the container image: admin {$PFY_ADMIN_LISTEN:127.0.0.1:2019} and metrics_address {$PFY_METRICS_LISTEN:127.0.0.1:2112}. The prelude pfy platform render produces and the compose install bundle's own Caddyfile both use that form, so an unconfigured proxy falls back to loopback and fails closed. The bundle's compose file sets each variable to every interface and publishes only 80 and 443, so both endpoints stay on the compose network. That compose file also exports MANAGER_DOMAIN, ID_DOMAIN, ACME_EMAIL, OIDC_CLIENT_SECRET and CADDY_JWT_SHARED_KEY, which its Caddyfile dereferences.
Session lifetime / idle policy
The caddy-security authentication portal (pocketportal) sets the portal session lifetime in the global security block:
crypto default token lifetime 3600token lifetime 3600 is an absolute portal-session lifetime of 3600 seconds (1 hour); there is no separate idle timeout, so after an hour the user is re-challenged through Pocket ID regardless of activity. Tune this per environment (shorter for higher-sensitivity deployments). Pocket ID owns its own upstream session/idle policy independently; this token only bounds the proxy-portal session. Logout invalidates it immediately, ahead of expiry.
See Session policy & logout for the full policy and the standardized logout contract.
Pocket ID
Pocket ID is the platform's identity provider. It is configured in two places:
- Proxy to Pocket ID (OIDC login): caddy-security authenticates users against Pocket ID via OIDC using
PFY_OAUTH_CLIENT_SECRETand the OIDC discovery/base URLs in the Caddyfileoauth identity provider genericblock (base_auth_url/metadata_url, e.g.http://pocketid.localhost). - Manager to Pocket ID (admin API): the Manager resolves/searches users via the Pocket ID admin API using
PFY_POCKET_ID_HOSTandPFY_POCKET_ID_API_KEY(sent asX-API-Key).
Pocket ID is read-only from the Manager
The Manager's Pocket ID client only reads (GET /api/users). It does not create Pocket ID accounts or manage sessions; Pocket ID owns identity and session lifecycle. See Session policy & logout.
Minimum secure production config
The bare minimum to run a hardened production deployment. The Manager enforces the starred (*) items at startup and refuses to boot without them; the rest are operational must-dos.
- [ ] Proxy secret set on both sides.
PFY_PROXY_SECRET(Manager) is set and equals the proxy'sPFY_MANAGER_TOKEN. An API-serving process refuses to start without it; a mismatch is not caught at startup and rejects every request. * - [ ] Dev-user override off.
PFY_DEV_USER_OVERRIDE_ENABLEDis unset/false (andPFY_ENV=production, which alone makes the override a fatal error if enabled). * - [ ] Non-default database password.
PFY_DB_PASSWORDis set to a strong, non-postgresvalue. * - [ ] TLS to the database.
PFY_DB_SSLMODEisrequire(or stronger,verify-ca/verify-full);disableis rejected in production. * - [ ] At-rest encryption key.
PFY_ENCRYPTION_KEYis a valid base64 32-byte key, so endpoint/backend secrets are encrypted at rest. * - [ ] Pocket ID API key.
PFY_POCKET_ID_API_KEYis set. * - [ ]
PFY_ENV=production. Disables the/gqlplayground, the dev reverse proxy, and the override escape hatch. * - [ ] Admin API not public.
PFY_ADMIN_LISTENbinds an internal interface only;:2019is never reachable from the internet. On Nomad the rendered job binds the node's own allocated address; setproxy_admin_bindininstall.yamlif that address is itself public (a single-NIC cloud host). - [ ] Metrics/probe server not public.
PFY_METRICS_LISTENbinds an internal interface only. It carries/metrics,/healthz,/readyzand/config-hash; the plugin's own default is every interface. - [ ] Session lifetime reviewed.
token lifetimein the Caddyfile is tuned for the deployment's sensitivity. - [ ] Secrets are secrets.
PFY_PROXY_SECRET,PFY_MANAGER_TOKEN,PFY_OAUTH_CLIENT_SECRET,PFY_ENCRYPTION_KEY,PFY_POCKET_ID_API_KEY,PFY_PILETS_SIGNING_PRIVATE_KEY,PFY_AUDIT_EXTERNAL_ANCHOR_SIGNING_KEY,PFY_ORCHESTRATOR_NOMAD_TOKEN,PFY_PROXY_ADMIN_AUTH_TOKENandPFY_DB_PASSWORDcome from a secret store, never from committed files.
None of the following is enforced at startup, and each is silently wrong when skipped:
- [ ] Pilet signing key set.
PFY_PILETS_SIGNING_PRIVATE_KEYandPFY_PILETS_SIGNING_KEY_ID(non-dev-id), or pilet uploads will be rejected the first time someone tries one. - [ ] Orchestrator selected.
PFY_ORCHESTRATOR_KIND=nomadplusPFY_ORCHESTRATOR_NOMAD_ADDRESS(and a token, on an ACL-enabled cluster) if any deployment uses scale-to-zero. Thenoopdefault answers wakes successfully and scales nothing. - [ ] Exactly one executor. At least one instance runs with
PFY_RUN_MODE=executororboth, or no audit sweep, credential-staleness report or outbox delivery ever runs. - [ ] Audit witness configured.
PFY_AUDIT_EXTERNAL_ANCHOR_ENDPOINTandPFY_AUDIT_EXTERNAL_ANCHOR_SIGNING_KEYtogether, or accept that the audit chain is not evidence against a privileged database rewrite. - [ ] Proxy sync is either on and complete, or off. If
PFY_PROXY_ADMIN_URLis set,PFY_PROXY_PRELUDE_FILEandPFY_PROXY_MANAGER_UPSTREAMmust be too (the Manager enforces this one).PFY_PROXY_FAULT_INJECT_FILEmust be unset. * - [ ] Audit retention reviewed.
PFY_CRON_AUDIT_RETENTION_DEFAULTandPFY_CRON_AUDIT_RETENTION_VIEWmatch your compliance retention before the first sweep runs; the sweep deletes rows.
Drift check
scripts/check-config-drift.mjs in the docs repository parses the Manager's config struct (manager/internal/config/config.go) and fails if any environment variable it reads is missing from this page. Run it with the manager repo checked out beside docs:
cd docs
node scripts/check-config-drift.mjs ../manager/internal/config/config.goThe docs CI workflow runs the same check when it has a token that can read the Manager repository.