> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parmanasystems.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Production Deployment

> Running @parmana/api as a container on Fly.io or any Docker-based platform, validated against a real, permanent deployment.

<Info>**\[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).</Info>

## Quick start

```bash theme={null}
docker build -t parmana-api .

docker run -p 3000:3000 \
  -e SUPABASE_URL=... \
  -e SUPABASE_SERVICE_ROLE_KEY=... \
  -e PARMANA_API_KEYS='[{"callerId":"...","keyHash":"..."}]' \
  -v /path/to/keys:/app/keys:ro \
  parmana-api
```

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](#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:

```bash theme={null}
openssl genrsa -out default.private.pem 2048
openssl rsa -in default.private.pem -pubout -out default.public.pem
```

(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](/api-reference/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](/integrations/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).

<Warning>
  **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](/integrations/razorpay#whats-proven-where-precisely).
</Warning>

## Next

<CardGroup cols={2}>
  <Card title="Local / self-hosted deployment" icon="laptop" href="/deployment/local">
    The simpler, single-process model for local development.
  </Card>

  <Card title="Razorpay" icon="credit-card" href="/integrations/razorpay">
    The connector this deployment's Razorpay configuration enables.
  </Card>
</CardGroup>
