diff --git a/packages/twenty-docker/helm/twenty/Chart.yaml b/packages/twenty-docker/helm/twenty/Chart.yaml new file mode 100644 index 0000000000..8b67602f25 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/Chart.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/QUICKSTART.md b/packages/twenty-docker/helm/twenty/QUICKSTART.md new file mode 100644 index 0000000000..65f9f336be --- /dev/null +++ b/packages/twenty-docker/helm/twenty/QUICKSTART.md @@ -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 +``` diff --git a/packages/twenty-docker/helm/twenty/README.md b/packages/twenty-docker/helm/twenty/README.md new file mode 100644 index 0000000000..b9e9874631 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/README.md @@ -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. diff --git a/packages/twenty-docker/helm/twenty/templates/NOTES.txt b/packages/twenty-docker/helm/twenty/templates/NOTES.txt new file mode 100644 index 0000000000..fad99f4176 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/NOTES.txt @@ -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 "" }} + {{- else }} + - Primary 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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/_helpers.tpl b/packages/twenty-docker/helm/twenty/templates/_helpers.tpl new file mode 100644 index 0000000000..6f9d9534b3 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/_helpers.tpl @@ -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 -}} diff --git a/packages/twenty-docker/helm/twenty/templates/deployment-db-internal.yaml b/packages/twenty-docker/helm/twenty/templates/deployment-db-internal.yaml new file mode 100644 index 0000000000..bee183b23f --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/deployment-db-internal.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/deployment-redis-internal.yaml b/packages/twenty-docker/helm/twenty/templates/deployment-redis-internal.yaml new file mode 100644 index 0000000000..b0a3172813 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/deployment-redis-internal.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/deployment-server.yaml b/packages/twenty-docker/helm/twenty/templates/deployment-server.yaml new file mode 100644 index 0000000000..cb9764cd88 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/deployment-server.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/deployment-worker.yaml b/packages/twenty-docker/helm/twenty/templates/deployment-worker.yaml new file mode 100644 index 0000000000..2d8ad94674 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/deployment-worker.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/ingress.yaml b/packages/twenty-docker/helm/twenty/templates/ingress.yaml new file mode 100644 index 0000000000..c89c5db93d --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/ingress.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/pvc-db-internal.yaml b/packages/twenty-docker/helm/twenty/templates/pvc-db-internal.yaml new file mode 100644 index 0000000000..ab2b482da6 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/pvc-db-internal.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/pvc-docker-data.yaml b/packages/twenty-docker/helm/twenty/templates/pvc-docker-data.yaml new file mode 100644 index 0000000000..8338e3a94f --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/pvc-docker-data.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/pvc-server.yaml b/packages/twenty-docker/helm/twenty/templates/pvc-server.yaml new file mode 100644 index 0000000000..b9c4197f17 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/pvc-server.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/secret-db-superuser.yaml b/packages/twenty-docker/helm/twenty/templates/secret-db-superuser.yaml new file mode 100644 index 0000000000..483defa1ac --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/secret-db-superuser.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/secret-db-url.yaml b/packages/twenty-docker/helm/twenty/templates/secret-db-url.yaml new file mode 100644 index 0000000000..3bac0e1a1d --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/secret-db-url.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/secret-tokens.yaml b/packages/twenty-docker/helm/twenty/templates/secret-tokens.yaml new file mode 100644 index 0000000000..6e72afc55f --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/secret-tokens.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/service-db-internal.yaml b/packages/twenty-docker/helm/twenty/templates/service-db-internal.yaml new file mode 100644 index 0000000000..5f8dcdd2c4 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/service-db-internal.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/service-redis-internal.yaml b/packages/twenty-docker/helm/twenty/templates/service-redis-internal.yaml new file mode 100644 index 0000000000..869160e1a0 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/service-redis-internal.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/templates/service-server.yaml b/packages/twenty-docker/helm/twenty/templates/service-server.yaml new file mode 100644 index 0000000000..59f95d47af --- /dev/null +++ b/packages/twenty-docker/helm/twenty/templates/service-server.yaml @@ -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 }} diff --git a/packages/twenty-docker/helm/twenty/tests/app_user_test.yaml b/packages/twenty-docker/helm/twenty/tests/app_user_test.yaml new file mode 100644 index 0000000000..32c565a10e --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/app_user_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/ingress_test.yaml b/packages/twenty-docker/helm/twenty/tests/ingress_test.yaml new file mode 100644 index 0000000000..0e3738658a --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/ingress_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/pvc_test.yaml b/packages/twenty-docker/helm/twenty/tests/pvc_test.yaml new file mode 100644 index 0000000000..d7c6fb723a --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/pvc_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/redis_db_switching_test.yaml b/packages/twenty-docker/helm/twenty/tests/redis_db_switching_test.yaml new file mode 100644 index 0000000000..9f72e25050 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/redis_db_switching_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/schema_permissions_test.yaml b/packages/twenty-docker/helm/twenty/tests/schema_permissions_test.yaml new file mode 100644 index 0000000000..435dae8fcf --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/schema_permissions_test.yaml @@ -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 + diff --git a/packages/twenty-docker/helm/twenty/tests/secret_generation_test.yaml b/packages/twenty-docker/helm/twenty/tests/secret_generation_test.yaml new file mode 100644 index 0000000000..24d2f6e2ae --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/secret_generation_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/server_url_test.yaml b/packages/twenty-docker/helm/twenty/tests/server_url_test.yaml new file mode 100644 index 0000000000..e4a3109799 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/server_url_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/storage_env_test.yaml b/packages/twenty-docker/helm/twenty/tests/storage_env_test.yaml new file mode 100644 index 0000000000..904ce94ac5 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/storage_env_test.yaml @@ -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 diff --git a/packages/twenty-docker/helm/twenty/tests/worker_deployment_test.yaml b/packages/twenty-docker/helm/twenty/tests/worker_deployment_test.yaml new file mode 100644 index 0000000000..0aaf0c7196 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/tests/worker_deployment_test.yaml @@ -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$" diff --git a/packages/twenty-docker/helm/twenty/values.schema.json b/packages/twenty-docker/helm/twenty/values.schema.json new file mode 100644 index 0000000000..cd87ec264f --- /dev/null +++ b/packages/twenty-docker/helm/twenty/values.schema.json @@ -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"] + } + } + } + } + ] + } + } +} diff --git a/packages/twenty-docker/helm/twenty/values.yaml b/packages/twenty-docker/helm/twenty/values.yaml new file mode 100644 index 0000000000..657a224fa9 --- /dev/null +++ b/packages/twenty-docker/helm/twenty/values.yaml @@ -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 diff --git a/packages/twenty-docker/k8s/README.md b/packages/twenty-docker/k8s/README.md index d538d21ba0..765e0f4464 100644 --- a/packages/twenty-docker/k8s/README.md +++ b/packages/twenty-docker/k8s/README.md @@ -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: