Deploy on Kubernetes

Terrantula deploys to Kubernetes via a Helm chart (infra/k8s/). The API and Worker run as long-running Bun processes; pg-boss (Postgres-native) is the job queue. The bundled Bitnami PostgreSQL chart provides both the database and the queue — no additional infrastructure is required.

Architecture

┌─────────────────────────────────────────────────────────────────┐ │ Ingress (nginx) │ └────────────────────────────┬────────────────────────────────────┘ │ ┌──────────────▼──────────────┐ │ API (Bun) │ │ Hono + Drizzle + Zod │ │ │ │ ┌─────────────────────┐ │ │ │ Business Logic │ │ │ │ - apply │ │ │ │ - constraints │ │ │ │ - recommendations │ │ │ │ - interpolation │ │ │ └─────────────────────┘ │ └──────┬───────────┬──────────┘ │ │ ┌──────────▼───────────▼──────────┐ │ PostgreSQL │ │ (data + pg-boss job queue) │ └─────────────────────────────────┘ │ ┌───────▼─────────────────┐ │ Worker (Bun) │ │ pg-boss long-poll │ │ - resolve + decrypt │ │ secrets │ │ - interpolate payload │ │ - fire webhook or PR │ │ - update ActionRun │ └──────────────────────────┘

Prerequisites

  • Kubernetes 1.25+
  • helm 3.x
  • kubectl configured for the target cluster

1. Pull chart dependencies

helm dependency update infra/k8s

This downloads the bitnami/postgresql subchart.

2. Install

helm install terrantula infra/k8s

The chart deploys PostgreSQL, the API, and the Worker. With the bundled database both DATABASE_URL and DATABASE_POOL_URL are constructed automatically and the API creates the pool login role itself on first boot — no manual secret management needed for a default install.

Two database roles, and the order they have to arrive in

Terrantula connects with two roles against one database, and they are not interchangeable. DATABASE_URL owns the tables and runs the migrations; DATABASE_POOL_URL is a role that owns nothing and holds no table grant, and every request goes through it. The reason is not defence in depth: against a role that owns its tables, PostgreSQL row-level security is not a boundary at all — the owner can re-grant itself a privilege it just revoked, and can switch FORCE ROW LEVEL SECURITY off. Two database roles has the measured detail.

With an external database (postgresql.enabled=false) this is your job, and the order matters. Getting it wrong takes the deployment down; getting it wrong in the other direction leaves it up with no tenant boundary.

  1. Apply the migrations first, on the release that contains 0136_pool_role. That migration is purely additive — it creates the terrantula_pool group role and grants it the RLS memberships WITH INHERIT FALSE, and it revokes nothing. A deployment still running one role keeps working unchanged while it sits unused, so this step is safe to apply to a live deployment on its own and needs no drain. 0137_orphan_purge_role adds a sixth membership the same way and needs nothing of you, and 0138_retention_purge_role a seventh: the grant is to terrantula_pool, so a login role already made a member of it picks the new scopes up without a second GRANT.

  2. Create the login role, once, against the same database:

    CREATE ROLE app_pool LOGIN PASSWORD '<secret>';
    GRANT terrantula_pool TO app_pool WITH INHERIT FALSE;

    WITH INHERIT FALSE is load-bearing, not stylistic: without it the role holds the tenant role's table privileges ambiently, with no scope entered, and a read that must be refused comes back as zero rows instead. Do not add WITH ADMIN OPTION — a member holding it can grant itself INHERIT TRUE and undo the previous sentence. If your database has revoked CONNECT from PUBLIC, grant it to app_pool; it needs nothing else, and specifically not USAGE ON SCHEMA public.

  3. Set secrets.databasePoolUrl to that role's DSN and helm upgrade. The chart requires it when postgresql.enabled=false and helm upgrade fails without it — there is deliberately no fallback to databaseUrl, because a fallback is how a deployment quietly stays on the single-role topology.

  4. If your platform manages roles declaratively (CNPG, Crunchy), set poolRole.managed=false so the API does not create the role behind your operator's back. With the bundled subchart, leave it true.

