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

# On-prem deployment

> Install and operate Spendra in customer-managed Kubernetes with external Postgres, Auth, secrets, ingress, and observability.

Spendra ships an on-prem package at `infra/helm/spendra`. The supported baseline is customer-managed Kubernetes with external Postgres, Supabase-compatible dashboard Auth, customer ingress/TLS, customer secret management, and customer observability/SIEM.

Spendra does not bundle Postgres, an ingress controller, a secrets manager, a service mesh, Prometheus, or a SIEM. The chart deploys only the Spendra web, API/gateway, worker, and operational jobs.

## Architecture

Deploy these components:

* `spendra-web`: Next.js dashboard.
* `spendra-api`: Fastify management API and OpenAI-compatible gateway.
* `spendra-worker`: Graphile Worker background processor.
* External Postgres 16, operated by the customer with HA, backups, PITR, and monitoring.
* Supabase-compatible Auth/JWKS for dashboard sessions.
* Customer ingress, TLS, optional mTLS/service mesh, secrets manager, log pipeline, and metrics scraper.

Recommended production starting point:

| Component     |           CPU |    Memory | Availability                                                 |
| ------------- | ------------: | --------: | ------------------------------------------------------------ |
| Web dashboard |        2 vCPU |      1 GB | At least two replicas behind ingress.                        |
| API/gateway   | 2-4 vCPU each | 2 GB each | At least two always-on replicas.                             |
| Worker        |        2 vCPU |    1-2 GB | Start with one replica; scale by queue depth and outbox lag. |
| Postgres      |       4+ vCPU |     8+ GB | Customer-managed HA Postgres with PITR.                      |

## Required secret

Create one Kubernetes Secret and pass its name as `secrets.existingSecret`.

Minimum keys:

```bash theme={null}
kubectl create secret generic spendra-runtime \
  --from-literal=DATABASE_URL='postgres://...' \
  --from-literal=SPENDRA_ALLOWED_ORIGINS='https://spendra.example.com' \
  --from-literal=SPENDRA_METRICS_BEARER_TOKEN='...' \
  --from-literal=OPENAI_API_KEY='...' \
  --from-literal=SUPABASE_JWT_SECRET='...'
```

Common optional keys include `SENTRY_DSN`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, Azure OpenAI settings, Vertex AI settings, SMTP/notification provider secrets, and provider-account secret references.

For production, manage this Secret through the customer secrets manager. External Secrets Operator, Vault Secrets Operator, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, and sealed-secret workflows are all acceptable as long as Kubernetes receives the expected environment variable names.

## Build environment-specific images

Build the web image separately for each environment. The dashboard browser bundle reads `NEXT_PUBLIC_*` values at Next.js build time, so a web image built for integration must not be promoted unchanged into production.

Use environment-specific tags such as `0.1.0-int` and `0.1.0-prod`:

```bash theme={null}
docker build -f infra/web.Dockerfile -t registry.example.com/spendra/spendra-web:0.1.0-prod \
  --build-arg NEXT_PUBLIC_SUPABASE_URL=https://auth.example.com \
  --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY='<browser-anon-key>' \
  --build-arg NEXT_PUBLIC_API_BASE_URL=https://spendra.example.com \
  --build-arg NEXT_PUBLIC_SENTRY_DSN='<optional-browser-dsn>' \
  .

docker build -f infra/api.Dockerfile -t registry.example.com/spendra/spendra-api:0.1.0-prod .
docker build -f infra/worker.Dockerfile -t registry.example.com/spendra/spendra-worker:0.1.0-prod .
```

The API and worker image contents are environment-neutral, but tag all three images with the same release/environment tag because the chart uses one `image.tag` value. Runtime endpoints, database credentials, provider secrets, and operational tokens come from `secrets.existingSecret`.

## Install

Render and inspect the manifests before installing:

```bash theme={null}
pnpm onprem:render
pnpm onprem:chart:lint
```

Install:

```bash theme={null}
helm upgrade --install spendra infra/helm/spendra \
  --namespace spendra \
  --create-namespace \
  --set global.imageRegistry=registry.example.com/spendra \
  --set image.tag=0.1.0-prod \
  --set secrets.existingSecret=spendra-runtime \
  --set auth.supabaseUrl=https://auth.example.com \
  --set api.publicApiBaseUrl=https://spendra.example.com
```

The chart fails rendering if `secrets.existingSecret` is not set. The Secret must include `DATABASE_URL`; production API startup rejects missing `DATABASE_URL` instead of falling back to an in-memory store.

The chart runs database migrations, Graphile Worker migrations, and database smoke checks as Helm hooks by default.

`db:seed` is disabled by default for on-prem. It inserts demo Acme data and a demo API key for local/demo environments, so it must not run in enterprise production installs. For an isolated demo environment only, explicitly set both `jobs.seed.enabled=true` and `jobs.seed.allowDemoData=true`; the seed hook is install-only and does not run on upgrades.

## Ingress, TLS, and mTLS

Enable ingress with customer-owned hostnames and TLS secrets:

```yaml theme={null}
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: spendra.example.com
      paths:
        - path: /
          pathType: Prefix
          service: web
        - path: /v1
          pathType: Prefix
          service: api
        - path: /management
          pathType: Prefix
          service: api
        - path: /health
          pathType: Exact
          service: api
  tls:
    - secretName: spendra-tls
      hosts:
        - spendra.example.com
```

Terminate public TLS at ingress or load balancer. If mTLS is required, enforce it in the customer ingress, API gateway, or service mesh. Spendra containers do not terminate mTLS directly.

Do not expose `/metrics` publicly. Scrape it only from the private observability plane with `SPENDRA_METRICS_BEARER_TOKEN`.

## Catalog updates and air-gapped mode

By default, the worker syncs model and tool catalogs from upstream sources on the configured schedule. Disable online sync in air-gapped deployments:

```yaml theme={null}
catalog:
  onlineSync:
    enabled: false
  offlineBundle:
    enabled: true
```

Create a bundle in a connected environment:

```bash theme={null}
pnpm catalog:export --output spendra-catalog.json --database-url 'postgres://...'
```

Import it inside the air-gapped environment after migrations:

```bash theme={null}
pnpm catalog:import --input spendra-catalog.json --database-url 'postgres://...'
```

Imported rows are upserted and catalog sync metadata is recorded with source `offline_bundle`.

## Upgrade and rollback

Before every upgrade:

1. Confirm Postgres backups and PITR are healthy.
2. Capture a restore point or backup according to customer policy.
3. Render the Helm diff or rendered manifests.
4. Run the upgrade during a maintenance window if schema changes are destructive or large.

Upgrade:

```bash theme={null}
helm upgrade spendra infra/helm/spendra \
  --namespace spendra \
  --reuse-values \
  --set image.tag=<new-version>
```

Application rollback uses Helm revision rollback:

```bash theme={null}
helm rollback spendra <revision> --namespace spendra
```

Schema rollback is not automatic. Normal fixes use forward migrations. If a destructive migration must be undone, restore Postgres from PITR/snapshot to the pre-upgrade restore point and then redeploy the matching application revision.

## Post-install acceptance

Verify:

* API `/health` returns successfully.
* API `/metrics` is reachable only from the private scraper with the metrics bearer token.
* Dashboard login reaches onboarding or the active organization.
* Worker jobs are present and processing.
* A governed OpenAI-compatible request creates a settled ledger entry.
* Audit records are written for key, policy, budget, role, and organization changes.
* Catalog sync is either healthy online or imported from an offline bundle.
