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

# Self-hosting

> Run the PeridotID API yourself

## Requirements

* Node.js 20+
* pnpm
* PostgreSQL 16+ (Supabase works great)

Refresh-token state lives in the `sessions` table, so **PostgreSQL is the only datastore** — no Redis needed.

## Environment variables

All configuration is loaded from `.env` in the API package. Copy
`apps/api/.env.example` to `apps/api/.env` and fill it in:

| Variable | Required | Description |
| - | - | - |
| `DATABASE_URL` | yes | PostgreSQL connection string. For Supabase, use the **transaction pooler** (port `6543`) with `?pgbouncer=true&connection_limit=1` |
| `JWT_ACCESS_SECRET` | yes | Secret for signing access tokens (32+ chars) |
| `JWT_REFRESH_SECRET` | yes | Secret for signing refresh tokens (32+ chars) |
| `GOOGLE_CLIENT_ID` | yes\* | Google OAuth client ID (same key name in every env — values differ) |
| `GOOGLE_CLIENT_SECRET` | yes\* | Google OAuth client secret (same key name in every env) |
| `GOOGLE_CALLBACK_URL` | yes\* | Must match the authorized redirect URI |
| `COOKIE_SECURE` | no | `true` in production (cookies only over HTTPS) |
| `COOKIE_DOMAIN` | no | Cookie domain; omit for localhost |
| `COOKIE_SAMESITE` | no | `lax` (default) or `none` (cross-site clients, requires `COOKIE_SECURE=true`) |
| `CLIENT_SUCCESS_URL` | no | Where users land after Google sign-in |
| `CORS_ORIGINS` | no | Comma-separated extra client origins allowed by CORS |
| `WEBAUTHN_RP_ID` / `WEBAUTHN_RP_NAME` / `WEBAUTHN_ORIGINS` | yes | Passkey relying-party config |
| `CLIENT_REDIRECT_ALLOWLIST` | no | Comma-separated origins allowed for cross-origin SSO |
| `ACCESS_TOKEN_TTL` | no | Access token lifetime (default `15m`) |
| `REFRESH_TOKEN_TTL` | no | Refresh token lifetime (default `30d`) |
| `APP_TOKEN_TTL` | no | Machine (client-credentials) token lifetime (default `1h`) |
| `PORT` | no | HTTP port (default `3301`) |

> **Solana chains/RPC/program id are not env.** They live in the chain registry
> (DB), seeded by `pnpm db:seed` and edited in the workspace admin
> (`GET /v1/chains`, `POST /v1/admin/chains`). RPC API keys, if any, must be
> public or proxied — the registry is served to clients as-is.

### Fiat (DOKU Checkout + internal ledger)

Fiat is one namespace: **DOKU Checkout is money-in only** (no Sub-Account); every
balance lives on the internal fiat ledger. See [Fiat & payments](/fiat).

| Variable | Required | Description |
| - | - | - |
| `DOKU_MODE` | no | `sandbox` (default) or `production` |
| `DOKU_CLIENT_ID` / `DOKU_SECRET_KEY` | yes\* | DOKU merchant credentials (Checkout) |
| `DOKU_PRIVATE_KEY` | no | RSA PEM for legacy Sub-Account calls (not needed for Checkout-only) |
| `DOKU_WEBHOOK_URL` | no | Public URL for DOKU payment notifications (`/v1/fiat/webhook`) |
| `DOKU_CHECKOUT_NOTIFY_URL` | no | Per-payment notify override (defaults to `DOKU_WEBHOOK_URL`) |
| `PID_FIAT_LEDGER_ENABLED` | no | Master switch (default `true`) |
| `PID_FIAT_LEDGER_FROZEN` | no | Freeze new issues/sends (default `false`) |
| `PID_FIAT_LEDGER_TREASURY_PID` | no | Label for the global-fee treasury (env, never hardcoded) |
| `PID_REDEMPTION_ENABLED` | no | Bank withdrawals (default `false`; disabled for now) |

`*` DOKU endpoints fail at call time until these are set. The global fee policy
is versioned in the DB (`fiat_fee_policies`) and can be published by an admin
(`POST /v1/fiat/admin/fee-policy`); per-app fees are configured per app in the
workspace.

`*` Google endpoints return `503` until these are set. Dev and prod run the
identical OAuth code path — only the values differ per environment. Create a
client per environment at the
[Google Cloud console](https://console.cloud.google.com/apis/credentials):
register `GOOGLE_CALLBACK_URL` (e.g. `http://localhost:3301/v1/auth/google/callback`)
on the dev client and the prod callback URL on the prod client.

## Local development with Docker

```bash theme={null}
docker compose up -d          # Postgres
pnpm install
pnpm db:migrate               # apply Prisma migrations
pnpm dev                      # API on http://localhost:3301, docs on http://localhost:3300
```

## Deploy to Vercel + Supabase

1. **Supabase** — create a project, then run migrations against it (use the session pooler,
   port `5432`, for migrations):

   ```bash theme={null}
   DATABASE_URL="postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres" \
     pnpm --filter @peridotvault/pid-api db:deploy
   ```

2. **Vercel — API** (`apps/api/vercel.json` is already configured for serverless). Add a
   project rooted at `apps/api` and set these env vars:
   `DATABASE_URL` (transaction pooler, port `6543`, `?pgbouncer=true`), `JWT_ACCESS_SECRET`,
   `JWT_REFRESH_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
   `GOOGLE_CALLBACK_URL`, `COOKIE_SECURE=true`, `COOKIE_SAMESITE`, `CLIENT_SUCCESS_URL`,
   `CORS_ORIGINS`.

3. **Mintlify — docs** (canonical; the board user deploys manually). The Mintlify
   source lives in `apps/docs` (`docs.json` + pages + `openapi.yaml`).
   In Mintlify: connect the `peridot-id` repo, set the docs path to
   `apps/docs`, and attach custom domains `docs.peridot.id` (prod) +
   `sandbox.docs.peridot.id` (sandbox) via CNAME at Hostinger. The legacy
   Fumadocs site (`apps/docs`) keeps serving the old `pid.peridotvault.com/docs*`
   paths until cutover; at cutover it lands a redirect old-docs → the Mintlify host.

4. Attach your custom domains and point DNS at Vercel.

## Production build

```bash theme={null}
pnpm build
pnpm --filter @peridotvault/pid-api db:deploy
pnpm --filter @peridotvault/pid-api start
```

## Legacy-database migration notes

Migrations are **additive** where possible, but the PID renames (`identityId` → `pid`,
`PidAccount` removal (1 identity = 1 wallet) rewrite identity-linked tables. For a **fresh**
database just run `db:deploy` + seed. For a database with existing rows from older
releases:

1. **Email-duplicate audit** — a non-null email is unique (one email = one PID).
   Resolve collisions before migrating (the uniqueness index fails otherwise):

   ```sql theme={null}
   SELECT lower(email) AS email, count(*), array_agg("pid")
   FROM identity_credentials
   WHERE email IS NOT NULL
   GROUP BY lower(email)
   HAVING count(*) > 1;
   ```

2. Deploy with `pnpm --filter @peridotvault/pid-api db:deploy` (same flow as the Supabase steps above).

3. **No backfill.** Existing users get **no wallet rows** — they are created only by
   explicit user action (`POST /v1/account`), so nothing is silently created for
   existing accounts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.