Helm chart
Run teriac server on Kubernetes with the teriac-server chart: install, routing with Gateway API or Ingress, sign-in, cloud credentials, values and how the chart runs it.
The teriac-server chart runs teriac server on Kubernetes 1.27+. It's published at
oci://ghcr.io/marcdomain/charts/teriac-server
(package).
Install
kubectl create namespace teriac
kubectl -n teriac create secret generic teriac-github \
--from-file=private-key=app.pem --from-literal=webhook-secret="$WEBHOOK_SECRET"
helm install teriac oci://ghcr.io/marcdomain/charts/teriac-server --version 0.2.1 -n teriac -f values.yaml# values.yaml
github:
appId: "123456"
existingSecret: teriac-github
config:
repoAllowlist: [github.com/acme/*]
requireApplyProject: true # one project per apply: teriac apply -p <project>
httpRoute:
enabled: true
parentRefs: [{ name: public-gateway, namespace: gateway-system }]
hostnames: [teriac.acme.dev]
dashboard:
oidc:
enabled: true
issuer: https://acme.okta.com/oauth2/default
clientId: 0oa1b2c3d4
existingSecret: teriac-okta # key: client-secret
adminGroups: [platform-admins]Then:
- Point the GitHub App's webhook at
https://teriac.acme.dev/events/github. - Register
https://teriac.acme.dev/auth/oidc/callbackas the redirect URI with Okta. - Check the install with
helm test teriac -n teriac.
Upgrade with helm upgrade teriac oci://ghcr.io/marcdomain/charts/teriac-server --version <new> -n
teriac -f values.yaml. A running apply finishes before the old pod stops.
Routing
| Option | Values | Notes |
|---|---|---|
| Gateway API (recommended) | httpRoute.* |
An HTTPRoute attached to your Gateway (Envoy Gateway, Istio, Cilium, NGINX Gateway Fabric, GKE, …) |
| Ingress | ingress.* |
For clusters that still use an Ingress controller |
| None | service.type |
Port-forward, or your own mesh routing |
To expose only the webhooks publicly, narrow the rule, and reach the dashboard through an internal
gateway or kubectl port-forward:
httpRoute:
enabled: true
parentRefs: [{ name: public-gateway, namespace: gateway-system, sectionName: https }]
hostnames: [teriac.acme.dev]
rules:
- matches: [{ path: { type: PathPrefix, value: /events/ } }]kubectl -n teriac port-forward svc/teriac-teriac-server 8080:80With an Ingress
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: teriac.acme.dev
paths: [{ path: /, pathType: Prefix }]
tls:
- { secretName: teriac-tls, hosts: [teriac.acme.dev] }config.publicUrl defaults to the first Ingress host. Set it yourself with Gateway API.
Dashboard sign-in
| Method | Values | Roles |
|---|---|---|
| Okta / any OIDC provider | dashboard.oidc.* |
adminGroups → admin, allowedGroups → viewer, everyone else is refused |
| Username and password | dashboard.users or usersExistingSecret |
role: admin or viewer per user |
| API token | dashboard.apiToken.existingSecret |
Admin. For automation and /api |
Viewers can see everything. Admins can also release locks and start drift checks.
Make password hashes with node packages/server/src/hash-password.ts. Sessions are signed, HttpOnly
cookies. The chart generates the signing key once and keeps it across upgrades.
Cloud credentials
Terraform runs inside the teriac pod, so give the pod the access your Terraform needs.
Workload identity (recommended): annotate the service account.
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/teriac # AWS IRSA
# iam.gke.io/gcp-service-account: teriac@acme.iam.gserviceaccount.com # GKE
# azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000 # Azure
podLabels:
# azure.workload.identity/use: "true"Secrets as env vars:
extraEnvFrom:
- secretRef: { name: teriac-cloud } # AWS_ACCESS_KEY_ID, ARM_CLIENT_SECRET, …
- secretRef: { name: teriac-chat-tokens } # TERIAC_SLACK_TOKEN, TERIAC_TEAMS_WEBHOOK
extraEnv:
- { name: AWS_REGION, value: eu-central-1 }Common values
| Value | Default | |
|---|---|---|
github.appId / github.existingSecret |
Required. App ID, and a Secret with private-key and webhook-secret |
|
github.apiUrl |
GitHub Enterprise Server API URL, e.g. https://ghe.acme.com/api/v3 |
|
config.repoAllowlist |
[] |
Repos teriac may act on, e.g. github.com/acme/* |
config.requireApplyProject |
false |
Refuse a bare teriac apply; one project at a time |
config.ackReaction |
eyes |
Reaction on a command teriac picked up: eyes, rocket, +1, heart, hooray, laugh, confused, -1 or none |
config.progressReply |
false |
Off: results are posted once and never edited. On: a spinning "Running…" reply edited into the result (needs a public https URL) |
config.commentMode |
new |
new: a new comment for each plan and apply. update: one comment at the top, edited in place |
config.parallelism |
4 |
Plans and applies running at once, shared fairly between PRs |
config.prParallelism |
parallelism − 1 | One PR's share |
config.runTimeoutSeconds |
3600 |
Time limit per Terraform command |
config.defaultVersion |
latest |
Tool version when a project pins none |
config.preinstall |
[] |
e.g. [terraform@1.9.8, tofu@1.8.5] |
config.drift |
true |
Run drift schedules |
config.publicUrl |
first Ingress host | The URL GitHub and browsers use |
config.logLevel |
info |
debug, info, warn or error |
config.verifySignatures |
true |
Check the vendor's signature on downloaded tools; false for mirrors without .sig files. |
repos |
{} |
Full repos.yaml: rules, approvers, channels, people, drift schedules |
extraEnvFrom / extraEnv |
[] |
Cloud credentials, TERIAC_SLACK_TOKEN, TERIAC_TEAMS_WEBHOOK, GITHUB_TOKEN (for OpenTofu/Terragrunt downloads), … |
serviceAccount.annotations |
{} |
Workload identity: IRSA, GKE, Azure |
persistence.size |
20Gi |
Checkouts, saved plans, binaries, provider cache, database |
persistence.existingClaim |
Use a PVC you made | |
backup.enabled |
false |
Back up the database on backup.schedule to the backend you fill in: backup.backend.s3.bucket, .gcs.bucket or .azure.container. An empty volume is restored on start. See Backups |
resources |
250m / 512Mi, limit 2Gi | Raise the memory limit for large plans |
serviceMonitor.enabled |
false |
Prometheus scraping of /metrics |
podDisruptionBudget.enabled |
false |
Stop node drains from interrupting an apply |
networkPolicy.enabled |
false |
Allow inbound traffic only from networkPolicy.ingressFrom |
Every value is listed under all values below, and with comments in values.yaml.
All values
Every value in the chart, with its default.
Image and names
| Value | Default | |
|---|---|---|
image.repository |
ghcr.io/marcdomain/teriac-server |
|
image.tag |
the chart's appVersion | Pin a version, or leave empty to match the chart |
image.pullPolicy |
IfNotPresent |
|
imagePullSecrets |
[] |
For a private mirror of the image |
nameOverride / fullnameOverride |
Resource names are <release>-teriac-server unless overridden |
GitHub
| Value | Default | |
|---|---|---|
github.appId |
Required. The GitHub App's ID | |
github.apiUrl |
GitHub Enterprise Server, e.g. https://ghe.acme.com/api/v3 |
|
github.existingSecret |
A Secret with keys private-key and webhook-secret (recommended) |
|
github.privateKey / github.webhookSecret |
Or inline, and the chart creates the Secret. Don't commit these to git |
Server settings (config)
| Value | Default | Env var |
|---|---|---|
config.repoAllowlist |
[] |
TERIAC_REPO_ALLOWLIST |
config.requireApplyProject |
false |
TERIAC_REQUIRE_APPLY_PROJECT |
config.ackReaction |
eyes |
TERIAC_ACK_REACTION |
config.progressReply |
false |
TERIAC_PROGRESS_REPLY |
config.commentMode |
new |
TERIAC_COMMENT_MODE |
config.parallelism |
4 |
TERIAC_PARALLELISM |
config.prParallelism |
parallelism − 1 | TERIAC_PR_PARALLELISM |
config.runTimeoutSeconds |
3600 |
TERIAC_RUN_TIMEOUT_SECONDS |
config.defaultVersion |
latest |
TERIAC_DEFAULT_VERSION |
config.preinstall |
[] |
TERIAC_PREINSTALL, e.g. [terraform@1.9.8, tofu@1.8.5, terragrunt@0.83.2] |
config.drift |
true |
TERIAC_DRIFT |
config.publicUrl |
first HTTPRoute or Ingress host | TERIAC_PUBLIC_URL |
config.logLevel |
info |
TERIAC_LOG_LEVEL |
config.verifySignatures |
true |
TERIAC_VERIFY_SIGNATURES. |
repos (default {}) is the whole repos.yaml, mounted at /config/repos.yaml.
Dashboard
| Value | Default | |
|---|---|---|
dashboard.oidc.enabled |
false |
Okta, Entra ID (Azure AD), Google, Keycloak, Auth0, … |
dashboard.oidc.name |
Okta |
Sign-in button label |
dashboard.oidc.issuer |
e.g. https://acme.okta.com/oauth2/default |
|
dashboard.oidc.clientId |
||
dashboard.oidc.existingSecret |
A Secret with key client-secret |
|
dashboard.oidc.clientSecret |
Or inline, and the chart creates the Secret | |
dashboard.oidc.scopes |
[openid, profile, email, groups] |
|
dashboard.oidc.groupsClaim |
groups |
|
dashboard.oidc.adminGroups |
[] |
Groups that become admins |
dashboard.oidc.allowedGroups |
[] |
If set, only these groups (and admins) may sign in |
dashboard.users |
[] |
{ username, password_hash, role, name } |
dashboard.usersExistingSecret |
Or a Secret with key users.yaml holding that list |
|
dashboard.apiToken.existingSecret |
A Secret with key api-token |
|
dashboard.sessionHours |
12 |
|
dashboard.sessionSecret.existingSecret |
A Secret with key session-secret (32+ characters). Left empty, the chart generates one and keeps it across upgrades |
Environment
| Value | Default | |
|---|---|---|
extraEnv |
[] |
Extra env vars, e.g. AWS_REGION |
extraEnvFrom |
[] |
Secrets or ConfigMaps as env vars: cloud credentials, chat tokens |
Storage and networking
| Value | Default | |
|---|---|---|
persistence.enabled |
true |
Checkouts, saved plans, tool binaries, provider cache, database |
persistence.size |
20Gi |
|
persistence.storageClass |
cluster default | |
persistence.accessModes |
[ReadWriteOnce] |
|
persistence.existingClaim |
Use a PVC you made | |
backup.* |
Scheduled backups; every value is on the Backups page | |
service.type |
ClusterIP |
|
service.port |
80 |
|
httpRoute.enabled |
false |
Gateway API |
httpRoute.annotations |
{} |
|
httpRoute.parentRefs |
[] |
e.g. [{ name: public-gateway, namespace: gateway-system, sectionName: https }] |
httpRoute.hostnames |
[] |
e.g. [teriac.acme.dev] |
httpRoute.rules |
everything (PathPrefix /) |
Narrow to /events/ to expose only webhooks |
ingress.enabled |
false |
|
ingress.className |
||
ingress.annotations |
{} |
|
ingress.hosts |
teriac.example.com, path / |
|
ingress.tls |
[] |
With TLS set, publicUrl defaults to https:// |
networkPolicy.enabled |
false |
|
networkPolicy.ingressFrom |
[] |
Namespaces allowed in: your ingress controller, Prometheus |
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { memory: 2Gi }
networkPolicy:
enabled: true
ingressFrom:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginxPod
| Value | Default | |
|---|---|---|
serviceAccount.create |
true |
|
serviceAccount.name |
<release>-teriac-server |
|
serviceAccount.annotations |
{} |
Workload identity: IRSA, GKE, Azure |
resources |
requests 250m / 512Mi, limit 2Gi memory |
Raise for large plans |
podSecurityContext |
non-root uid/gid 10001, fsGroup 10001, RuntimeDefault seccomp |
|
securityContext |
no privilege escalation, read-only root filesystem, all capabilities dropped | |
terminationGracePeriodSeconds |
3600 |
Time for running plans and applies to finish |
podAnnotations / podLabels |
{} |
|
nodeSelector / tolerations / affinity |
Standard scheduling | |
podDisruptionBudget.enabled |
false |
Stop node drains from interrupting an apply |
serviceMonitor.enabled |
false |
Prometheus Operator scraping of /metrics |
serviceMonitor.interval |
30s |
|
serviceMonitor.labels |
{} |
Labels your Prometheus selects on |
The security defaults, which suit restricted Pod Security Standards:
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]Rules in values
repos:
channels:
infra-alerts: { type: slack, channel: "#infra-drift", token_env: TERIAC_SLACK_TOKEN }
repos:
- id: github.com/acme/infra
projects:
- match: prod/**
permissions:
apply: [team:platform-admins]
apply_destroy: [admin] # also covers state rm / state mv / force-unlock
force_unlock: [] # single commands can be set too; [] = nobody
approvals: [{ from: team:platform-admins, count: 2 }]
drift:
- { name: prod-nightly, projects: ["prod/**"], schedule: "0 6 * * 1-5", timezone: Europe/Berlin, notify: [infra-alerts] }
extraEnvFrom:
- secretRef: { name: teriac-chat-tokens } # holds TERIAC_SLACK_TOKENPolicy checks, the two-person rule, permissions for Terraform commands, and the
safety settings risk, freezes, protected, secret_scan, break_glass, summary,
keep_approval_on_rebase and stale_base go in the same repos value; see
repos.yaml.
How the chart runs it
- One replica, as a StatefulSet. Locks and history are kept in SQLite on the volume.
- Locked down. It runs as non-root (uid 10001) with a read-only root filesystem, all Linux
capabilities dropped, and the
RuntimeDefaultseccomp profile. - Applies finish before shutdown.
terminationGracePeriodSeconds: 3600lets a running apply finish on upgrades. - Config changes roll the pod. Checksums of the config, secrets and dashboard settings are pod annotations.