Skip to content

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:

VariableWhat an unset value actually does
PFY_PROXY_SECRETIn 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_KEYIn production: refuses to start (loud, correct). In development: stores endpoint/backend secrets in plaintext with a single warning line.
PFY_PROXY_MANAGER_UPSTREAMWith 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_KINDDefaults 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_IDWith 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 ​

VariableDefaultDevProdNotes
PFY_ENVproductiondevelopmentproductionSecure 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_MODEbothanyapi, executor, or bothWhich halves of the Manager run in this process: the API server, the trigger executor, or both.
PFY_PORT8080Main HTTP/API port. Must differ from PFY_HEALTH_PORT; range 1–65535.
PFY_HEALTH_PORT8081Health-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/frontendDirectory the Manager serves the bundled frontend from.
PFY_PROXY_TARGEThttp://localhost:5173Dev-only reverse-proxy target for the Vite frontend dev server; used only when IsDev().
PFY_CONFIG_PATH(unset)optionaloptionalOverrides which config file is loaded, ahead of the config.local.yml → config.yml search.

Security & secrets ​

VariableDefaultDevProdNotes
PFY_PROXY_SECRET(empty)recommendedrequired when the API is servedThe 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)requiredBase64-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.*) ​

VariableDefaultDevProdNotes
PFY_DB_HOSTlocalhostPostgreSQL host.
PFY_DB_PORT5432PostgreSQL port; range 1–65535.
PFY_DB_USERpostgresPostgreSQL user.
PFY_DB_PASSWORD(no default)may be emptyrequiredHas 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_NAMEpostgresDatabase name.
PFY_DB_SSLMODErequiredisable (opt-in)require or strongerSecure by default. disable is rejected in production; local development opts out explicitly.
PFY_DB_MAXIDLE100Max idle connections in the pool.
PFY_DB_MAXOPEN1000Max open connections in the pool.
PFY_DB_MAXLIFETIME1hMax 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.

VariableDefaultDevProdNotes
PFY_DEV_USER_OVERRIDE_ENABLEDfalseopt-in onlymust be offMaster switch. Setting it outside env=development is a fatal startup error.
PFY_DEV_USER_OVERRIDE_ID(empty)dev onlyFixed user ID assigned to unauthenticated requests.
PFY_DEV_USER_OVERRIDE_EMAIL(empty)dev onlyFixed email.
PFY_DEV_USER_OVERRIDE_NAME(empty)dev onlyFixed display name.
PFY_DEV_USER_OVERRIDE_USERNAME(empty)dev onlyFixed 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.

VariableDefaultDevProdNotes
PFY_POCKET_ID_HOSThttp://pocketid.localhostBase URL of the Pocket ID instance.
PFY_POCKET_ID_API_KEY(empty)may be emptyrequiredSent 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.*) ​

VariableDefaultDevProdNotes
PFY_CRON_BACKEND_HEARTBEAT_TIMEOUT1mHow long a registered backend may go without a heartbeat before it is considered down (Go duration).
PFY_CRON_TRIGGER_CHECK_INTERVAL30sHow often the executor scans for triggers to fire (Go duration).
PFY_CRON_METRICS_PORT9090Prometheus metrics port for the executor; range 1–65535.
PFY_CRON_AUDIT_ANCHOR_TAIL_WINDOW24hHow 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_DEFAULT8760h (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_VIEW2160h (90 days)Shorter retention for high-volume, low-value view events. Also deletes data.
PFY_CRON_CREDENTIAL_ROTATION_MAX_AGE2160h (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_RETENTION168h (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_INTERVAL1mHow 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_TIMEOUT5mBounds 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.

VariableDefaultDevProdNotes
PFY_ORCHESTRATOR_KINDnoopnoopnomad if you use scale-to-zeroSelects 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)recommendedNomad 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 ACLsNomad ACL token (sent as the client SecretID). A secret. Empty is only viable on an ACL-less cluster.
PFY_ORCHESTRATOR_NOMAD_NAMESPACE(empty)optionalNomad namespace. Empty uses the client default (default).
PFY_ORCHESTRATOR_PENDING_FLOOR_TTL3mHow 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.

VariableDefaultDevProdNotes
PFY_PROXY_ADMIN_URL(empty)(empty)optionalThe 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 setPath 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 setThe 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 onThe 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)recommendedHeader 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)recommendedValue sent in that header. A secret. Ignored when the header name is empty.
PFY_PROXY_SYNC_DEBOUNCE2sQuiet 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_INTERVAL5mPeriodic 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 onlymust be unsetDevelopment-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.

VariableDefaultDevProdNotes
PFY_ESB_MAX_REQUEST_BYTES10485760 (10 MiB)Inbound request body cap.
PFY_ESB_MAX_RESPONSE_BYTES10485760 (10 MiB)Upstream response body cap.
PFY_ESB_FORWARD_TIMEOUT30sOutbound 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.

VariableDefaultDevProdNotes
PFY_PILETS_MAX_UPLOAD_BYTES52428800 (50 MiB)Maximum pilet upload size.
PFY_PILETS_SIGNING_PRIVATE_KEY(empty)optionalrequired to uploadBase64-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)optionalrequired to uploadIdentifies 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 rotatingPublic 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.

yaml
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 trusted

There 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:

