Skip to main content
[AVAILABLE], validated this session against real parmana-api and parmana-api-live Fly.io apps. 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, two processes, started by docker/entrypoint.sh:
  1. The API server (node packages/api/dist/server.js), the primary process. Its exit, for any reason, brings the container down.
  2. The Razorpay settlement poll loop (tsx scripts/process-razorpay-settlements.ts), reaped independently. It exits 0 and stays exited when Razorpay credentials aren’t configured for this deployment, a supported “optional, absent” state, that must never kill a healthy, currently-serving API server. A poller crash (nonzero exit) is logged loudly as a warning instead, settlement confirmations stop processing until the container is redeployed.
This couples the poll loop’s liveness to the API server’s, a crash in the API brings the poller down too, and horizontally scaling the API to N replicas multiplies the poll loop to N pollers. The settlement processor’s runOnce() is idempotent by design, so N concurrent pollers draining the same durable event store is safe, just wasteful. Running the poller as its own service scaled to exactly one replica is the production-grade shape, that’s future infrastructure work, not done at this stage.

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).
Neither 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, the caller authentication audit trail, the Razorpay webhook event store, and the settlement poll loop’s event store 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. If Razorpay isn’t configured at all, the webhook route is simply never mounted and the poller exits 0 cleanly, but the moment any Razorpay credential is present, SUPABASE_URL / key become required too, and SUPABASE_URL / key are required for the nonce store and audit trail 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.

Razorpay (optional)

Unset by default, the connector, the webhook route, and the settlement poller all detect this and degrade gracefully rather than failing to boot. To enable:
  • RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET, enables the connector.
  • RAZORPAY_WEBHOOK_SECRET, enables POST /webhooks/razorpay. Unset in production means the route is never mounted at all.
  • RAZORPAY_SETTLEMENT_POLL_INTERVAL_MS (default 15000), poll interval for the settlement processor.
See Razorpay for the connector itself.

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.

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.

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. The settlement poller shuts down independently, it stops scheduling new ticks immediately and waits for whichever tick is currently in flight before exiting.

Fly.io specifics

Validated against a real parmana-api Fly app.
  • Secrets: scripts/generate-fly-secrets.mjs generates PARMANA_KEY_MATERIAL_JSON, PARMANA_API_KEYS (single smoke-test caller), and RAZORPAY_WEBHOOK_SECRET locally (never printed), leaving SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET 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.
  • Webhook registration: POST /webhooks/razorpay only becomes reachable once RAZORPAY_WEBHOOK_SECRET is set and the machines have restarted with it. Register the permanent URL (https://<app>.fly.dev/webhooks/razorpay) in the Razorpay Dashboard against Test Mode specifically if the credentials are test-mode keys, a Live Mode registration silently receives nothing for test-mode activity.
  • 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.

Live-mode deployment

A second Fly app can run the identical image against a Razorpay live-mode credential pair instead of test-mode. Operationally it’s identical to the test-mode app, the only differences are the Razorpay credential pair and the webhook registration mode (register against Live Mode specifically, a Test Mode registration silently receives nothing for live-mode activity).
Scope, precisely: one real-money transaction, not sustained operation. This deployment shape has validated exactly one authenticated, policy-gated 100-paise refund against a real, card-paid ₹10 Payment Link, through the same production POST /execute chain, with a genuine refund.processed webhook from Razorpay’s own infrastructure and a signed SETTLED settlement confirmation roughly 43 seconds later. It does not validate volume, sustained live-mode operation, or failover, running two machines is for redundancy, not capacity or tested failover behavior. See docs/CLAIMS.md §3.9 for the full trace and Razorpay.

Next

Local / self-hosted deployment

The simpler, single-process model for local development.

Razorpay

The connector this deployment’s Razorpay configuration enables.