[AVAILABLE], validated against a real, permanent Fly.io deployment (
parmana-api). Platform-agnostic by design, built against a bare docker build / docker run, not a specific PaaS’s proprietary build system. Source: DEPLOYMENT.md (repo root).Quick start
GET /health (liveness) and GET /ready (readiness, see Health
checks) are both served once the process is up.
What’s in the image
One image, one process, started bydocker/entrypoint.sh: the API server
(node packages/api/dist/server.js). entrypoint.sh forwards SIGTERM/SIGINT to it and
exits with its exit code, a minimal process supervisor, not a multi-process init system.
Required configuration
Everything below is validated eagerly, before the port is bound. A misconfigured process never boots “successfully” and fails later on the first real request, it exits immediately with a clear error naming exactly what’s missing.Signing key material, always required
Every execution authorization, receipt, verification, and settlement confirmation is signed. Two key pairs are required inPARMANA_KEY_DIR (default ./keys, already set in the image):
default.private.pem/default.public.pem, the authorization-signing key.gateway.private.pem/gateway.public.pem, the Gateway’s attestation-signing key, deliberately separate (PARMANA_GATEWAY_KEY_IDoverrides thegatewayid if you need a different one).
KEY_PROVIDER (optional, defaults to local) fails startup loudly if set to
aws-kms/azure-key-vault/gcp-kms/hsm. None of those custody models are implemented
yet, and this is a real config value with no working provider behind it today.
PARMANA_GATEWAY_ID (optional, defaults to parmana-gateway) is this process’s logical
Gateway identity, distinct from PARMANA_GATEWAY_KEY_ID above, which is a key file prefix.
Set a distinct value per environment or tenant if running more than one logically distinct
gateway against the same audit trail.
Neither key file is generated automatically. Two ways to provide them:
- Mount a volume or platform secret file at
/app/keyscontaining all four.pemfiles, the image’skeys/starts empty on purpose, key material must never be baked into the image. PARMANA_KEY_MATERIAL_JSON, for platforms with no persistent-volume or secret-file primitive. A JSON object,{ "<keyId>": { "privateKeyPem": string, "publicKeyPem": string } }, written toPARMANA_KEY_DIRat boot for any file that doesn’t already exist there, a pre-mounted file always wins, never overwritten. Needs entries for bothdefaultandgatewaykey ids.
gateway.{private,public}.pem.)
Storage (PARMANA_STORAGE)
memory(default), no external dependency, fine for a single-instance deployment with no durability guarantee across restarts.supabase, requiresSUPABASE_URLandSUPABASE_SERVICE_ROLE_KEY(orSUPABASE_ANON_KEY). Validated eagerly at boot, not lazily on first request.
PARMANA_STORAGE: the Execution Gateway’s replay-nonce store and the caller
authentication audit trail are always Supabase-backed in production, never falls back to
in-memory, because a durable store is required for these to behave correctly across process
restarts and replicas — SUPABASE_URL / key are required for both regardless of
PARMANA_STORAGE.
Applying the schema (Supabase)
Any Supabase-backed component needs the schema insupabase/migrations/ actually applied to
the target Supabase project, a manual step. An unapplied table surfaces as PostgREST
returning PGRST205 (“Could not find the table … in the schema cache”) on every request
touching it, even though the connection and credentials are otherwise fine.
Two ways to apply it:
- Supabase CLI, if linked to the project:
supabase db push. - No CLI link, or a Dashboard-only workflow: run
scripts/apply-all-migrations.sql(a concatenation of every file insupabase/migrations/, in chronological order, unmodified) once, in full, via the Supabase Dashboard’s SQL Editor. Safe to re-run, every statement is already idempotent (CREATE TABLE IF NOT EXISTS,ADD COLUMN IF NOT EXISTS).
GET /ready (or any Supabase-backed route) still returns PGRST205 immediately
after applying migrations, force a reload rather than waiting: Dashboard → Database → API →
“Reload schema cache”, or NOTIFY pgrst, 'reload schema'; via the SQL Editor.
Caller authentication (PARMANA_API_KEYS)
A JSON array of { "callerId": string, "keyHash": string } entries. Refuses to start with no
caller authentication configured. For local development only, PARMANA_AUTH_DISABLED=true
bypasses this (logs a loud warning on every boot), never set this in a real deployment. See
Authentication.
Policy directory (PARMANA_POLICY_DIR)
Already set to ./policies in the image, the committed policies/ directory is baked in.
Override only if a platform needs to mount a different policy set.
Everything else
PORT (default 3000, read dynamically for platforms that inject it at deploy time),
LOG_LEVEL, CRYPTO_MODE, HASH_PROVIDER, PRIMARY_SIGNATURE_PROVIDER, TRUST_PROFILE,
RECEIPT_VERSION, EXECUTION_AUTHORIZATION_TTL_SECONDS, SHUTDOWN_TIMEOUT_MS (default
10000, see below) all have working defaults and rarely need to be set. See
packages/shared/src/config/Config.ts for the authoritative list, it’s the only place
process.env is read for application config.
RATE_LIMIT_EXECUTE_PER_MINUTE/RATE_LIMIT_HEALTH_PER_MINUTE (defaults 30/300): when
DATABASE_URL is configured, both limiters share counts fleet-wide via a durable
Postgres-backed store; without it, each machine counts independently, so a horizontally
scaled deployment’s effective ceiling is limitPerMinute × machineCount. See
Authentication.
Health checks
GET /health, pure liveness, no external dependency touched.GET /ready, readiness: when storage is Supabase-backed, makes one cheap read againstconsumed_noncesto confirm the connection and credentials actually work, returning503if not, so an orchestrator can tell “up but backed by dead storage” apart from “genuinely ready” and route around it. When storage ismemory, there’s no external dependency to probe, it reports ready unconditionally. Also carriesauthDisabled(plus awarningstring when true). Set up a synthetic check on this field, sincePARMANA_AUTH_DISABLEDshould never be set in a real deployment.
Graceful shutdown
OnSIGTERM / SIGINT: the API server stops accepting new connections, lets in-flight
requests finish, then exits, the “drain, don’t drop” shape a PaaS orchestrator expects before
it force-kills the container. SHUTDOWN_TIMEOUT_MS (default 10000) bounds how long a hung
in-flight request can delay shutdown before the process force-exits on its own terms.
Fly.io specifics
Validated against a realparmana-api Fly app.
- Secrets:
scripts/generate-fly-secrets.mjsgeneratesPARMANA_KEY_MATERIAL_JSONandPARMANA_API_KEYS(singlesmoke-testcaller) locally (never printed), leavingSUPABASE_URL/SUPABASE_SERVICE_ROLE_KEYas placeholders to fill in, thenfly secrets import < .flysecrets/secrets.envto apply. Rotating any secret restarts every machine to pick it up,fly statusshould show a recent “last updated” and passing health checks before treating the new value as live. - Region:
fly.tomldeclares a primary region, but machine placement is Fly’s choice at create time, confirm actual placement withfly statusrather than assuming the configured primary region. - Smoke test:
GET /healthandGET /readyshould both return200post-deploy, an unauthenticatedPOST /executeshould return401with aWWW-Authenticateheader, confirming caller auth is actually wired rather than accidentally disabled.
Next
Local / self-hosted deployment
The simpler, single-process model for local development.