If the new image lands before step 2, the API refuses to serve: it checks the live server at first use and fails closed when the pool role can reach a tenant table with no scope entered. That is a loud, immediately reversible outage — roll the image tag back, or finish step 2 — and it is deliberately preferred to the alternative, which is serving with the policies switched off and nothing to notice it by.

Do not skip step 1 for step 2. GRANT terrantula_pool TO … fails with role "terrantula_pool" does not exist until 0136 has applied.

3. Run migrations

Migrations are not run automatically on Kubernetes. Run them as a one-off pod after install or upgrade:

Use the same API image you deployed — see Building images; there is no publicly pullable first-party image.

kubectl run terrantula-migrate \
  --image=ghcr.io/your-org/terrantula-api:latest \
  --restart=Never \
  --env="DATABASE_URL=$(kubectl get secret terrantula-secrets -o jsonpath='{.data.DATABASE_URL}' | base64 -d)" \
  -- bun run src/db/migrate.ts

Configuration

All values can be overridden with --set or a values override file (-f my-values.yaml).

PostgreSQL

ValueDefaultDescription
postgresql.enabledtrueDeploy bundled PostgreSQL. Set to false to use an external database.
postgresql.auth.usernameterrantulaDatabase username
postgresql.auth.passwordterrantulaDatabase password — change this in production
postgresql.auth.databaseterrantulaDatabase name
postgresql.primary.persistence.size8GiPVC size for PostgreSQL data
poolRole.managedtrueThe API creates and re-grants the grantless pool login role after migrating and before serving. Set false when your platform manages roles declaratively; create it yourself instead (see above).
poolRole.nameterrantula_pool_appName of that login role. Only used when postgresql.enabled=true; with an external database the name comes from secrets.databasePoolUrl.
secrets.databasePoolUrl—Required when postgresql.enabled=false. The grantless runtime pool DSN. Must name a different role from secrets.databaseUrl.

API

ValueDefaultDescription
api.replicaCount2Number of API pod replicas
api.port3000Container port
image.api.repository(project-internal registry)API image repository — you must set this to your own, see Building images
image.api.tag0.15.0API image tag

Worker

ValueDefaultDescription
worker.replicaCount1Number of Worker pod replicas
image.worker.repository(project-internal registry)Worker image repository — you must set this to your own, see Building images
image.worker.tag0.11.0Worker image tag

Ingress

ValueDefaultDescription
ingress.enabledtrueDeploy an Ingress resource
ingress.classNamenginxIngress class
ingress.hostterrantula.example.comHostname
ingress.tls[]TLS configuration

Common scenarios

External database

Disable the bundled PostgreSQL and supply a connection string:

helm install terrantula infra/k8s \
  --set postgresql.enabled=false \
  --set secrets.databaseUrl="postgresql://user:pass@your-db-host:5432/terrantula"

Production credentials

Use a values override file to avoid passing secrets on the command line:

# my-values.yaml
postgresql:
  auth:
    password: "a-strong-random-password"

ingress:
  host: terrantula.your-domain.com
  tls:
    - hosts:
        - terrantula.your-domain.com
      secretName: terrantula-tls
helm install terrantula infra/k8s -f my-values.yaml

Upgrade

helm upgrade terrantula infra/k8s

Building images

Self-hosters must build their own images. The chart's default image.*.repository values point at a project-internal registry that is not publicly pullable — treat them as placeholders and override both.

Build from the workspace root (required for monorepo dependency resolution):

docker build -f packages/api/Dockerfile    -t ghcr.io/your-org/terrantula-api:latest .
docker build -f packages/worker/Dockerfile -t ghcr.io/your-org/terrantula-worker:latest .
docker push ghcr.io/your-org/terrantula-api:latest
docker push ghcr.io/your-org/terrantula-worker:latest

Then reference your images at install time:

helm install terrantula infra/k8s \
  --set image.api.repository=ghcr.io/your-org/terrantula-api \
  --set image.worker.repository=ghcr.io/your-org/terrantula-worker