Skip to main content
[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

The process fails closed on missing configuration, see below for exactly what’s required and why. 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 by docker/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 in PARMANA_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_ID overrides the gateway id 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:
  1. Mount a volume or platform secret file at /app/keys containing all four .pem files, the image’s keys/ starts empty on purpose, key material must never be baked into the image.
  2. PARMANA_KEY_MATERIAL_JSON, for platforms with no persistent-volume or secret-file primitive. A JSON object, { "<keyId>": { "privateKeyPem": string, "publicKeyPem": string } }, written to PARMANA_KEY_DIR at boot for any file that doesn’t already exist there, a pre-mounted file always wins, never overwritten. Needs entries for both default and gateway key ids.
Generate a throwaway pair locally with:
(repeat for 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, requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_ANON_KEY). Validated eagerly at boot, not lazily on first request.
Independent of 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 in supabase/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:
  1. Supabase CLI, if linked to the project: supabase db push.
  2. No CLI link, or a Dashboard-only workflow: run scripts/apply-all-migrations.sql (a concatenation of every file in supabase/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).
After applying, PostgREST’s schema cache can lag behind the newly created tables until it reloads. If 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 against consumed_nonces to confirm the connection and credentials actually work, returning 503 if not, so an orchestrator can tell “up but backed by dead storage” apart from “genuinely ready” and route around it. When storage is memory, there’s no external dependency to probe, it reports ready unconditionally. Also carries authDisabled (plus a warning string when true). Set up a synthetic check on this field, since PARMANA_AUTH_DISABLED should never be set in a real deployment.

Graceful shutdown

On SIGTERM / 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 real parmana-api Fly app.
  • Secrets: scripts/generate-fly-secrets.mjs generates PARMANA_KEY_MATERIAL_JSON and PARMANA_API_KEYS (single smoke-test caller) locally (never printed), leaving SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY as placeholders to fill in, then fly secrets import < .flysecrets/secrets.env to apply. Rotating any secret restarts every machine to pick it up, fly status should show a recent “last updated” and passing health checks before treating the new value as live.
  • Region: fly.toml declares a primary region, but machine placement is Fly’s choice at create time, confirm actual placement with fly status rather than assuming the configured primary region.
  • Smoke test: GET /health and GET /ready should both return 200 post-deploy, an unauthenticated POST /execute should return 401 with a WWW-Authenticate header, confirming caller auth is actually wired rather than accidentally disabled.

Next

Local / self-hosted deployment

The simpler, single-process model for local development.