snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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 }}

View File

@@ -0,0 +1,213 @@
{{- 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 -}}
{{/* Check if using external secret for database password */}}
{{- define "twenty.db.useExternalSecret" -}}
{{- if and (not .Values.db.enabled) .Values.db.external.secretName .Values.db.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Database URL secret name */}}
{{- define "twenty.dbUrl.secretName" -}}
{{- printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- end -}}
{{/* Database password secret name */}}
{{- define "twenty.dbPassword.secretName" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.secretName -}}
{{- else -}}
{{- include "twenty.dbUrl.secretName" . -}}
{{- end -}}
{{- end -}}
{{/* Database password secret key */}}
{{- define "twenty.dbPassword.secretKey" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.passwordKey -}}
{{- else if .Values.db.enabled -}}
appPassword
{{- else -}}
password
{{- end -}}
{{- end -}}
{{/* Database URL template for external secret (will be evaluated at runtime) */}}
{{- define "twenty.dbUrl.template" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
{{- printf "%s://%s:$(DB_PASSWORD)@%s:%v/%s%s" $scheme $user $host $port $db $qs -}}
{{- end -}}
{{- end -}}
{{/* Check if using external secret for redis password */}}
{{- define "twenty.redis.useExternalSecret" -}}
{{- if and (not .Values.redisInternal.enabled) .Values.redis.external.secretName .Values.redis.external.passwordKey -}}
true
{{- else -}}
false
{{- 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 -}}
{{- if or (eq (include "twenty.redis.useExternalSecret" .) "true") (.Values.redis.external.password) -}}
{{- $auth := ":$(REDIS_PASSWORD)@" -}}
{{- printf "redis://%s%s:%v" $auth $host $port -}}
{{- else -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{- define "twenty.serverUrl" -}}
{{- if .Values.server.env.SERVER_URL -}}
{{- .Values.server.env.SERVER_URL -}}
{{- else 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 -}}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -0,0 +1,219 @@
{{- 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 }}
{{- with .Values.server.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.dnsPolicy }}
dnsPolicy: {{ . }}
{{- end }}
{{- with .Values.server.dnsConfig }}
dnsConfig:
{{- toYaml . | nindent 8 }}
{{- end }}
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 }}
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 }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- 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 }}
{{- with .Values.server.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
containerPort: {{ include "twenty.server.containerPort" . }}
protocol: TCP
{{- with .Values.server.startupProbe }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.server.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.server.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
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 }}

View File

@@ -0,0 +1,119 @@
{{- 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 }}
{{- with .Values.worker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.dnsPolicy }}
dnsPolicy: {{ . }}
{{- end }}
{{- with .Values.worker.dnsConfig }}
dnsConfig:
{{- toYaml . | nindent 8 }}
{{- 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!"
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 }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- 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
{{- with .Values.worker.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -0,0 +1,20 @@
{{- if and .Values.redisInternal.enabled .Values.redisInternal.persistence.enabled (not .Values.redisInternal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
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:
accessModes:
{{ toYaml .Values.redisInternal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.redisInternal.persistence.size }}
{{- if .Values.redisInternal.persistence.storageClass }}
storageClassName: {{ .Values.redisInternal.persistence.storageClass }}
{{- end }}
{{- end }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -0,0 +1,45 @@
{{- $secretName := printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- if .Values.db.enabled -}}
{{- $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 }}
{{- else if not .Values.db.external.secretName -}}
{{- $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 "" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
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 "%s://%s:%s@%s:%v/%s%s" $scheme (urlquery $user) (urlquery $pass) $host $port $db $qs | quote }}
password: {{ $pass | quote }}
{{- end -}}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -0,0 +1,58 @@
suite: env mapping
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders server extraEnv with plain value
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: FEATURE_X_ENABLED
value: "true"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_X_ENABLED
value: "true"
- it: renders server extraEnv with valueFrom
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
- it: renders worker extraEnv with valueFrom
template: templates/deployment-worker.yaml
set:
worker.extraEnv:
- name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,89 @@
suite: redis external authentication
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: injects REDIS_PASSWORD from external secret into server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects REDIS_PASSWORD from external secret into worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects plaintext REDIS_PASSWORD when password set directly in server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: injects plaintext REDIS_PASSWORD when password set directly in worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: does not inject REDIS_PASSWORD into server when using internal redis
template: templates/deployment-server.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
- it: does not inject REDIS_PASSWORD into worker when using internal redis
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true

View File

@@ -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

View File

@@ -0,0 +1,107 @@
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

View File

@@ -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

View File

@@ -0,0 +1,72 @@
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[?(@.name=="SERVER_URL")].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[?(@.name=="SERVER_URL")].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:
- isSubset:
path: metadata.annotations
content:
cert-manager.io/cluster-issuer: letsencrypt-prod

View File

@@ -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

View File

@@ -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$"

View File

@@ -0,0 +1,281 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Twenty Helm Chart Values",
"type": "object",
"additionalProperties": true,
"definitions": {
"probe": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"properties": {
"httpGet": {
"type": "object",
"properties": {
"path": { "type": "string" },
"port": { "type": ["string", "integer"] },
"scheme": { "type": "string", "enum": ["HTTP", "HTTPS"] },
"host": { "type": "string" },
"httpHeaders": { "type": "array" }
}
},
"tcpSocket": {
"type": "object",
"properties": {
"port": { "type": ["string", "integer"] },
"host": { "type": "string" }
},
"required": ["port"]
},
"exec": {
"type": "object",
"properties": {
"command": { "type": "array", "items": { "type": "string" } }
},
"required": ["command"]
},
"initialDelaySeconds": { "type": "integer", "minimum": 0 },
"periodSeconds": { "type": "integer", "minimum": 1 },
"timeoutSeconds": { "type": "integer", "minimum": 1 },
"successThreshold": { "type": "integer", "minimum": 1 },
"failureThreshold": { "type": "integer", "minimum": 1 },
"terminationGracePeriodSeconds": { "type": "integer", "minimum": 1 }
},
"oneOf": [
{ "required": ["httpGet"] },
{ "required": ["tcpSocket"] },
{ "required": ["exec"] }
]
}
]
}
},
"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": {
"SERVER_URL": { "type": "string", "description": "Override for the server URL (e.g., https://crm.example.com). If not set, derived from ingress or service." },
"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" },
"IS_EMAIL_VERIFICATION_REQUIRED": { "type": "boolean" },
"EMAIL_VERIFICATION_TOKEN_EXPIRES_IN": { "type": "string" },
"PASSWORD_RESET_TOKEN_EXPIRES_IN": { "type": "string" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
},
"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" }
}
},
"nodeSelector": { "type": "object" },
"tolerations": {
"type": "array",
"items": { "type": "object" }
},
"affinity": { "type": "object" },
"dnsPolicy": { "type": ["string", "null"], "enum": ["ClusterFirst", "ClusterFirstWithHostNet", "Default", "None", null] },
"dnsConfig": { "type": "object" },
"startupProbe": { "$ref": "#/definitions/probe" },
"livenessProbe": { "$ref": "#/definitions/probe" },
"readinessProbe": { "$ref": "#/definitions/probe" }
}
},
"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" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
},
"nodeSelector": { "type": "object" },
"tolerations": {
"type": "array",
"items": { "type": "object" }
},
"affinity": { "type": "object" },
"dnsPolicy": { "type": ["string", "null"], "enum": ["ClusterFirst", "ClusterFirstWithHostNet", "Default", "None", null] },
"dnsConfig": { "type": "object" }
}
},
"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"]
}
}
}
}
]
}
}
}

