[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
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 bydocker/entrypoint.sh:
- The API server (
node packages/api/dist/server.js), the primary process. Its exit, for any reason, brings the container down. - The Razorpay settlement poll loop
(
tsx scripts/process-razorpay-settlements.ts), reaped independently. It exits0and 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.
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 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).
- 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, 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 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.
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, enablesPOST /webhooks/razorpay. Unset in production means the route is never mounted at all.RAZORPAY_SETTLEMENT_POLL_INTERVAL_MS(default15000), poll interval for the settlement processor.
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 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.
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. 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 realparmana-api Fly app.
- Secrets:
scripts/generate-fly-secrets.mjsgeneratesPARMANA_KEY_MATERIAL_JSON,PARMANA_API_KEYS(singlesmoke-testcaller), andRAZORPAY_WEBHOOK_SECRETlocally (never printed), leavingSUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY/RAZORPAY_KEY_ID/RAZORPAY_KEY_SECRETas 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. - Webhook registration:
POST /webhooks/razorpayonly becomes reachable onceRAZORPAY_WEBHOOK_SECRETis 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 /healthandGET /readyshould both return200post-deploy, an unauthenticatedPOST /executeshould return401with aWWW-Authenticateheader, 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).Next
Local / self-hosted deployment
The simpler, single-process model for local development.
Razorpay
The connector this deployment’s Razorpay configuration enables.