Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/twenty-docker/helm/twenty/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions packages/twenty-docker/helm/twenty/QUICKSTART.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Twenty Helm Chart - Quick Install

## Simple Install

Set your domain and install:

```bash
export DOMAIN=twenty.gc.kencove.com
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The QUICKSTART.md file contains a hardcoded specific domain "twenty.gc.kencove.com" which appears to be a personal/internal domain used during development. This should be replaced with a generic example domain like "your-domain.com" or "crm.example.com" to match the style used elsewhere in the documentation.

Suggested change
export DOMAIN=twenty.gc.kencove.com
export DOMAIN=crm.example.com

Copilot uses AI. Check for mistakes.

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
```
79 changes: 79 additions & 0 deletions packages/twenty-docker/helm/twenty/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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
- 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:
```bash
# values-secrets.yaml (do not commit)
# storage:
# type: s3
# s3:
# bucket: my-bucket
# region: us-east-1
# accessKeyId: AKIA...
# 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`.
17 changes: 17 additions & 0 deletions packages/twenty-docker/helm/twenty/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -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 }}
156 changes: 156 additions & 0 deletions packages/twenty-docker/helm/twenty/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
{{- 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: S3_BUCKET
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.region }}
- name: S3_REGION
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.endpoint }}
- name: S3_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.accessKeyId }}
- name: S3_ACCESS_KEY_ID
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.secretAccessKey }}
- name: S3_SECRET_ACCESS_KEY
value: {{ . | quote }}
{{- end }}
{{- end -}}
{{- end -}}
Original file line number Diff line number Diff line change
@@ -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 }}
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imagePullPolicy for the internal database deployment is not defined in values.yaml but is being referenced on line 35. This will cause a template rendering error. Add an imagePullPolicy field to db.internal.image in values.yaml with a default value like "IfNotPresent".

Suggested change
imagePullPolicy: {{ .Values.db.internal.image.pullPolicy }}
imagePullPolicy: {{ .Values.db.internal.image.pullPolicy | default "IfNotPresent" }}

Copilot uses AI. Check for mistakes.
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 }}
Loading