feat: helm chart (#16808)

# Add Helm Chart

- Introduces a Twenty Helm chart with sensible defaults: internal
Postgres/Redis, auto DB creation/user, migrations, TLS via cert-manager,
and quickstart docs.

## Feedback requested
- Handling replicas > 1 with local storage (warn/force S3?).
- Defaults/guards for ephemeral pods + S3.
This commit is contained in:
Don Kendall
2026-01-08 07:45:46 -05:00
committed by GitHub
parent 22573ccf03
commit 8630efc3d7
31 changed files with 2052 additions and 4 deletions
@@ -0,0 +1,7 @@
apiVersion: v2
name: twenty
description: A Helm chart to deploy Twenty CRM (server + worker) with optional PostgreSQL and Redis dependencies.
type: application
version: 0.1.0
appVersion: "v1.14.0"
icon: https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg
@@ -0,0 +1,51 @@
# Twenty Helm Chart - Quick Install
## Simple Install
Set your domain and install:
```bash
export DOMAIN=crm.example.com
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace --wait \
--set "server.ingress.hosts[0].host=$DOMAIN" \
--set "server.ingress.tls[0].hosts[0]=$DOMAIN"
```
That's it! The chart will:
- Auto-generate a secure access token
- Create the PostgreSQL database "twenty" and schema "core" automatically
- Run TypeORM migrations via server init
- Enable TLS via cert-manager (acme: true by default for letsencrypt-prod)
## Access the App
Visit: `https://$DOMAIN`
Sign up to create your admin account through the web UI.
## Retrieve System Credentials
App secret token (for configuration/integrations):
```bash
kubectl get secret tokens -n twentycrm -o jsonpath='{.data.accessToken}' | base64 --decode && echo
```
Internal PostgreSQL credentials are managed by the chart and not exposed by default. If you need direct access, create your own user in the database pod or use an external PostgreSQL instance.
Jobs for DB creation and migrations have been removed to simplify deployments; the server handles readiness and migrations at startup.
## Advanced Configuration
See [full README](README.md) for:
- External PostgreSQL/Redis
- S3 storage configuration
- Custom resource limits
## Uninstall
```bash
helm uninstall my-twenty -n twentycrm
kubectl delete namespace twentycrm
```
@@ -0,0 +1,86 @@
# Twenty Helm Chart
Deploy Twenty CRM on Kubernetes with server, worker, PostgreSQL, and Redis components.
## Features
- Server and worker deployments with full env exposure via `values.yaml`.
- Internal PostgreSQL (Spilo) and Redis deployments included.
- PVC-based persistence using dynamic storage classes (no static PV manifests).
- Ingress with configurable annotations, hosts, and TLS.
- Database readiness and migrations handled by server/worker init containers by default.
Standard Kubernetes Jobs for DB creation/user and migrations have been removed to simplify installs. Readiness and migrations run in init containers.
## Quick Start
See [QUICKSTART.md](QUICKSTART.md) for a simple 2-line install with your domain.
## Installing
**Prerequisites:** Kubernetes 1.21+, Helm 3.8+, default StorageClass
Internal DB + Redis (default):
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace
```
External DB/Redis:
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace \
--set db.enabled=false \
--set db.external.host=db.example.com \
--set redisInternal.enabled=false
```
## Key Values
See `values.yaml` for a comprehensive list.
## Notes
- Database URL and Redis URL are composed automatically from chart settings
- Database `twenty` and schema `core` are created automatically by server init container
- No optional jobs: the chart no longer provides separate Jobs for DB or migrations.
- Access token auto-generated (32 chars) if not provided; reuses existing secret if present
- For production, provide a strong `secrets.tokens.accessToken` value via a secure values file; the auto-generated token is a convenience fallback.
- TLS enabled by default via cert-manager (`acme: true`)
- Requires default StorageClass for PVC provisioning
## Testing
```bash
helm lint ./packages/twenty-docker/helm/twenty
helm template my-twenty ./packages/twenty-docker/helm/twenty
helm plugin install https://github.com/quintush/helm-unittest
helm unittest ./packages/twenty-docker/helm/twenty
```
## Storage
**Local (default):** Uses PVCs for persistence
**S3:** Set `storage.type=s3` and provide credentials using a values file. You can either pass credentials directly or reference an existing Kubernetes Secret.
```bash
# values-secrets.yaml (do not commit)
# storage:
# type: s3
# s3:
# bucket: my-bucket
# region: us-east-1
# # Option A: direct values
# accessKeyId: AKIA...
# secretAccessKey: ...
# # Option B: reference a Secret
# # secretName: my-s3-creds
# # accessKeyIdKey: accessKeyId
# # secretAccessKeyKey: secretAccessKey
helm install my-twenty ./packages/twenty-docker/helm/twenty -f values-secrets.yaml
```
## Production Tips
- **Image versioning:** The chart defaults to `Chart.yaml`'s `appVersion` (currently v1.14.0). Override via `image.tag` in values to pin a different version or use `latest` for rolling updates.
- **Keep secrets secure:** Avoid `--set` for sensitive values; use `-f values-secrets.yaml` or reference existing Kubernetes Secrets via `server.extraEnvFrom`.
- S3 credentials can be referenced via `storage.s3.secretName + accessKeyIdKey/secretAccessKeyKey` to avoid embedding them in pod specs.
@@ -0,0 +1,17 @@
Thank you for installing Twenty!
To access the application:
- If ingress enabled:
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) }}
- Primary host: {{ (index .Values.server.ingress.hosts 0).host | default "<set a host>" }}
{{- else }}
- Primary host: <set a host>
{{- end }}
- URL: {{ include "twenty.serverUrl" . }}
- If ingress disabled: expose the service as needed (e.g., `kubectl port-forward svc/{{ include "twenty.fullname" . }}-server 3000:{{ .Values.server.service.port }}` or create a LoadBalancer/NodePort).
Configuration:
- Using internal DB: {{ ternary "yes" "no" .Values.db.enabled }}
- Using external DB: {{ ternary "yes" "no" (not .Values.db.enabled) }}
- Using internal Redis: {{ ternary "yes" "no" .Values.redisInternal.enabled }}
@@ -0,0 +1,172 @@
{{- define "twenty.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "twenty.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "twenty.namespace" -}}
{{ .Release.Namespace }}
{{- end -}}
{{/* Server image fields merged with globals */}}
{{- define "twenty.server.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.server.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.server.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.server.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Worker image fields merged with globals */}}
{{- define "twenty.worker.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.worker.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.worker.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.worker.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Extract parts of image helper */}}
{{- define "twenty.image.repository" -}}
{{- regexFind "^([^:|]+)" . -}}
{{- end -}}
{{- define "twenty.image.tag" -}}
{{- regexFind ":([^|]+)" . | trimPrefix ":" -}}
{{- end -}}
{{- define "twenty.image.pullPolicy" -}}
{{- regexFind "\\|(.+)$" . | trimPrefix "|" -}}
{{- end -}}
{{/* Compose DB connection URL */}}
{{- define "twenty.dbUrl" -}}
{{- if .Values.server.env.PG_DATABASE_URL -}}
{{- .Values.server.env.PG_DATABASE_URL -}}
{{- else if .Values.db.enabled -}}
{{- $host := printf "%s-db" (include "twenty.fullname" .) -}}
{{- $user := .Values.db.internal.appUser | default "twenty_app_user" -}}
{{- $pass := .Values.db.internal.appPassword | default (randAlphaNum 32) -}}
{{- $db := .Values.db.internal.database | default "twenty" -}}
{{- printf "postgres://%s:%s@%s.%s.svc.cluster.local/%s" $user $pass $host (include "twenty.namespace" .) $db -}}
{{- else -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $pass := .Values.db.external.password | default "postgres" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
{{- printf "%s://%s:%s@%s:%v/%s%s" $scheme $user $pass $host $port $db $qs -}}
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
{{- .Values.server.env.REDIS_URL -}}
{{- else if .Values.redisInternal.enabled -}}
{{- $host := printf "%s-redis" (include "twenty.fullname" .) -}}
{{- printf "redis://%s.%s.svc.cluster.local:6379" $host (include "twenty.namespace" .) -}}
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from ingress, else service */}}
{{- define "twenty.serverUrl" -}}
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) -}}
{{- $host := (index .Values.server.ingress.hosts 0).host -}}
{{- $tls := gt (len .Values.server.ingress.tls) 0 -}}
{{- $scheme := ternary "https" "http" $tls -}}
{{- $port := ternary 443 80 $tls -}}
{{- printf "%s://%s:%v" $scheme $host $port -}}
{{- else -}}
{{- $svc := printf "%s-server" (include "twenty.fullname" .) -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $port := .Values.server.service.port | default 3000 -}}
{{- printf "http://%s.%s.svc.cluster.local:%v" $svc $ns $port -}}
{{- end -}}
{{- end -}}
{{/* Tokens secret name */}}
{{- define "twenty.secret.tokens.name" -}}
{{- .Values.secrets.tokens.name | default "tokens" -}}
{{- end -}}
{{/* Access token value: reuse existing secret if present, else provided value, else generated */}}
{{- define "twenty.secret.tokens.access" -}}
{{- $name := include "twenty.secret.tokens.name" . -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $existing := lookup "v1" "Secret" $ns $name -}}
{{- if and $existing $existing.data.accessToken -}}
{{- b64dec $existing.data.accessToken -}}
{{- else if .Values.secrets.tokens.accessToken -}}
{{- .Values.secrets.tokens.accessToken -}}
{{- else -}}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end -}}
{{/* Server container port */}}
{{- define "twenty.server.containerPort" -}}
{{- .Values.server.service.port | default 3000 -}}
{{- end -}}
{{/* Storage type: prefer top-level storage.type, else legacy server.env.STORAGE_TYPE, else local */}}
{{- define "twenty.storageType" -}}
{{- if .Values.storage.type -}}
{{- .Values.storage.type -}}
{{- else if .Values.server.env.STORAGE_TYPE -}}
{{- .Values.server.env.STORAGE_TYPE -}}
{{- else -}}
local
{{- end -}}
{{- end -}}
{{/* Additional storage env vars (e.g., S3) */}}
{{- define "twenty.storageEnv" -}}
{{- if eq (include "twenty.storageType" .) "s3" -}}
{{- with .Values.storage.s3.bucket }}
- name: STORAGE_S3_NAME
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.region }}
- name: STORAGE_S3_REGION
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.endpoint }}
- name: STORAGE_S3_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.accessKeyIdKey }}
- name: STORAGE_S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.accessKeyIdKey | quote }}
{{- else }}
{{- with .Values.storage.s3.accessKeyId }}
- name: STORAGE_S3_ACCESS_KEY_ID
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.secretAccessKeyKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.secretAccessKeyKey | quote }}
{{- else }}
{{- with .Values.storage.s3.secretAccessKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,62 @@
{{- if .Values.db.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: db
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
volumes:
{{- if .Values.db.internal.persistence.enabled }}
- name: db-data
persistentVolumeClaim:
claimName: {{ if .Values.db.internal.persistence.existingClaim }}{{ .Values.db.internal.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-db{{ end }}
{{- end }}
containers:
- name: db
image: {{ .Values.db.internal.image.repository }}:{{ .Values.db.internal.image.tag }}
imagePullPolicy: {{ .Values.db.internal.image.pullPolicy | default "IfNotPresent" }}
env:
- name: PGUSER_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: username
- name: PGPASSWORD_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: SPILO_PROVIDER
value: {{ .Values.db.internal.env.SPILO_PROVIDER | quote }}
- name: ALLOW_NOSSL
value: {{ .Values.db.internal.env.ALLOW_NOSSL | quote }}
ports:
- containerPort: 5432
name: tcp
protocol: TCP
resources:
{{- toYaml .Values.db.internal.resources | nindent 12 }}
{{- if .Values.db.internal.persistence.enabled }}
volumeMounts:
- name: db-data
mountPath: /home/postgres/pgdata
{{- end }}
{{- end }}
@@ -0,0 +1,57 @@
{{- if .Values.redisInternal.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: redis
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: {{ .Values.redisInternal.image.repository }}:{{ .Values.redisInternal.image.tag }}
imagePullPolicy: {{ default "IfNotPresent" .Values.redisInternal.image.pullPolicy }}
command:
- redis-stack-server
args:
- "--port"
- {{ .Values.redisInternal.service.port | quote }}
- "--maxmemory-policy"
- "noeviction"
ports:
- containerPort: {{ .Values.redisInternal.service.port }}
name: redis
protocol: TCP
resources:
{{- toYaml .Values.redisInternal.resources | nindent 12 }}
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -0,0 +1,196 @@
{{- if .Values.server.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-server
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
replicas: {{ .Values.server.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: server
strategy:
{{- if or .Values.server.dockerDataPersistence.enabled .Values.server.persistence.enabled }}
type: Recreate
{{- else }}
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
{{- end }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
volumes:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
persistentVolumeClaim:
claimName: {{ if .Values.server.dockerDataPersistence.existingClaim }}{{ .Values.server.dockerDataPersistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-docker-data{{ end }}
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
persistentVolumeClaim:
claimName: {{ if .Values.server.persistence.existingClaim }}{{ .Values.server.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-server{{ end }}
{{- end }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
{{- if .Values.db.enabled }}
- name: ensure-database-exists
image: postgres:16-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: APP_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: appPassword
command:
- sh
- -c
- |
DBNAME={{ .Values.db.internal.database | default "twenty" }}
APP_USER={{ .Values.db.internal.appUser | default "twenty_app_user" }}
export PGPASSWORD
export APP_PASSWORD
echo "Creating database ${DBNAME} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON SEQUENCES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
value: {{ .Values.server.env.SIGN_IN_PREFILLED | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: ACCESS_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.ACCESS_TOKEN_EXPIRES_IN | quote }}
- name: LOGIN_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.LOGIN_TOKEN_EXPIRES_IN | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
containerPort: {{ include "twenty.server.containerPort" . }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 40
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 5
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
volumeMounts:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
mountPath: /app/docker-data
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
mountPath: /app/packages/twenty-server/.local-storage
{{- end }}
{{- with .Values.server.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.server.stdin }}
stdin: {{ .Values.server.stdin }}
{{- end }}
{{- if .Values.server.tty }}
tty: {{ .Values.server.tty }}
{{- end }}
{{- end }}
@@ -0,0 +1,77 @@
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-worker
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: worker
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
containers:
- name: worker
{{- $img := include "twenty.worker.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
{{- toYaml .Values.worker.command | nindent 12 }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
stdin: {{ default true .Values.worker.stdin }}
tty: {{ default true .Values.worker.tty }}
{{- end }}
@@ -0,0 +1,47 @@
{{- if and .Values.server.enabled .Values.server.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "twenty.fullname" . }}
namespace: {{ include "twenty.namespace" . }}
annotations:
{{- if .Values.server.ingress.acme }}
cert-manager.io/cluster-issuer: "letsencrypt-prod"
{{- end }}
{{- with .Values.server.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.server.ingress.className }}
ingressClassName: {{ .Values.server.ingress.className }}
{{- end }}
rules:
{{- range .Values.server.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- if .paths }}
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "twenty.fullname" $ }}-server
port:
name: http-tcp
{{- end }}
{{- else }}
- path: /
pathType: Prefix
backend:
service:
name: {{ include "twenty.fullname" $ }}-server
port:
name: http-tcp
{{- end }}
{{- end }}
{{- if .Values.server.ingress.tls }}
tls:
{{- toYaml .Values.server.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.db.enabled .Values.db.internal.persistence.enabled (not .Values.db.internal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
accessModes:
{{ toYaml .Values.db.internal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.db.internal.persistence.size }}
{{- if .Values.db.internal.persistence.storageClass }}
storageClassName: {{ .Values.db.internal.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.server.enabled .Values.server.dockerDataPersistence.enabled (not .Values.server.dockerDataPersistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-docker-data
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
accessModes:
{{ toYaml .Values.server.dockerDataPersistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.server.dockerDataPersistence.size }}
{{- if .Values.server.dockerDataPersistence.storageClass }}
storageClassName: {{ .Values.server.dockerDataPersistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.server.enabled .Values.server.persistence.enabled (not .Values.server.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-server
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
accessModes:
{{ toYaml .Values.server.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.server.persistence.size }}
{{- if .Values.server.persistence.storageClass }}
storageClassName: {{ .Values.server.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,24 @@
{{- if .Values.db.enabled }}
{{- $ns := include "twenty.namespace" . -}}
{{- $existing := lookup "v1" "Secret" $ns (printf "%s-db-superuser" (include "twenty.fullname" .)) -}}
{{- $user := .Values.db.internal.env.PGUSER_SUPERUSER | default "postgres" -}}
{{- $pass := .Values.db.internal.env.PGPASSWORD_SUPERUSER | default "" -}}
{{- if and $existing $existing.data.password -}}
{{- $pass = (b64dec $existing.data.password) -}}
{{- else if eq $pass "" -}}
{{- $pass = randAlphaNum 32 -}}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "twenty.fullname" . }}-db-superuser
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
type: Opaque
stringData:
username: {{ $user | quote }}
password: {{ $pass | quote }}
{{- end }}
@@ -0,0 +1,22 @@
{{- $secretName := printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- $existingSecret := lookup "v1" "Secret" (include "twenty.namespace" .) $secretName -}}
{{- $appPassword := "" -}}
{{- if $existingSecret -}}
{{- $appPassword = index $existingSecret.data "appPassword" | b64dec -}}
{{- else -}}
{{- $appPassword = .Values.db.internal.appPassword | default (randAlphaNum 32) -}}
{{- end -}}
{{- $appUser := .Values.db.internal.appUser | default "twenty_app_user" -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
type: Opaque
stringData:
url: {{ printf "postgres://%s:%s@%s-db.%s.svc.cluster.local/%s" (urlquery $appUser) (urlquery $appPassword) (include "twenty.fullname" .) (include "twenty.namespace" .) (.Values.db.internal.database | default "twenty") | quote }}
appPassword: {{ $appPassword | quote }}
@@ -0,0 +1,13 @@
{{- if .Values.secrets.tokens.create }}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.secrets.tokens.name | default "tokens" }}
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: tokens
stringData:
accessToken: {{ include "twenty.secret.tokens.access" . | quote }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.db.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: db
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.redisInternal.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: redis
ports:
- port: {{ .Values.redisInternal.service.port }}
targetPort: {{ .Values.redisInternal.service.port }}
protocol: TCP
name: redis
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.server.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "twenty.fullname" . }}-server
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
type: {{ .Values.server.service.type | default "ClusterIP" }}
selector:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: server
ports:
- name: http-tcp
port: {{ .Values.server.service.port }}
targetPort: {{ include "twenty.server.containerPort" . }}
protocol: TCP
{{- end }}
@@ -0,0 +1,43 @@
suite: app user and database initialization
templates:
- templates/secret-db-url.yaml
- templates/deployment-server.yaml
release:
name: my-twenty
namespace: default
tests:
- it: db-url secret includes app password
template: templates/secret-db-url.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- isKind:
of: Secret
- equal:
path: metadata.name
value: my-twenty-twenty-db-url
- isNotEmpty:
path: stringData.appPassword
- it: server init container creates app user with proper permissions
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: test_user
db.internal.database: testdb
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: CREATE USER
- it: db url uses app user not superuser
template: templates/secret-db-url.yaml
set:
db.enabled: true
db.internal.appUser: my_app
db.internal.database: mydb
asserts:
- matchRegex:
path: stringData.url
pattern: ^postgres://my_app:.*@my-twenty-twenty-db
@@ -0,0 +1,120 @@
suite: ingress rendering
templates:
- templates/ingress.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders with host and paths
set:
server.ingress.enabled: true
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
server.ingress.tls:
- hosts:
- twenty.example.com
secretName: twenty-tls
asserts:
- equal:
path: spec.rules[0].host
value: twenty.example.com
- equal:
path: spec.rules[0].http.paths[0].path
value: /
- equal:
path: spec.rules[0].http.paths[0].pathType
value: Prefix
- equal:
path: spec.tls[0].hosts[0]
value: twenty.example.com
- equal:
path: spec.tls[0].secretName
value: twenty-tls
- it: adds cert-manager annotation when acme is true
set:
server.ingress.enabled: true
server.ingress.acme: true
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
asserts:
- equal:
path: metadata.annotations["cert-manager.io/cluster-issuer"]
value: letsencrypt-prod
- it: supports multiple hosts with different paths
set:
server.ingress.enabled: true
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
- host: crm.example.com
paths:
- path: /api
pathType: Prefix
- path: /graphql
pathType: Exact
asserts:
- equal:
path: spec.rules[0].host
value: twenty.example.com
- equal:
path: spec.rules[1].host
value: crm.example.com
- equal:
path: spec.rules[1].http.paths[0].path
value: /api
- equal:
path: spec.rules[1].http.paths[1].path
value: /graphql
- equal:
path: spec.rules[1].http.paths[1].pathType
value: Exact
- it: does not render when ingress disabled
set:
server.ingress.enabled: false
asserts:
- hasDocuments:
count: 0
- it: supports custom annotations
set:
server.ingress.enabled: true
server.ingress.annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
custom.annotation/key: "value"
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
asserts:
- equal:
path: metadata.annotations["nginx.ingress.kubernetes.io/proxy-body-size"]
value: "50m"
- equal:
path: metadata.annotations["custom.annotation/key"]
value: "value"
- it: supports custom className
set:
server.ingress.enabled: true
server.ingress.className: nginx
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
asserts:
- equal:
path: spec.ingressClassName
value: nginx
@@ -0,0 +1,106 @@
suite: pvc configuration
templates:
- templates/pvc-server.yaml
- templates/pvc-db-internal.yaml
- templates/pvc-docker-data.yaml
release:
name: my-twenty
namespace: default
tests:
- it: server pvc renders when persistence enabled
template: templates/pvc-server.yaml
set:
server.persistence.enabled: true
server.persistence.size: 10Gi
asserts:
- isKind:
of: PersistentVolumeClaim
- equal:
path: metadata.name
value: my-twenty-twenty-server
- equal:
path: spec.resources.requests.storage
value: 10Gi
- contains:
path: spec.accessModes
content: ReadWriteOnce
- it: server pvc does not render when persistence disabled
template: templates/pvc-server.yaml
set:
server.persistence.enabled: false
asserts:
- hasDocuments:
count: 0
- it: server pvc renders with correct defaults
template: templates/pvc-server.yaml
set:
server.persistence.enabled: true
asserts:
- isKind:
of: PersistentVolumeClaim
- it: server pvc omits storageClassName when set to hyphen
template: templates/pvc-server.yaml
set:
server.persistence.enabled: true
server.persistence.storageClass: ""
asserts:
- isNull:
path: spec.storageClassName
- it: db-internal pvc renders with correct size
template: templates/pvc-db-internal.yaml
set:
db.enabled: true
db.internal.persistence.enabled: true
db.internal.persistence.size: 20Gi
asserts:
- isKind:
of: PersistentVolumeClaim
- equal:
path: metadata.name
value: my-twenty-twenty-db
- isKind:
of: PersistentVolumeClaim
- it: db-internal pvc does not render when disabled
template: templates/pvc-db-internal.yaml
set:
db.enabled: false
asserts:
- hasDocuments:
count: 0
- it: db-internal pvc renders when enabled
template: templates/pvc-db-internal.yaml
set:
db.enabled: true
db.internal.persistence.enabled: true
asserts:
- hasDocuments:
count: 1
- it: docker-data pvc renders when enabled
template: templates/pvc-docker-data.yaml
set:
server.enabled: true
server.dockerDataPersistence.enabled: true
server.dockerDataPersistence.size: 5Gi
asserts:
- isKind:
of: PersistentVolumeClaim
- it: all pvcs use ReadWriteOnce access mode
templates:
- templates/pvc-server.yaml
- templates/pvc-db-internal.yaml
set:
server.persistence.enabled: true
db.enabled: true
db.internal.persistence.enabled: true
asserts:
- contains:
path: spec.accessModes
content: ReadWriteOnce
@@ -0,0 +1,91 @@
suite: redis and database switching
templates:
- templates/deployment-redis-internal.yaml
- templates/service-redis-internal.yaml
- templates/deployment-db-internal.yaml
- templates/service-db-internal.yaml
release:
name: my-twenty
namespace: default
tests:
- it: internal redis deployment renders when enabled
template: templates/deployment-redis-internal.yaml
set:
redisInternal.enabled: true
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: my-twenty-twenty-redis
- it: internal redis does not render when disabled
template: templates/deployment-redis-internal.yaml
set:
redisInternal.enabled: false
asserts:
- hasDocuments:
count: 0
- it: redis service renders when internal redis enabled
template: templates/service-redis-internal.yaml
set:
redisInternal.enabled: true
asserts:
- isKind:
of: Service
- equal:
path: metadata.name
value: my-twenty-twenty-redis
- it: redis uses args for port configuration
template: templates/deployment-redis-internal.yaml
set:
redisInternal.enabled: true
redisInternal.service.port: 6379
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: --port
- contains:
path: spec.template.spec.containers[0].args
content: "6379"
- it: internal db deployment renders when enabled
template: templates/deployment-db-internal.yaml
set:
db.enabled: true
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: my-twenty-twenty-db
- it: internal db does not render when disabled
template: templates/deployment-db-internal.yaml
set:
db.enabled: false
asserts:
- hasDocuments:
count: 0
- it: internal db uses Recreate strategy for PVC
template: templates/deployment-db-internal.yaml
set:
db.enabled: true
asserts:
- equal:
path: spec.strategy.type
value: Recreate
- it: db service renders when internal db enabled
template: templates/service-db-internal.yaml
set:
db.enabled: true
asserts:
- isKind:
of: Service
- equal:
path: metadata.name
value: my-twenty-twenty-db
@@ -0,0 +1,122 @@
suite: schema permissions (core for app tables, public for extensions)
templates:
- templates/deployment-server.yaml
release:
name: my-twenty
namespace: default
tests:
# Core Schema Tests
# ================
# Twenty uses a dedicated 'core' schema for all application tables and the TypeORM
# migrations tracking table. This provides namespace isolation and clarity.
- it: ensure-database-exists creates core schema
template: templates/deployment-server.yaml
set:
db.enabled: true
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: CREATE SCHEMA IF NOT EXISTS core
- it: app user gets all privileges on core schema
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: GRANT ALL PRIVILEGES ON SCHEMA core
- it: app user gets all privileges on core tables
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA core
- it: default privileges set for future core tables
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON TABLES
- it: default privileges set for future core sequences
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON SEQUENCES
# Public Schema Tests
# ==================
# PostgreSQL extensions (like unaccent for accent-insensitive text search) are
# created in the 'public' schema. Twenty's migrations create these functions, so
# the app user needs full privileges on the public schema as well.
- it: app user gets all privileges on public schema
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: GRANT ALL PRIVILEGES ON SCHEMA public
- it: app user gets all privileges on public tables
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public
- it: default privileges set for future public tables
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES
- it: default privileges set for future public sequences
template: templates/deployment-server.yaml
set:
db.enabled: true
db.internal.appUser: twenty_app_user
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES
# TypeORM Migration Tests
# ======================
# TypeORM migrations are configured to use the core.datasource which targets the
# 'core' schema. This ensures the _typeorm_migrations table and all application
# tables use the dedicated core schema.
- it: migrations run against core datasource
template: templates/deployment-server.yaml
set:
db.enabled: true
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="run-migrations")].command[2]
pattern: core\.datasource
@@ -0,0 +1,52 @@
suite: secret generation
templates:
- templates/secret-tokens.yaml
- templates/secret-db-superuser.yaml
- templates/secret-db-url.yaml
release:
name: my-twenty
namespace: default
tests:
- it: generates tokens
template: templates/secret-tokens.yaml
set:
secrets.tokens.create: true
asserts:
- isKind:
of: Secret
- equal:
path: metadata.name
value: tokens
# db-user secret removed; chart no longer manages app user credentials
- it: db-superuser secret generates password for internal DB
template: templates/secret-db-superuser.yaml
set:
db.enabled: true
asserts:
- isKind:
of: Secret
- equal:
path: metadata.name
value: my-twenty-twenty-db-superuser
- it: db-superuser secret does not render when internal DB disabled
template: templates/secret-db-superuser.yaml
set:
db.enabled: false
asserts:
- hasDocuments:
count: 0
- it: db-url secret renders for internal database
template: templates/secret-db-url.yaml
set:
db.enabled: true
db.internal.database: appdb
asserts:
- isKind:
of: Secret
- equal:
path: metadata.name
value: my-twenty-twenty-db-url
@@ -0,0 +1,71 @@
suite: server url
templates:
- templates/deployment-server.yaml
release:
name: my-twenty
namespace: default
tests:
- it: derives from ingress with paths
set:
server.ingress.enabled: true
server.ingress.hosts:
- host: crm.example.com
paths:
- path: /
pathType: Prefix
server.ingress.tls:
- secretName: example-tls
hosts:
- crm.example.com
asserts:
- equal:
path: spec.template.spec.containers[0].env[0].value
value: "https://crm.example.com:443"
- it: falls back to service when ingress disabled
set:
server.ingress.enabled: false
server.service.port: 3000
server.env.SERVER_URL: ""
asserts:
- matchRegex:
path: spec.template.spec.containers[0].env[0].value
pattern: ^http://my-twenty-twenty-server\.default\.svc\.cluster\.local:3000$
---
suite: ingress configuration
templates:
- templates/ingress.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders with host and paths
set:
server.ingress.hosts:
- host: twenty.example.com
paths:
- path: /
pathType: Prefix
server.ingress.tls:
- hosts:
- twenty.example.com
secretName: twenty-tls
asserts:
- equal:
path: spec.rules[0].host
value: twenty.example.com
- equal:
path: spec.rules[0].http.paths[0].path
value: /
- equal:
path: spec.tls[0].hosts[0]
value: twenty.example.com
- equal:
path: spec.tls[0].secretName
value: twenty-tls
- it: adds cert-manager annotation when acme true
set:
server.ingress.acme: true
asserts:
- equal:
path: metadata.annotations[cert-manager.io/cluster-issuer]
value: letsencrypt-prod
@@ -0,0 +1,46 @@
suite: storage env s3
templates:
- templates/deployment-server.yaml
release:
name: my-twenty
namespace: default
tests:
- it: injects S3 env vars when storage.type is s3
set:
storage.type: s3
storage.s3.bucket: my-bucket
storage.s3.region: us-east-1
storage.s3.endpoint: https://s3.amazonaws.com
storage.s3.accessKeyId: AKIA
storage.s3.secretAccessKey: secret
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_TYPE
value: s3
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_S3_NAME
value: my-bucket
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_S3_REGION
value: us-east-1
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_S3_ENDPOINT
value: https://s3.amazonaws.com
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_S3_ACCESS_KEY_ID
value: AKIA
- contains:
path: spec.template.spec.containers[0].env
content:
name: STORAGE_S3_SECRET_ACCESS_KEY
value: secret
@@ -0,0 +1,76 @@
suite: worker deployment
templates:
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders with correct name and labels
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: my-twenty-twenty-worker
- equal:
path: metadata.labels["app.kubernetes.io/name"]
value: twenty
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: worker
- it: uses db-url secret for database connection
set:
db.enabled: true
asserts:
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="PG_DATABASE_URL")].valueFrom.secretKeyRef.name
value: my-twenty-twenty-db-url
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="PG_DATABASE_URL")].valueFrom.secretKeyRef.key
value: url
- it: does not mount storage by default
asserts:
- isNull:
path: spec.template.spec.volumes
- it: uses correct command for worker process
asserts:
- contains:
path: spec.template.spec.containers[0].command
content: yarn
- contains:
path: spec.template.spec.containers[0].command
content: worker:prod
- it: sets replica count to 1 by default
asserts:
- equal:
path: spec.replicas
value: 1
- it: supports local storage configuration
set:
storage.type: local
asserts:
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="STORAGE_TYPE")].value
value: local
- it: uses custom image when specified
set:
worker.image.repository: custom/twenty
worker.image.tag: v2.0.0
asserts:
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: "^custom/twenty:v2.0.0$"
- it: falls back to Chart.AppVersion when tag not specified
set:
worker.image.tag: ""
asserts:
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: ":v1.14.0$"
@@ -0,0 +1,182 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Twenty Helm Chart Values",
"type": "object",
"additionalProperties": true,
"properties": {
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"nameOverride": { "type": "string" },
"fullnameOverride": { "type": "string" },
"namespaceOverride": { "type": "string" },
"secrets": {
"type": "object",
"properties": {
"tokens": {
"type": "object",
"properties": {
"create": { "type": "boolean" },
"name": { "type": "string" },
"accessToken": { "type": "string" }
},
"required": ["create", "name"]
}
}
},
"server": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": {
"type": "integer",
"minimum": 1,
"description": "Number of server replicas. When using local storage with ReadWriteOnce (RWO) PersistentVolumeClaims (the default), multiple replicas cannot share the same PVC and pods will fail to schedule if replicaCount > 1. Use replicaCount = 1 for local/RWO storage, or configure a shared storage backend such as S3 before increasing this value."
},
"podSecurityContext": { "type": "object" },
"containerSecurityContext": { "type": "object" },
"env": {
"type": "object",
"properties": {
"NODE_PORT": { "type": "integer", "minimum": 1 },
"PG_DATABASE_URL": { "type": "string" },
"REDIS_URL": { "type": "string" },
"SIGN_IN_PREFILLED": { "type": "string" },
"STORAGE_TYPE": { "type": "string" },
"ACCESS_TOKEN_EXPIRES_IN": { "type": "string" },
"LOGIN_TOKEN_EXPIRES_IN": { "type": "string" },
"EMAIL_DRIVER": { "type": "string" },
"EMAIL_SMTP_HOST": { "type": "string" },
"EMAIL_SMTP_PORT": { "type": "integer" },
"EMAIL_SMTP_USER": { "type": "string" },
"EMAIL_SMTP_PASSWORD": { "type": "string" },
"EMAIL_SMTP_NO_TLS": { "type": "boolean" },
"EMAIL_FROM_ADDRESS": { "type": "string" },
"EMAIL_FROM_NAME": { "type": "string" },
"EMAIL_SYSTEM_ADDRESS": { "type": "string" },
"IS_EMAIL_VERIFICATION_REQUIRED": { "type": "boolean" },
"EMAIL_VERIFICATION_TOKEN_EXPIRES_IN": { "type": "string" },
"PASSWORD_RESET_TOKEN_EXPIRES_IN": { "type": "string" }
}
},
"service": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1 },
"sessionAffinity": { "type": "string", "enum": ["None", "ClientIP"] },
"sessionAffinityTimeoutSeconds": { "type": "integer", "minimum": 1 }
}
},
"ingress": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"className": { "type": "string" },
"acme": { "type": "boolean" },
"hosts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"host": { "type": "string" },
"paths": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"pathType": { "type": "string", "enum": ["Prefix", "Exact", "ImplementationSpecific"] }
}
}
}
}
}
},
"tls": { "type": "array" }
}
},
"persistence": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"existingClaim": { "type": "string" },
"storageClass": { "type": "string" },
"accessModes": { "type": "array", "items": { "type": "string" } },
"size": { "type": "string" }
}
},
"dockerDataPersistence": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"existingClaim": { "type": "string" },
"storageClass": { "type": "string" },
"accessModes": { "type": "array", "items": { "type": "string" } },
"size": { "type": "string" }
}
}
}
},
"worker": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": { "type": "integer", "minimum": 1 },
"podSecurityContext": { "type": "object" },
"containerSecurityContext": { "type": "object" },
"env": {
"type": "object",
"properties": {
"PG_DATABASE_URL": { "type": "string" },
"REDIS_URL": { "type": "string" },
"STORAGE_TYPE": { "type": "string" },
"DISABLE_DB_MIGRATIONS": { "type": "string" }
}
}
}
},
"storage": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["local", "s3"] },
"s3": {
"type": "object",
"properties": {
"bucket": { "type": "string" },
"region": { "type": "string" },
"endpoint": { "type": "string" },
"accessKeyId": { "type": "string" },
"secretAccessKey": { "type": "string" },
"secretName": { "type": "string" },
"accessKeyIdKey": { "type": "string" },
"secretAccessKeyKey": { "type": "string" }
}
}
},
"required": ["type"],
"allOf": [
{
"if": { "properties": { "type": { "const": "s3" } }, "required": ["type"] },
"then": {
"required": ["s3"],
"properties": {
"s3": {
"required": ["bucket", "region", "accessKeyId", "secretAccessKey"]
}
}
}
}
]
}
}
}
@@ -0,0 +1,169 @@
# Default image used by all components
image:
repository: twentycrm/twenty
tag: "" # defaults to Chart.yaml appVersion
pullPolicy: IfNotPresent
nameOverride: ""
fullnameOverride: ""
# Global security context (uid/gid for all pods)
securityContext:
runAsUser: 1000
fsGroup: 1000
# Storage backend: local or s3
storage:
type: local
s3:
bucket: ""
region: ""
endpoint: ""
accessKeyId: ""
secretAccessKey: ""
# Auth tokens (random if not provided)
secrets:
tokens:
create: true
name: tokens
accessToken: ""
# Server deployment
server:
enabled: true
replicaCount: 1
image: {} # override repository/tag/pullPolicy per component
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1024Mi
env:
SIGN_IN_PREFILLED: "false"
ACCESS_TOKEN_EXPIRES_IN: "7d"
LOGIN_TOKEN_EXPIRES_IN: "1h"
service:
type: ClusterIP
port: 3000
ingress:
enabled: true
className: nginx
acme: true # add cert-manager annotation for Let's Encrypt
annotations: {}
hosts:
- host: crm.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: twenty-tls
hosts:
- crm.example.com
persistence:
enabled: true
size: 10Gi
storageClass: ""
existingClaim: ""
accessModes:
- ReadWriteOnce
dockerDataPersistence:
enabled: true
size: 100Mi
storageClass: ""
existingClaim: ""
accessModes:
- ReadWriteOnce
extraVolumeMounts: []
# Worker deployment
worker:
enabled: true
replicaCount: 1
image: {}
command: ["yarn", "worker:prod"]
resources:
requests:
cpu: 250m
memory: 1024Mi
limits:
cpu: 1000m
memory: 2048Mi
# PostgreSQL
db:
enabled: true
internal:
database: twenty
appUser: twenty_app_user
appPassword: "" # random if empty
image:
repository: twentycrm/twenty-postgres-spilo
tag: 3.3-p2
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1024Mi
persistence:
enabled: true
size: 10Gi
storageClass: ""
existingClaim: ""
accessModes:
- ReadWriteOnce
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
SPILO_PROVIDER: local
ALLOW_NOSSL: "true"
external:
host: ""
port: 5432
user: twenty_app_user
password: ""
database: twenty
ssl: false
# Redis
redisInternal:
enabled: true
image:
repository: redis/redis-stack-server
tag: 7.2.0-v10
pullPolicy: IfNotPresent
resources:
requests:
cpu: 250m
memory: 1024Mi
limits:
cpu: 500m
memory: 2048Mi
service:
port: 6379
persistence:
enabled: false
size: 1Gi
storageClass: ""
existingClaim: ""
accessModes:
- ReadWriteOnce
# External Redis (when redisInternal.enabled=false)
redis:
external:
host: ""
port: 6379
+20 -4
View File
@@ -1,11 +1,11 @@
# README
# Kubernetes for Twenty CRM (Helm)
DISCLAIMER: The k8s and podman deployments are not maintained by the core team.
These files are provided and maintained by the community. Twenty core team
maintains support for docker deployment.
## Overview
This repository contains Kubernetes manifests and Terraform files to help you deploy and manage the TwentyCRM application. The files are located in the `packages/twenty-docker/k8s` directory.
This repository contains a world-class Helm chart and (legacy) raw Kubernetes manifests plus Terraform files to help you deploy and manage the Twenty CRM application. The chart is located at `packages/twenty-docker/helm/twenty` and supersedes the manifests in `packages/twenty-docker/k8s/manifests`.
## Prerequisites
@@ -13,9 +13,25 @@ Before using these files, ensure you have the following installed and configured
- Kubernetes cluster (e.g., Minikube, EKS, GKE)
- kubectl
- Helm 3
- Terraform
- Docker
## Helm Chart (Recommended)
**Quick install:**
```bash
export DOMAIN=your-domain.com
helm install my-twenty packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace --wait \
--set server.ingress.hosts[0].host=$DOMAIN \
--set server.ingress.hosts[0].paths[0].path=/ \
--set server.ingress.hosts[0].paths[0].pathType=Prefix \
--set server.ingress.tls[0].hosts[0]=$DOMAIN
```
See [QUICKSTART](../helm/twenty/QUICKSTART.md) and [chart README](../helm/twenty/README.md) for details.
## Setup Instructions
### Step 1: Clone the Repository
@@ -24,7 +40,7 @@ Clone the repository to your local machine:
``` bash
git clone https://github.com/twentyhq/twenty.git
cd twentycrm/packages/twenty-docker/k8s
cd twenty/packages/twenty-docker/k8s
```
### Step 2: Customize the Manifests and Terraform Files
@@ -59,7 +75,7 @@ cd twentycrm/packages/twenty-docker/k8s
## OR
### Step 3: Deploy with Kubernetes Manifests
### Step 3 (Alternative): Deploy with Kubernetes Manifests
1. Navigate to the Kubernetes manifests directory: