docs/self-hosting/kubernetes.mdx
The official Helm chart installs the full Trigger.dev stack into a Kubernetes namespace. Read the self-hosting overview first.
Pick a path and stay on it. Run the evaluation install to try Trigger.dev with everything bundled in-cluster - one helm install, default values, no external dependencies. Run the production install for real workloads: external Postgres, Redis, ClickHouse, and object storage, your own secrets, and HA on the datastores you already operate. Each configuration decision below belongs to one path or the other.
This guide alone is unlikely to produce a hardened production deployment - securing, scaling, and reliability-tuning your cluster remain yours to own. Should the burden get too much, we'd be happy to see you on Trigger.dev cloud where we deal with these concerns for you.
The chart deploys a webapp, a supervisor, and the datastores they depend on. The supervisor pulls runs from the webapp and schedules each one onto a worker node, which executes the task in a container sized by its machine preset.
flowchart TD
webapp["webapp
API + dashboard"]
supervisor["supervisor"]
workers["worker nodes
task containers"]
pg[("Postgres
primary DB")]
redis[("Redis
queue / state")]
ch[("ClickHouse
task events v2")]
obj[("object storage
MinIO / external S3")]
reg[("registry
deploy images")]
s2[("s2 / s2-lite
realtime streams v2")]
webapp --> pg
webapp --> redis
webapp --> ch
webapp --> obj
webapp --> s2
webapp -->|worker token| supervisor
supervisor --> workers
workers --> reg
Each component maps to a top-level key in values.yaml: webapp, supervisor, postgres, redis, electric, clickhouse, s3 (object storage), s2, and registry. The datastores default to deploy: true and run in-cluster; set deploy: false with an external block to bring your own. The registry is the exception - it defaults to deploy: false, so you point it at an external one. Webapp settings live under webapp, supervisor settings under supervisor.config, and each service takes extraEnvVars for anything else.
The following are minimum requirements for running the entire stack on Kubernetes:
Cluster resources:
Individual components:
These requirements scale based on your task concurrency and can be adjusted via the resources section in your values.yaml. For example:
webapp:
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 4Gi
Bundled datastores let you run the whole stack with a single command and no external services. The default values are insecure and are only suitable for testing - for real workloads, follow the production install instead.
<Steps> <Step title="Install the chart with default values"> ```bash helm upgrade -n trigger --install trigger \ oci://ghcr.io/triggerdotdev/charts/trigger \ --version "^4.5.0" \ --create-namespace ```The chart deploys the webapp, supervisor, and bundled Postgres, Redis, Electric, ClickHouse, MinIO, and s2-lite. The registry is not deployed by default - configure an external one before deploying tasks.
The dashboard is now reachable at `http://localhost:3040`.
```bash
kubectl logs -n trigger deployment/trigger-webapp | grep -A1 "magic link"
```
Open the printed link to sign in.
Confirm every component reached a healthy state:
kubectl get pods -n trigger
The webapp, supervisor, and each bundled datastore pod should report Running with all containers READY. Migrations and bootstrap run inside the webapp pod rather than as separate jobs, so if the webapp stays unready, check its logs. If a pod is stuck in Pending or CrashLoopBackOff, jump to Troubleshooting.
Once you can sign in and trigger a run, you've validated the stack. For a deployment that survives restarts, upgrades, and load, move on to the production install.
Running external datastores is the requirement that separates a production install from an evaluation one. You supply your own Postgres, Redis, ClickHouse, and object storage, manage secrets yourself, and point the chart at each service. The steps below layer onto the same helm upgrade command, driven by a custom values file.
Application, control-plane, and bundled-datastore secrets left unset are generated on first install and retained across helm upgrade - they are never rotated automatically, so sessions, encrypted data, and datastore volumes survive upgrades. This auto-generation is the default and needs no configuration.
For production, supply your own Secret instead so credentials live outside the release. With secrets.existingSecret set, the chart generates nothing and reads every key from your Secret; a preflight check fails the install - before touching a running release - and lists any keys it can't find.
To pin values inline instead of letting the chart generate them - for example to share MANAGED_WORKER_SECRET with an external supervisor - set them under secrets:
secrets:
enabled: true
sessionSecret: "your-32-char-hex-secret-1"
magicLinkSecret: "your-32-char-hex-secret-2"
# ...
To use an existing Secret instead, leave the values unset and set existingSecret. It must contain at least SESSION_SECRET, MAGIC_LINK_SECRET, ENCRYPTION_KEY, PROVIDER_SECRET, COORDINATOR_SECRET, and MANAGED_WORKER_SECRET - plus s3-auth-access-key-id and s3-auth-secret-access-key if you deploy the bundled MinIO with s3.auth.existingSecret cleared:
secrets:
enabled: false
existingSecret: "your-existing-secret"
The chart wires PROVIDER_SECRET and COORDINATOR_SECRET into the webapp deployment, and the pre-upgrade check requires both, so an existing Secret must include them. For a deployment that already runs, add them before upgrading:
kubectl patch secret my-trigger-secrets -n trigger --type merge -p \
"{\"stringData\":{\"PROVIDER_SECRET\":\"$(openssl rand -hex 16)\",\"COORDINATOR_SECRET\":\"$(openssl rand -hex 16)\"}}"
There is no clean migration for a compromised ENCRYPTION_KEY: changing it makes existing encrypted data unreadable. If a deployment is still running a previously published default and cannot rotate yet, set ALLOW_INSECURE_DEFAULT_SECRETS=true on the webapp to keep booting while you plan a migration.
Most values map directly to the environment variables documented in the webapp and supervisor environment variable overview. Environment variables use UPPER_SNAKE_CASE; Helm values use camelCase:
# Environment variable
APP_ORIGIN=https://trigger.example.com
# Becomes Helm value
webapp:
appOrigin: "https://trigger.example.com"
Print the full default surface for the version you're deploying:
# Specific version
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.5.0"
# Latest v4
helm show values oci://ghcr.io/triggerdotdev/charts/trigger \
--version "^4.5.0"
Create a values-custom.yaml that overrides the defaults with your URLs and resource requests:
# Application URLs and resource limits
webapp:
appOrigin: "https://trigger.example.com"
loginOrigin: "https://trigger.example.com"
apiOrigin: "https://trigger.example.com"
resources:
requests:
cpu: 1000m
memory: 2Gi
limits:
cpu: 2000m
memory: 4Gi
supervisor:
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
Deploy with your custom values:
helm upgrade -n trigger --install trigger \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "^4.5.0" \
--create-namespace \
-f values-custom.yaml
Set extra environment variables on any service:
webapp:
extraEnvVars:
- name: EXTRA_ENV_VAR
value: "extra-value"
Set extra annotations on any service:
webapp:
podAnnotations:
"my-annotation": "my-value"
Disable each built-in datastore and point the chart at your own. Every service supports direct configuration or an existing Kubernetes secret; use existing secrets to keep credentials out of values.yaml and Helm releases.
Direct configuration:
postgres:
deploy: false
external:
databaseUrl: "postgresql://user:password@host:5432/database?schema=public"
directUrl: "" # Optional, defaults to databaseUrl
Using existing secrets (recommended):
postgres:
deploy: false
external:
existingSecret: "postgres-credentials"
# Optional: Use secretKeys to specify the key names in the secret
# secretKeys:
# databaseUrlKey: "postgres-database-url" # default
# directUrlKey: "postgres-direct-url" # default
Direct configuration:
redis:
deploy: false
external:
host: "my-redis.example.com"
port: 6379
password: "my-password"
tls:
enabled: true
Using existing secrets (recommended):
redis:
deploy: false
external:
host: "my-redis.example.com"
port: 6379
existingSecret: "redis-credentials"
# existingSecretPasswordKey: "redis-password" # default (optional)
tls:
enabled: true
Migration from the old Bitnami subchart to the official clickhouse/clickhouse-server image is automatic - a normal upgrade moves you across with no manual step. The one exception: if you pinned clickhouse.image to a Bitnami tag, switch it to an official clickhouse/clickhouse-server tag first, since Bitnami tags don't exist in the official repository.
Direct configuration:
clickhouse:
deploy: false
external:
host: "my-clickhouse.example.com"
httpPort: 8123
username: "my-username"
password: "my-password"
Using existing secrets (recommended):
clickhouse:
deploy: false
external:
host: "my-clickhouse.example.com"
httpPort: 8123
username: "my-username"
existingSecret: "clickhouse-credentials"
# existingSecretKey: "clickhouse-password" # default (optional)
Object storage holds deploy artifacts and large payloads. See the Docker object storage setup for conceptual information. Disable the bundled MinIO and point at any S3-compatible endpoint.
Direct configuration:
s3:
deploy: false
external:
endpoint: "https://s3.amazonaws.com"
accessKeyId: "my-access-key"
secretAccessKey: "my-secret-key"
Using existing secrets (recommended):
s3:
deploy: false
external:
endpoint: "https://s3.amazonaws.com"
existingSecret: "s3-credentials"
# Optional: override the key names in the secret
# existingSecretAccessKeyIdKey: "access-key-id" # default
# existingSecretSecretAccessKeyKey: "secret-access-key" # default
When connecting to PostgreSQL instances that require custom CA certificates (such as AWS RDS with SSL verification), mount the CA certificate as a volume and configure the webapp to use it:
postgres:
deploy: false
external:
databaseUrl: "postgresql://user:[email protected]:5432/triggerdb?schema=public&sslmode=require"
# Alternatively, use an existing secret
existingSecret: "postgres-credentials"
# secretKeys:
# databaseUrlKey: "postgres-database-url" # default
connection:
sslMode: "require"
# Webapp configuration with SSL CA certificate
webapp:
extraEnvVars:
- name: NODE_EXTRA_CA_CERTS
value: "/etc/ssl/certs/postgres-ca.crt"
extraVolumes:
- name: postgres-ca-cert
secret:
secretName: postgres-ca-secret
items:
- key: ca.crt
path: postgres-ca.crt
extraVolumeMounts:
- name: postgres-ca-cert
mountPath: /etc/ssl/certs
readOnly: true
Benefits:
values.yaml or Helm releasesDATABASE_URL stored securely in Kubernetes secretsBuilt deploy images are pushed to a registry. See the Docker registry setup for conceptual information. Configure an external registry in your values.yaml:
# Use external registry (recommended)
registry:
deploy: false
# Part of deployment image ref, for example: your-registry.example.com/your-company/proj_123:20250625.1.prod
repositoryNamespace: "your-company"
external:
host: "your-registry.example.com:5000"
auth:
enabled: true
username: "your-username"
password: "your-password"
For production clusters we recommend deploying NodeLocal DNSCache. DNS queries — especially to managed Postgres or Redis endpoints — can be very slow under Kubernetes' default resolver, and a node-local cache typically gives a large step change in latency and throughput across the cluster.
The default ndots: 5 setting also forces every cluster search domain to be tried before resolving hostnames with fewer dots (the case for most external database hosts). Lowering ndots to 1 on the webapp and supervisor pods avoids those extra round-trips.
Authentication options are identical to the Docker-based installation. Configure the provider in your values.yaml:
GitHub OAuth:
webapp:
extraEnvVars:
- name: AUTH_GITHUB_CLIENT_ID
value: "your-github-client-id"
- name: AUTH_GITHUB_CLIENT_SECRET
value: "your-github-client-secret"
Email authentication (Resend):
webapp:
extraEnvVars:
- name: EMAIL_TRANSPORT
value: "resend"
- name: FROM_EMAIL
value: "[email protected]"
- name: REPLY_TO_EMAIL
value: "[email protected]"
- name: RESEND_API_KEY
value: "your-resend-api-key"
Restricting access:
webapp:
extraEnvVars:
- name: WHITELISTED_EMAILS
value: "^(user1@company\\.com|user2@company\\.com)$"
The supervisor authenticates to the webapp with a worker token. The default bootstrap path generates one for you; supply your own token to manage workers separately.
Bootstrap (default): the webapp generates a worker token and makes it available to the supervisor via a shared volume.
webapp:
bootstrap:
enabled: true
workerGroupName: "bootstrap"
Manual: to set up workers separately or use a custom token:
<Steps> <Step title="Read the worker token from the webapp logs"> ```bash kubectl logs deployment/trigger-webapp -n trigger | grep -A15 "Worker Token" ``` </Step> <Step title="Create a secret with the token"> ```bash kubectl create secret generic worker-token \ --from-literal=token=tr_wgt_your_token_here \ -n trigger ``` </Step> <Step title="Point the supervisor at the secret"> ```yaml supervisor: bootstrap: enabled: false workerToken: secret: name: "worker-token" key: "token" ``` </Step> </Steps>With external datastores, secrets, registry, and authentication in place, you have a production install. If you only wanted to try Trigger.dev, the evaluation install gets you there in one command.
helm upgrade is safe to run as-is. Your generated secrets, data volumes, and the bundled ClickHouse are preserved and migrated automatically, so a normal upgrade needs no preparation. A few situations need one manual step first — expand any that apply to you.
As of chart 4.5.6 the webapp deployment references `PROVIDER_SECRET` and `COORDINATOR_SECRET`, and the pre-upgrade check requires both, so an existing Secret must carry them. Add both before upgrading:
```bash
kubectl patch secret my-trigger-secrets -n trigger --type merge -p \
"{\"stringData\":{\"PROVIDER_SECRET\":\"$(openssl rand -hex 16)\",\"COORDINATOR_SECRET\":\"$(openssl rand -hex 16)\"}}"
```
If a required key is missing, the upgrade stops before touching your running release and tells you which to add.
```bash
kubectl get secret trigger-datastore -n trigger \
-o jsonpath='{.data.clickhouse-admin-password}' | base64 -d
```
The keys are `clickhouse-admin-password`, `postgres-password`, and `minio-root-user` / `minio-root-password`.
- Point `clickhouse.persistence.existingClaim` at your ClickHouse data volume (`data-<release>-clickhouse-shard0-0`) and keep it set on future syncs, so ClickHouse reuses its data instead of starting on an empty volume.
- Confirm `PROVIDER_SECRET` and `COORDINATOR_SECRET` are already in your Secret before syncing.
Lock versions in two ways.
Helm chart version (recommended):
# Pin to a specific version for production
helm upgrade -n trigger --install trigger \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.5.0"
# The app version will be different from the chart version
# This is the version of the Trigger.dev webapp and supervisor
# ..and should always match your Trigger.dev CLI version
helm show chart \
oci://ghcr.io/triggerdotdev/charts/trigger \
--version "4.5.0" | grep appVersion
Specific image tags:
webapp:
image:
tag: "v4.5.0"
supervisor:
image:
tag: "v4.5.0"
The chart version's appVersion field determines the default image tags. Newer image tags may be incompatible with older chart versions and vice versa.
Check logs:
# Webapp logs
kubectl logs deployment/trigger-webapp -n trigger -f
# Supervisor logs
kubectl logs deployment/trigger-supervisor -n trigger -f
# All pods
kubectl logs -l app.kubernetes.io/instance=trigger -n trigger -f
Check pod status:
kubectl get pods -n trigger
kubectl describe pod <pod-name> -n trigger
Start from scratch:
# Delete the release
helm uninstall trigger -n trigger
# Delete persistent volumes (optional)
# WARNING: This will delete all your data!
kubectl delete pvc -l app.kubernetes.io/instance=trigger -n trigger
# Delete the namespace (optional)
kubectl delete namespace trigger
Common issues:
See the Docker troubleshooting section for more information.
By default, task events (timeline, logs, spans) are stored in PostgreSQL. For production deployments we recommend storing them in ClickHouse instead, it scales to much higher volumes and avoids unbounded growth of the TaskEvent table.
ClickHouse is already deployed by the chart, so no extra services are required. To enable, set EVENT_REPOSITORY_DEFAULT_STORE on the webapp via extraEnvVars:
webapp:
extraEnvVars:
- name: EVENT_REPOSITORY_DEFAULT_STORE
value: "clickhouse_v2"
This only affects new runs; existing runs continue to read from wherever their events were originally stored.
Realtime streams power AI-agent token streaming and run streams. They default to v2, backed by the bundled s2 deployment — s2-lite, the open-source, self-hostable S2 server. The chart deploys it with a persistent volume, so no extra services are required.
To fall back to the Redis-backed v1 streams, set the default version to v1:
s2:
defaultStreamVersion: "v1"
To use a hosted S2 at s2.dev instead of the bundled s2-lite, disable the bundled deployment and point at your basin. Supply the access token via an existing secret:
s2:
deploy: false
skipAccessTokens: false
external:
endpoint: "https://your-basin.b.aws.s2.dev/v1"
existingSecret: "s2-credentials"
existingSecretAccessTokenKey: "access-token"
basin: "your-basin"
To disable realtime streams v2 entirely and use v1, set s2.deploy: false with no external endpoint. See helm show values for all s2 options.
See the Docker CLI usage section, the commands are identical regardless of deployment method.
When running the CLI in a CI environment, your login profiles won't be available. Instead, use the TRIGGER_API_URL and TRIGGER_ACCESS_TOKEN environment variables to point at your self-hosted instance and authenticate.
For more detailed instructions, see the GitHub Actions guide.
By default, the Trigger.dev webapp sends telemetry data to our servers. This data is used to improve the product and is not shared with third parties. To disable telemetry, set in your values.yaml:
telemetry:
enabled: false