View File

@@ -0,0 +1,228 @@
# 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: ""
# Option A: direct values
accessKeyId: ""
secretAccessKey: ""
# Option B: reference a Secret
# secretName: my-s3-creds
# accessKeyIdKey: accessKeyId
# secretAccessKeyKey: 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"
extraEnv: []
# - name: EMAIL_DRIVER
# value: smtp
# - name: SMTP_PASSWORD
# valueFrom:
# secretKeyRef:
# name: smtp-creds
# key: password
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: []
# Pod scheduling and DNS
nodeSelector: {}
tolerations: []
affinity: {}
dnsPolicy: ~
dnsConfig: {}
# Health probes for the server container. Set any probe to null to disable it.
# startupProbe gates the slow initial boot (up to periodSeconds x failureThreshold),
# so liveness/readiness intentionally carry no initialDelaySeconds.
startupProbe:
httpGet:
path: /healthz
port: http-tcp
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /healthz
port: http-tcp
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http-tcp
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Worker deployment
worker:
enabled: true
replicaCount: 1
image: {}
command: ["yarn", "worker:prod"]
resources:
requests:
cpu: 250m
memory: 1024Mi
limits:
cpu: 1000m
memory: 2048Mi
extraEnv: []
# Pod scheduling and DNS
nodeSelector: {}
tolerations: []
affinity: {}
dnsPolicy: ~
dnsConfig: {}
# 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
secretName: ""
passwordKey: ""
# 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
password: ""
secretName: ""
passwordKey: ""