StateSpellingEffect
Retired (rotated out on schedule)key_id + public_keyPublished in the anchor and trusted for verification. Omit it and every bundle signed with that key is refused by the loader.
Revoked (compromised)revoked: trueNeither 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.

VariableDefaultDevProdNotes
PFY_AUDIT_EXTERNAL_ANCHOR_ENDPOINT(empty)optionalstrongly recommendedURL 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)optionalstrongly recommendedBase64-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)optionaloptionalKey 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.

VariableDefaultDevProdNotes
PFY_COMMS_WS_MAX_CONNS_PER_USER8Concurrent 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_CONNS5000Process-wide websocket ceiling behind the per-user cap.
PFY_COMMS_WS_INBOUND_FRAMES_PER_SECOND20Sustained client frame rate metered per connection.
PFY_COMMS_WS_INBOUND_FRAME_BURST96Burst 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_SECOND64Sustained subscribe/unsubscribe rate, counted per topic per connection.
PFY_COMMS_WS_SUBSCRIBE_CHURN_BURST192Burst allowance for the churn meter; same reconnect caveat as the frame burst.
PFY_COMMS_EMAIL_FROM_DOMAINS(unset)optionaloptionalComma-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.*) ​

VariableDefaultDevProdNotes
PFY_LOGGER_LEVELinfoLog 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).

VariableDefaultDevProdNotes
PFY_INVITE_BASE_URL(empty)optionalrecommendedPublic 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)optionalrecommendedOutbound SMTP host. Empty ⇒ invite mail is not sent (plaintext-link fallback).
PFY_SMTP_PORT587SMTP 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_TIMEOUT10sBounds the TCP/TLS connect to the SMTP host.
PFY_SMTP_SEND_TIMEOUT30sBounds 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).

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

VariableDefaultDevProdNotes
PFY_MANAGER_TOKEN(none, required)requiredrequiredThe 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)requiredrequiredOAuth client secret for the caddy-security to Pocket ID (OIDC) handshake. A secret.
PFY_ADMIN_LISTEN127.0.0.1:2019keep loopbackinternal onlyAddress 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_LISTEN127.0.0.1:2112keep loopbackinternal onlyAddress 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:

nginx
crypto default token lifetime 3600

token 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:

  1. Proxy to Pocket ID (OIDC login): caddy-security authenticates users against Pocket ID via OIDC using PFY_OAUTH_CLIENT_SECRET and the OIDC discovery/base URLs in the Caddyfile oauth identity provider generic block (base_auth_url / metadata_url, e.g. http://pocketid.localhost).
  2. Manager to Pocket ID (admin API): the Manager resolves/searches users via the Pocket ID admin API using PFY_POCKET_ID_HOST and PFY_POCKET_ID_API_KEY (sent as X-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's PFY_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_ENABLED is unset/false (and PFY_ENV=production, which alone makes the override a fatal error if enabled). *
  • [ ] Non-default database password. PFY_DB_PASSWORD is set to a strong, non-postgres value. *
  • [ ] TLS to the database. PFY_DB_SSLMODE is require (or stronger, verify-ca / verify-full); disable is rejected in production. *
  • [ ] At-rest encryption key. PFY_ENCRYPTION_KEY is a valid base64 32-byte key, so endpoint/backend secrets are encrypted at rest. *
  • [ ] Pocket ID API key. PFY_POCKET_ID_API_KEY is set. *
  • [ ] PFY_ENV=production. Disables the /gql playground, the dev reverse proxy, and the override escape hatch. *
  • [ ] Admin API not public. PFY_ADMIN_LISTEN binds an internal interface only; :2019 is never reachable from the internet. On Nomad the rendered job binds the node's own allocated address; set proxy_admin_bind in install.yaml if that address is itself public (a single-NIC cloud host).
  • [ ] Metrics/probe server not public. PFY_METRICS_LISTEN binds an internal interface only. It carries /metrics, /healthz, /readyz and /config-hash; the plugin's own default is every interface.
  • [ ] Session lifetime reviewed. token lifetime in 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_TOKEN and PFY_DB_PASSWORD come 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_KEY andPFY_PILETS_SIGNING_KEY_ID (non-dev- id), or pilet uploads will be rejected the first time someone tries one.
  • [ ] Orchestrator selected. PFY_ORCHESTRATOR_KIND=nomad plus PFY_ORCHESTRATOR_NOMAD_ADDRESS (and a token, on an ACL-enabled cluster) if any deployment uses scale-to-zero. The noop default answers wakes successfully and scales nothing.
  • [ ] Exactly one executor. At least one instance runs with PFY_RUN_MODE=executor or both, or no audit sweep, credential-staleness report or outbox delivery ever runs.
  • [ ] Audit witness configured. PFY_AUDIT_EXTERNAL_ANCHOR_ENDPOINT andPFY_AUDIT_EXTERNAL_ANCHOR_SIGNING_KEY together, 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_URL is set, PFY_PROXY_PRELUDE_FILE and PFY_PROXY_MANAGER_UPSTREAM must be too (the Manager enforces this one). PFY_PROXY_FAULT_INJECT_FILE must be unset. *
  • [ ] Audit retention reviewed. PFY_CRON_AUDIT_RETENTION_DEFAULT and PFY_CRON_AUDIT_RETENTION_VIEW match 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:

bash
cd docs
node scripts/check-config-drift.mjs ../manager/internal/config/config.go

The docs CI workflow runs the same check when it has a token that can read the Manager repository.