Configuration
The Manager reads one YAML file and lets any PFY_* environment variable override it. Below: where the file is looked up, what every key does, the defaults, and what production refuses to start without.
Two pages cover configuration
The keys here are grouped by what you are trying to do. The Configuration Reference covers the same keys from the deployment side, with security notes for each and the matching proxy and Pocket ID settings.
How the configuration is loaded
The Manager picks one file:
- The file named by
PFY_CONFIG_PATH, if set. Naming a file that does not exist is an error. - Otherwise
config.local.yml(gitignored; copy it fromconfig.example.yml). - Otherwise
config.yml.
Relative file names are searched in ., ../ and ../../, so a binary started from cmd/server still finds the repository-root file. If no file exists at all, that is not an error: the Manager runs on environment variables and built-in defaults, and logs No config.local.yml or config.yml found. That is how the published container image runs: it ships no config file. A file that exists but does not parse is fatal.
Environment variables are applied on top of whichever file was read:
PFY_* environment variable > config file > built-in defaultThe variable name is PFY_ plus the YAML path in upper case, with _ between nested keys: pocket_id.api_key becomes PFY_POCKET_ID_API_KEY, dev_user_override.enabled becomes PFY_DEV_USER_OVERRIDE_ENABLED. List values that are plain strings (comms.email_from_domains) are comma-separated in the environment; a list of objects (pilets.signing.previous_keys) can only be set in the file.
YAML is not interpolated
password: ${DB_PASSWORD} sets the password to that literal string. Pass secrets as PFY_* variables instead.
Unknown keys are dropped silently
The loader ignores keys it does not recognise, without a warning. dbname: instead of name: under db leaves the Manager on the default postgres database while /healthz still says healthy.
A complete development file
env: development # development or production; the default is production
run_mode: both # api, executor, or both
port: 8080 # API server
health_port: 8081 # /healthz and /readyz
frontend_dir: ../frontend/dist # built UI, served in production
proxy_target: http://frontend:5173 # Vite dev server, used in development only
db:
host: localhost
port: 5432
user: postgres
password: password
name: productify
sslmode: disable
maxopen: 25
maxidle: 10
maxlifetime: 5m
pocket_id:
host: http://pocketid.localhost
api_key: your-api-key
cron:
backend_heartbeat_timeout: 1m
trigger_check_interval: 1s
dev_user_override:
enabled: true
id: ca53cb60-e72b-47b5-a7a3-9fa48dda681c
email: user@productify.dev
name: Test User
username: testSettings
Defaults below are the built-in values the Manager uses when a key is absent. "Prod" says what production (env: production) requires at startup.
Server
| Key | Default | Description |
|---|---|---|
env | production | development or production, nothing else. Production turns off the /gql playground, the dev proxy to the frontend and the dev-user override, and requires a DB password, a Pocket ID API key, an encryption key and (when the API is served) a proxy secret. |
run_mode | both | api, executor or both. See Run modes. |
port | 8080 | API server port. Must differ from health_port. |
health_port | 8081 | Serves /healthz (liveness, always 200) and /readyz (readiness). Separate listener, no authentication. The API port also serves GET/HEAD /health, which reuses the same readiness verdict: ok/unavailable for an anonymous caller, the full /readyz body for one presenting X-Proxy-Auth. |
frontend_dir | /app/frontend | Directory the built UI is served from in production. |
proxy_target | http://localhost:5173 | Where unknown paths are proxied in development (the Vite dev server). |
logger.level | info | Log level for the Manager's own logger (debug, info, warn, error). An unknown value is a fatal startup error. |
Security and secrets
| Key | Default | Prod | Description |
|---|---|---|---|
proxy_secret | (empty) | required when run_mode is api or both | The value the proxy sends as X-Proxy-Auth; must equal the proxy's PFY_MANAGER_TOKEN. Every user-facing route checks it before looking at any credential. Empty means every request to /query, /pilets, /api/audit, /api/access and /api/proxy/* is answered 401 with an error body that names this setting, unless dev_user_override is active. |
encryption_key | (empty) | required | Base64 32-byte AES-256 key for secret columns (ESB endpoint credentials, backend callback tokens). Empty in development stores them in plaintext with a warning. Generate one with openssl rand -base64 32; losing it makes existing ciphertext unreadable. |
Database (db.*)
| Key | Default | Prod | Description |
|---|---|---|---|
db.host | localhost | Host. | |
db.port | 5432 | Port. | |
db.user | postgres | User. | |
db.password | (none) | required | No default on purpose, so a deployment cannot ship the well-known postgres credential by accident. |
db.name | postgres | Database name. The key is name, not dbname. | |
db.sslmode | require | disable is rejected | disable, require, verify-ca or verify-full. |
db.maxopen | 1000 | Maximum open connections. | |
db.maxidle | 100 | Maximum idle connections. | |
db.maxlifetime | 1h | Maximum connection lifetime (Go duration). |
Pocket ID (pocket_id.*)
| Key | Default | Prod | Description |
|---|---|---|---|
pocket_id.host | http://pocketid.localhost | Base URL of the Pocket ID instance. The Manager calls it to search users and to fill in usernames for users it sees for the first time. | |
pocket_id.api_key | (empty) | required | Pocket ID admin API key. Required in every production process, including a pure executor. |
Trigger executor and sweeps (cron.*)
Everything in this group runs only in a process whose run_mode is executor or both.
| Key | Default | Description |
|---|---|---|
cron.trigger_check_interval | 30s | How often the executor evaluates trigger schedules. Each pass covers the whole window since the previous pass, so a 30 s interval does not miss per-minute schedules; set 1s for per-second triggers. |
cron.backend_heartbeat_timeout | 1m | A registered backend whose last heartbeat is older than this is marked inactive and stops receiving triggers. Also the cadence of the maintenance sweeps below. |
cron.metrics_port | 9090 | Prometheus /metrics listener for the executor (pfy_executor_* metrics). |
cron.audit_anchor_tail_window | 24h | How far back each audit-chain verification re-checks. |
cron.audit_retention_default | 8760h (1 year) | Retention for audit rows; older rows are deleted by the sweep. |
cron.audit_retention_view | 2160h (90 d) | Shorter retention for view audit events. |
cron.credential_rotation_max_age | 2160h (90 d) | The age at which a machine-user or backend token must be rotated. The sweep reports credentials past it on the pfy_executor_stale_credentials gauge and in one audit row per lapse; it rotates nothing. 0 disables the sweep. |
cron.failed_trigger_run_retention | 168h (7 d) | How long a failed pending trigger run is kept. 0 disables the sweep. |
cron.comms_outbox_interval | 1m | How often queued notification emails are delivered. |
cron.comms_outbox_timeout | 5m | Time limit for one delivery pass. |
Executor metrics
A process running the executor serves Prometheus metrics at http://<host>:<metrics_port>/metrics. They cover trigger queues per application, dispatch outcomes and latency, proxy-sync pushes and audit-chain verification failures. See Trigger System.
Development override (dev_user_override.*)
| Key | Default | Description |
|---|---|---|
dev_user_override.enabled | false | Treat every request as the user below. Only honoured when env is development; the Manager refuses to start if it is enabled anywhere else. |
dev_user_override.id | (empty) | User ID (UUID). |
dev_user_override.email | (empty) | Email. |
dev_user_override.name | (empty) | Display name. |
dev_user_override.username | (empty) | Read into the config but currently not used: the username is fetched from Pocket ID when the user row is created. See the Quick Start for the effect and the workaround. |
Development only
dev_user_override bypasses authentication. It is also the only supported way to run the Manager with no proxy in front of it: when it is active, the proxy-secret gate is skipped.
Proxy vhost sync (proxy_*)
Off by default. Set proxy_admin_url to make the Manager render and push the proxy's Caddyfile itself; the startup log and the dashboard say SYNC OFF while it is unset.
| Key | Default | Description |
|---|---|---|
proxy_admin_url | (empty) | Caddy admin API of the proxy, e.g. http://proxy:2019. Empty disables sync. Bind it to an internal network. |
proxy_prelude_file | (empty) | Operator-owned Caddyfile prelude from pfy platform render. Required when sync is on; without it a push would remove the login portal. |
proxy_manager_upstream | (empty) | host:port of the Manager as the proxy reaches it (e.g. manager:8080). Required when sync is on. |
proxy_metrics_url | (empty) | The proxy's metrics listener (e.g. http://proxy:2112; :2112 is the plugin's default metrics_address, not the admin port). The Manager reads GET /config-hash from it to verify what the proxy is really running; without it drift is reported as unverified. Pointing this at an address nothing serves does not fail loudly: the read simply never succeeds and the verdict stays unverified, which is the state the setting exists to escape. |
proxy_admin_auth_header | (empty) | Header name used to authenticate admin and metrics calls. Empty sends none. |
proxy_admin_auth_token | (empty) | Value for that header. |
proxy_sync_debounce | 2s | Quiet period after a burst of changes before one push. |
proxy_sync_interval | 5m | Periodic resync. Runs in every run mode, not only the executor. |
proxy_fault_inject_file | (empty) | Development-only test hook. Setting it outside env: development is a fatal error. |
Pilets (pilets.*)
| Key | Default | Prod | Description |
|---|---|---|---|
pilets.max_upload_bytes | 52428800 (50 MiB) | Maximum pilet upload size. | |
pilets.signing.key_id | (empty) | required to upload | Identifier of the active signing key; carried in the feed as keyId. Required whenever a private key is set; a dev- prefixed id is refused in production. |
pilets.signing.private_key | (empty) | required to upload | Base64 Ed25519 key (32-byte seed or 64-byte key). In development an unset key derives a fake dev- key; in production uploads are rejected until it is set. |
pilets.signing.previous_keys | (empty) | List of {key_id, public_key, revoked}. Retired keys keep old bundles loadable; revoked: true stops a key from ever verifying. File-only. |
Scale-from-zero orchestrator (orchestrator.*)
| Key | Default | Description |
|---|---|---|
orchestrator.kind | noop | noop or nomad. With noop, POST /api/proxy/wake accepts requests but scales nothing; /readyz and the startup log carry a warning. |
orchestrator.pending_floor_ttl | 3m | How long a wake keeps a replica floor of 1 against the autoscaler. |
orchestrator.nomad.address | (empty) | Nomad API address. |
orchestrator.nomad.token | (empty) | Nomad ACL token. |
orchestrator.nomad.namespace | (empty) | Nomad namespace. |
Other groups
| Key | Default | Description |
|---|---|---|
esb.max_request_bytes | 10485760 (10 MiB) | ESB inbound body cap. |
esb.max_response_bytes | 10485760 (10 MiB) | ESB upstream response cap. |
esb.forward_timeout | 30s | ESB outbound request timeout. |
audit.external_anchor_endpoint | (empty) | URL the signed audit-chain head is POSTed to. Both this and the signing key must be set to enable external anchoring. |
audit.external_anchor_signing_key | (empty) | Base64 Ed25519 key for the anchor. |
audit.external_anchor_key_id | (empty) | Key id carried with the anchor. |
invite.base_url | (empty) | Public prefix of tenant invite links. |
smtp.host / smtp.port | (empty) / 587 | Outbound mail. Empty host means invite links are logged instead of emailed. |
smtp.from, smtp.username, smtp.password | (empty) | Sender and credentials. |
smtp.dial_timeout / smtp.send_timeout | 10s / 30s | Connect and whole-conversation limits. |
comms.ws_max_conns_per_user | 8 | Websocket ceiling per user. |
comms.ws_max_conns | 5000 | Websocket ceiling per process. |
comms.ws_inbound_frames_per_second / comms.ws_inbound_frame_burst | 20 / 96 | Inbound frame rate limit per connection. |
comms.ws_subscribe_churn_per_second / comms.ws_subscribe_churn_burst | 64 / 192 | Subscribe/unsubscribe rate limit per connection. |
comms.email_from_domains | (unset) | Optional allowlist of sender domains tenant admins may use. Exact, case-insensitive match. |
One environment-only variable is read by manager -migrate, not from the config file: PFY_MIGRATIONS_DIR, which replaces the migrations compiled into the binary with an on-disk directory. PFY_ATLAS_BIN is not read on this path; setting it logs a notice and changes nothing.
Run modes
both (default)
API server and trigger executor in one process. Use it for development and small installs.
api
API server only. Use it to scale the API horizontally.
An install made only of api processes never runs a sweep
Trigger execution, backend heartbeat cleanup, invite and CLI-login expiry, audit-chain verification, audit retention, credential-staleness reporting, PAT expiry auditing and notification email delivery all live in the executor. If no process runs with executor or both, none of them runs and nothing reports it. Only the proxy-sync resync runs in every mode.
executor
Executor only, no HTTP API (the health listener and the metrics listener still start). Use it for a dedicated scheduling process. Several executors share one lease, so only one processes triggers at a time.
Examples
Development
The file in A complete development file is enough. dev_user_override is what lets you run without a proxy.
Production, API process
Secrets go in the environment, never in the file.
env: production
run_mode: api
port: 8080
db:
host: db.prod.internal
port: 5432
user: manager_api
name: productify
sslmode: verify-full
maxopen: 50
pocket_id:
host: https://auth.company.comexport PFY_DB_PASSWORD=... # required
export PFY_POCKET_ID_API_KEY=... # required
export PFY_ENCRYPTION_KEY=... # required (base64, 32 bytes)
export PFY_PROXY_SECRET=... # required when the API is served; == the proxy's PFY_MANAGER_TOKEN
export PFY_PILETS_SIGNING_PRIVATE_KEY=...
export PFY_PILETS_SIGNING_KEY_ID=prod-2026-01Production, executor process
env: production
run_mode: executor
db:
host: db.prod.internal
port: 5432
user: manager_executor
name: productify
sslmode: verify-full
cron:
backend_heartbeat_timeout: 2m
trigger_check_interval: 1s
metrics_port: 9090export PFY_DB_PASSWORD=...
export PFY_POCKET_ID_API_KEY=... # required on the executor too
export PFY_ENCRYPTION_KEY=... # required on the executor tooThe production checks run in every process
Validation runs before anything else, regardless of run_mode. An executor missing a key exits with pocket_id.api_key is required in production (set PFY_POCKET_ID_API_KEY) or PFY_ENCRYPTION_KEY is required in production, even though it never calls Pocket ID. Only proxy_secret is skipped for a pure executor.
curl http://executor-host:9090/metricsSecurity checklist
- Never commit
config.local.yml. - Pass secrets as
PFY_*variables; the YAML has no${VAR}substitution. - Use
sslmode: verify-fullin production;disableis refused. - Leave
dev_user_overrideoff outside development; the Manager refuses to start otherwise. - Set
proxy_secretto the proxy'sPFY_MANAGER_TOKEN. In production the API refuses to start without it; in development every request is rejected with401until it is set or the dev override is on. - Set
encryption_keyand back it up. Rotating it makes existing ciphertext undecryptable. - Set
audit.external_anchor_*if you need the audit log to be tamper-evident against someone with direct database access. - Size
db.maxopento your database.
Related
- Quick Start: running the Manager locally
- Configuration Reference: the deployment-side view of these settings, plus proxy and Pocket ID
- Manager Deployment and Nomad: production deployment