Configuration Reference
This page lists 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.
Drift-tested
The Manager half of this page is verified in CI against manager/internal/config/config.go: if a new setting lands in the Manager without a row here, the build fails. See Keeping this page honest.
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_ — e.g. the YAML key db.host is PFY_DB_HOST.
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.
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 (/health). 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 | The shared secret the trusted proxy sends as X-Proxy-Auth on every forwarded request. It establishes the proxy↔manager trust boundary — without it the Manager would trust identity headers (X-Token-*) from anyone who can reach its port. Empty means "no secret configured": outside the dev-user-override escape hatch the Manager fails closed (rejects every request). 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. |
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. |
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.*)
| Variable | Default | Dev | Prod | Notes |
|---|---|---|---|---|
PFY_PILETS_MAX_UPLOAD_BYTES | 52428800 (50 MiB) | — | — | Maximum pilet upload size. |
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. |
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 ↔ 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. |
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 → 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 → 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. Without it the Manager fails closed. * - [ ] 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. - [ ] 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, andPFY_DB_PASSWORDcome from a secret store, never from committed files.
Keeping this page honest
A drift guard parses the Manager's config struct and fails if any environment variable it reads is missing from this page.
Run it locally (with the manager repo checked out beside docs):
cd docs
node scripts/check-config-drift.mjs
# or point it explicitly:
node scripts/check-config-drift.mjs ../manager/internal/config/config.goIt derives every PFY_* variable from the fig struct tags (plus any os.Getenv reads) and checks each appears in this file. In CI the config-drift workflow checks out both repos and runs it on every change.