Skip to content

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:

  1. The file named by PFY_CONFIG_PATH, if set. Naming a file that does not exist is an error.
  2. Otherwise config.local.yml (gitignored; copy it from config.example.yml).
  3. 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 default

The 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 ​

yaml
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: test

Settings ​

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 ​

KeyDefaultDescription
envproductiondevelopment 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_modebothapi, executor or both. See Run modes.
port8080API server port. Must differ from health_port.
health_port8081Serves /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/frontendDirectory the built UI is served from in production.
proxy_targethttp://localhost:5173Where unknown paths are proxied in development (the Vite dev server).
logger.levelinfoLog level for the Manager's own logger (debug, info, warn, error). An unknown value is a fatal startup error.

Security and secrets ​

KeyDefaultProdDescription
proxy_secret(empty)required when run_mode is api or bothThe 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)requiredBase64 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.*) ​

KeyDefaultProdDescription
db.hostlocalhostHost.
db.port5432Port.
db.userpostgresUser.
db.password(none)requiredNo default on purpose, so a deployment cannot ship the well-known postgres credential by accident.
db.namepostgresDatabase name. The key is name, not dbname.
db.sslmoderequiredisable is rejecteddisable, require, verify-ca or verify-full.
db.maxopen1000Maximum open connections.
db.maxidle100Maximum idle connections.
db.maxlifetime1hMaximum connection lifetime (Go duration).

Pocket ID (pocket_id.*) ​

KeyDefaultProdDescription
pocket_id.hosthttp://pocketid.localhostBase 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)requiredPocket 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.

KeyDefaultDescription
cron.trigger_check_interval30sHow 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_timeout1mA 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_port9090Prometheus /metrics listener for the executor (pfy_executor_* metrics).
cron.audit_anchor_tail_window24hHow far back each audit-chain verification re-checks.
cron.audit_retention_default8760h (1 year)Retention for audit rows; older rows are deleted by the sweep.
cron.audit_retention_view2160h (90 d)Shorter retention for view audit events.
cron.credential_rotation_max_age2160h (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_retention168h (7 d)How long a failed pending trigger run is kept. 0 disables the sweep.
cron.comms_outbox_interval1mHow often queued notification emails are delivered.
cron.comms_outbox_timeout5mTime 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.*) ​

KeyDefaultDescription
dev_user_override.enabledfalseTreat 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.

KeyDefaultDescription
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_debounce2sQuiet period after a burst of changes before one push.
proxy_sync_interval5mPeriodic 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.*) ​

KeyDefaultProdDescription
pilets.max_upload_bytes52428800 (50 MiB)Maximum pilet upload size.
pilets.signing.key_id(empty)required to uploadIdentifier 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 uploadBase64 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.*) ​

KeyDefaultDescription
orchestrator.kindnoopnoop or nomad. With noop, POST /api/proxy/wake accepts requests but scales nothing; /readyz and the startup log carry a warning.
orchestrator.pending_floor_ttl3mHow 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 ​

KeyDefaultDescription
esb.max_request_bytes10485760 (10 MiB)ESB inbound body cap.
esb.max_response_bytes10485760 (10 MiB)ESB upstream response cap.
esb.forward_timeout30sESB 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) / 587Outbound 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_timeout10s / 30sConnect and whole-conversation limits.
comms.ws_max_conns_per_user8Websocket ceiling per user.
comms.ws_max_conns5000Websocket ceiling per process.
comms.ws_inbound_frames_per_second / comms.ws_inbound_frame_burst20 / 96Inbound frame rate limit per connection.
comms.ws_subscribe_churn_per_second / comms.ws_subscribe_churn_burst64 / 192Subscribe/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.

yaml
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.com
bash
export 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-01

Production, executor process ​

yaml
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: 9090
bash
export PFY_DB_PASSWORD=...
export PFY_POCKET_ID_API_KEY=...     # required on the executor too
export PFY_ENCRYPTION_KEY=...        # required on the executor too

The 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.

bash
curl http://executor-host:9090/metrics

Security checklist ​

  1. Never commit config.local.yml.
  2. Pass secrets as PFY_* variables; the YAML has no ${VAR} substitution.
  3. Use sslmode: verify-full in production; disable is refused.
  4. Leave dev_user_override off outside development; the Manager refuses to start otherwise.
  5. Set proxy_secret to the proxy's PFY_MANAGER_TOKEN. In production the API refuses to start without it; in development every request is rejected with 401 until it is set or the dev override is on.
  6. Set encryption_key and back it up. Rotating it makes existing ciphertext undecryptable.
  7. Set audit.external_anchor_* if you need the audit log to be tamper-evident against someone with direct database access.
  8. Size db.maxopen to your database.