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.
helm 3.xkubectl configured for the target clusterThis downloads the bitnami/postgresql subchart.
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.
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.
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.
Create the login role, once, against the same database:
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.
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.
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.
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.
All values can be overridden with --set or a values override file (-f my-values.yaml).
| Value | Default | Description |
|---|---|---|
postgresql.enabled | true | Deploy bundled PostgreSQL. Set to false to use an external database. |
postgresql.auth.username | terrantula | Database username |
postgresql.auth.password | terrantula | Database password — change this in production |
postgresql.auth.database | terrantula | Database name |
postgresql.primary.persistence.size | 8Gi | PVC size for PostgreSQL data |
poolRole.managed | true | The 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.name | terrantula_pool_app | Name 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. |
| Value | Default | Description |
|---|---|---|
api.replicaCount | 2 | Number of API pod replicas |
api.port | 3000 | Container port |
image.api.repository | (project-internal registry) | API image repository — you must set this to your own, see Building images |
image.api.tag | 0.15.0 | API image tag |
| Value | Default | Description |
|---|---|---|
worker.replicaCount | 1 | Number 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.tag | 0.11.0 | Worker image tag |
| Value | Default | Description |
|---|---|---|
ingress.enabled | true | Deploy an Ingress resource |
ingress.className | nginx | Ingress class |
ingress.host | terrantula.example.com | Hostname |
ingress.tls | [] | TLS configuration |
Disable the bundled PostgreSQL and supply a connection string:
Use a values override file to avoid passing secrets on the command line:
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):
Then reference your images at install time: