Overview
Twenty is an open-source CRM — a self-hosted alternative to Salesforce, HubSpot and Pipedrive — with customizable objects, pipelines, views and workflows behind a REST and GraphQL API. This template deploys the Twenty server (React front end plus NestJS API on port3000), a background worker that drains its job queues and runs its cron jobs, a bundled single-node Redis, and a PostgreSQL database that is a single instance by default or a highly available Patroni cluster with one flag. Attachments go to a shared persistent volume or to an S3 bucket you own. Twenty is AGPL-3.0 licensed; the template runs the unmodified upstream image.
--gvc GVC_NAME.What Gets Created
Prerequisites
Twenty encrypts stored OAuth tokens, TOTP secrets and app variables, and signs its auth tokens, with one app key that you supply through an opaque secret created before installing. The value never passes through Helm values; the template hands it to Twenty as bothAPP_SECRET and ENCRYPTION_KEY.
Create the app key secret
--gvc flag is involved:secrets.name to this name.Back the value up
secrets.name without pointing secrets.fallbackName at the previous key makes stored OAuth tokens, TOTP secrets and app variables undecryptable and logs everyone out. See Rotating the App Key.Choose the two bundled passwords
postgres.credentials.password) and the Redis password (redis.auth.password) ship as change-me-… placeholders that the template uses as-is. Pick strong values and pass them at install time. Both are embedded in connection URLs, so use only letters, digits, - and _; the chart refuses to render anything else. The database password seeds the database on first boot and is not changed by later value edits.- S3 attachment storage — an existing bucket plus either a Control Plane cloud account (AWS S3) or a static-key dictionary secret (S3-compatible servers). See Attachment Storage. Required for more than one server replica.
- Database backups — a bucket and access setup for AWS S3, Google Cloud Storage or an S3-compatible server. See Backing Up.
- App key rotation — a second opaque secret holding the previous key, referenced by
secrets.fallbackNamefor the duration of the rotation. See Rotating the App Key.
Installation
Once the app key secret exists, install into your existing GVC with the two bundled passwords set:UI
CLI
Terraform
Pulumi
Configuration
Each subsection shows the shipped defaults for one top-level values area.Twenty Server
twenty.image. Leave twenty.serverUrl empty on a public install: the server then advertises its own canonical endpoint, and the worker advertises the server’s — never its own. Set it, with the scheme, whenever browsers open Twenty at any other address: a custom domain (https://crm.example.com), or the address you reach through a port-forward on a private install (http://localhost:3000). Twenty compares the browser origin against SERVER_URL, so a mismatch breaks sign-in and CORS with opaque errors.
Background Worker
worker.replicas knob, exposes no port and accepts no inbound traffic. It has no probes, so it reports ready as soon as it is scheduled — read its logs rather than its readiness:
App Key Secret
secrets.name is the prerequisite opaque secret from Prerequisites. secrets.fallbackName is set only while rotating the key — see Rotating the App Key.
Attachment Storage
- AWS S3 (keyless)
- S3-compatible (MinIO and others)
Access
publicAccess.enabled: false for an internal-only instance — external requests are then refused, and in-GVC callers still reach it according to internalAccess.type:
Redis
REDIS_URL — and it runs as a single node with persistence on and eviction disabled, so a queued job is never dropped. Twenty accepts only a plain redis:// URL, which is why the Sentinel-based redis template is not used here. Change redis.auth.password before installing; only letters, digits, - and _ are accepted because the value is embedded in a connection URL.
Database
Enable exactly one ofpostgres (single instance, default) and postgresHA (highly available) — the chart refuses to render with both or neither. Twenty is wired to the active database automatically: the single instance directly, or the HAProxy leader endpoint in HA mode. In both modes the database credentials come from postgres.credentials.*: this template writes them into a dictionary secret named by postgres.config.credentialsSecretName or postgresHA.config.credentialsSecretName and hands that name to the bundled database. There is nothing for you to create.
postgres.* and postgresHA.*, with one exception: postgresHA.proxy.enabled must stay true, because the HAProxy endpoint is the address Twenty connects to.
Connecting
3000 and query the health route:
publicAccess.enabled: false, the same port-forward is how you reach the UI in a browser at http://localhost:3000 — and twenty.serverUrl must then be set to that address, because Twenty checks the browser origin against it.
First Run
Twenty ships no default account. The first person to sign up creates the workspace and becomes its full administrator.Wait for the server workload to report ready
3000. Every fresh install logs relation "core.appToken" does not exist and similar lines early in the boot — the migration framework probing for tables it has not created yet — and the readiness probe may report a transient failure during the migration window. Both stop on their own once migrations finish; do not abort the install.Sign up immediately
RELEASE_NAME-twenty and create the first account. On a public endpoint, whoever reaches the URL first owns the CRM — sign up as soon as the workload is ready, or install with publicAccess.enabled: false and reach the sign-up page through a port-forward (set twenty.serverUrl to http://localhost:3000 for that session).Finish configuration inside the app
Operations
Backing Up
Database backups are optional and off by default. They cover the PostgreSQL database — every record, workspace and user. Attachments are not included: in S3 mode they live in your bucket, in local mode on theRELEASE_NAME-twenty-storage volume set, which cannot be snapshotted.
Enable backups with postgres.backup.enabled: true or postgresHA.backup.enabled: true (matching your database mode) and complete the storage setup for your provider before installing. The keys below are shown as backup.*; set them inside the enabled database block.
- AWS S3
- Google Cloud Storage
- S3-compatible (MinIO and others)
Create a bucket
backup.aws.bucket and backup.aws.region to match.Set up a cloud account
backup.aws.cloudAccountName to its name.Create a bucket-scoped IAM policy
YOUR_BUCKET_NAME with the backup bucket), then set backup.aws.policyName to the policy’s name.RELEASE_NAME-postgres-backup cron workload on backup.schedule. In HA mode, backup.mode selects logical (a scheduled dump run by RELEASE_NAME-postgres-ha-backup) or wal-g (continuous WAL archiving every backup.walg.intervalSeconds). backup.*.prefix is the key prefix within the bucket; schedules are cron expressions in UTC.
Restoring a Backup
The database restore is the one documented for the bundled database: Restoring a Backup on the postgres page, or Restoring a Backup on the postgres-highly-available page (logical and WAL-G). In HA mode, connect throughRELEASE_NAME-postgres-ha-proxy.GVC_NAME.cpln.local so the restore targets the current leader; use the username and password from the secret named by *.config.credentialsSecretName.
Three Twenty-specific points:
-
The server and worker must not be writing to the database while a dump is replayed. After the restore, force a redeployment of both so they reconnect:
-
Attachments are restored separately: copy them back into the bucket (S3 mode) or onto the
RELEASE_NAME-twenty-storagevolume set (local mode). -
Keep the app key from your prerequisite secret with the backup. A dump restored under a different
ENCRYPTION_KEYcannot decrypt the OAuth tokens, TOTP secrets and app variables it contains.
Rotating the App Key
secrets.name points at the opaque secret holding the current app key. secrets.fallbackName points at a second opaque secret holding the previous key and exists only for a rotation: Twenty uses it as FALLBACK_ENCRYPTION_KEY, a verification-only key that keeps rows encrypted under the old key readable while new writes use the new key.
Create the new key secret
Upgrade with both names set
helm upgrade with secrets.name pointing at the new secret and secrets.fallbackName at the old one. The identity is granted reveal on exactly those two secrets plus the ones the template creates.Let the rollout finish
secrets.fallbackName on an assumption — keep both secrets until you have confirmed the old key is no longer needed.Upgrading From 1.0.0
Version1.0.1 only updates the bundled highly available database to a release that compacts its etcd cluster. Without compaction, etcd’s backend grows with time alone and eventually goes read-only, taking PostgreSQL failover with it. Only installs running the HA database (postgresHA.enabled: true, off by default here) are affected — see etcd History Compaction for the mechanism and the symptoms. Compaction stops further growth but cannot shrink a backend that has already grown.
Upgrading From 1.0.1 or Earlier
Version1.1.0 changed the single-instance database path:
postgres.config.username,postgres.config.passwordandpostgres.config.databasemoved topostgres.credentials.username,postgres.credentials.passwordandpostgres.credentials.database. The newpostgres.config.credentialsSecretNamenames the dictionary secret this template now creates from those three values. Carrying the old keys fails the render withconfig.username was REMOVED in postgres 3.4.0— move the three keys and you are done, and ignore that message’s advice to create a secret yourself.postgres.backup.minio.accessKeyandpostgres.backup.minio.secretKeywere replaced bypostgres.backup.minio.credentialsSecretName, a prerequisite dictionary secret holdingaccessKeyandsecretKey— see Backing Up.
postgres.credentials.* equal to the values your database was initialised with; the running database still enforces them.
Upgrading From 1.1.0 or Earlier
Version1.2.0 moved the highly available database to postgres-highly-available 2.5.0, which no longer creates its own credentials secret:
postgresHA.postgres.username,postgresHA.postgres.passwordandpostgresHA.postgres.databasewere removed. Carrying them fails the render withthe postgres block was REMOVED in 2.5.0. Delete the block.- In HA mode the credentials now come from
postgres.credentials.*— the same keys the single-instance path uses — and this template writes them into the secret named by the newpostgresHA.config.credentialsSecretName.
Upgrading From 1.2.0 or Earlier
Version1.2.1 replaced postgresHA.backup.minio.accessKey and postgresHA.backup.minio.secretKey with postgresHA.backup.minio.credentialsSecretName, a prerequisite dictionary secret holding accessKey and secretKey. Only installs backing up the HA database to an S3-compatible server are affected — see Backing Up for the secret.
Version 1.3.0 changes no values. It makes the server and worker wait for a live PostgreSQL before running boot migrations, which fixes a first-install failure where the containers could start against a database that was not yet accepting connections.
Scaling and Availability
-
Server tier —
twenty.replicassets how many server replicas run behind the endpoint. Anything above1requiresstorage.type: s3; the chart refuses to render the combination with local storage. -
Boot migrations run in exactly one container, and the template places them automatically:
Because the worker owns migrations above one replica, a fresh multi-replica install serves errors until the worker has created the schema. Upgrades of an existing release already have a populated schema.
-
Worker and Redis — both are fixed at one replica by design: the worker owns cron registration, and Twenty accepts only a plain
redis://URL. A Redis restart stalls background jobs briefly; the UI stays up. -
Database — the opt-in
postgresHApath keeps serving through a replica failure and a rolling restart; the defaultpostgrespath has no failover. The mode is an install-time choice. -
Helm upgrades — the first
helm upgradeafter an install can restart the bundled database and Redis even when their values did not change; upgrade in a quiet window.
Troubleshooting
The deployment never starts and cpln logs is empty
The deployment never starts and cpln logs is empty
RELEASE_NAME-twenty and RELEASE_NAME-twenty-worker never reach ready, and cpln logs returns zero lines.Cause: a secret referenced by name does not exist — usually the app key secret (secrets.name), or the fallback or S3 key secret when those are set. The container never starts, so there is nothing to log.Fix: read status.versions[].message, which names the missing secret, then create it. The deployment recovers on its own after several minutes, or force a redeployment to skip the wait.First boot logs relation core.appToken does not exist
First boot logs relation core.appToken does not exist
error: relation "core.appToken" does not exist and similar lines, and the readiness probe reports a transient failure.Cause: the migration framework probes for its state tables before creating them. This is expected on every fresh install and stops once the boot migrations finish.Fix: none — wait for the server to report ready. If the server never does, read its log for a migration error:The server logs waiting for Postgres and then exits for restart
The server logs waiting for Postgres and then exits for restart
twenty: waiting for Postgres... (attempt N) and ends with twenty: Postgres not ready after 250s; exiting for restart.Cause: the start script waits for the database to answer a real PostgreSQL handshake before running migrations, and the database was not accepting connections within its window. On a first HA install this can happen once while the cluster elects a leader; a database that never comes up is a different problem.Fix: the container restarts and retries on its own. If it keeps happening, check the database workload — RELEASE_NAME-postgres or RELEASE_NAME-postgres-ha — with cpln workload get-deployments and its logs.Sign-in fails or the browser reports CORS errors
Sign-in fails or the browser reports CORS errors
SERVER_URL. This happens with a custom domain, or when reaching a private install through a port-forward, while twenty.serverUrl is empty.Fix: set twenty.serverUrl to exactly the URL browsers use, including the scheme (https://crm.example.com, or http://localhost:3000 for a port-forward), and run helm upgrade.Render fails because replicas above 1 require s3 storage
Render fails because replicas above 1 require s3 storage
helm install or helm upgrade fails before anything is applied, citing twenty.replicas > 1 requires storage.type: s3.Cause: local attachments live on a volume set, which does not serve more than one server replica.Fix: complete the S3 setup under Attachment Storage and set storage.type: s3, or keep twenty.replicas: 1.Render fails because static keys are only for S3-compatible servers
Render fails because static keys are only for S3-compatible servers
static keys (storage.s3.auth.secretName) are only for S3-compatible servers.Cause: storage.s3.auth.secretName is set but storage.s3.endpoint is empty. AWS S3 is keyless only.Fix: for AWS S3, clear storage.s3.auth.secretName and use storage.s3.cloudAccountName plus storage.s3.policyName; for an S3-compatible server, set storage.s3.endpoint.Render fails with config.username was REMOVED in postgres 3.4.0
Render fails with config.username was REMOVED in postgres 3.4.0
helm upgrade fails before anything is applied, citing config.username was REMOVED in postgres 3.4.0.Cause: your values still carry the 1.0.x single-instance keys postgres.config.username, postgres.config.password or postgres.config.database.Fix: move the three keys to postgres.credentials.* as described in Upgrading From 1.0.1 or Earlier. Do not create a secret yourself — this template creates it.Render fails with the postgres block was REMOVED in 2.5.0
Render fails with the postgres block was REMOVED in 2.5.0
helm upgrade fails before anything is applied, citing the postgres block was REMOVED in 2.5.0.Cause: your values still carry postgresHA.postgres.username, postgresHA.postgres.password or postgresHA.postgres.database from version 1.1.0 or earlier.Fix: delete the postgresHA.postgres block and put the same three values under postgres.credentials.* — see Upgrading From 1.1.0 or Earlier.A second release is refused at install as managed by a different release
A second release is refused at install as managed by a different release
cannot be updated because it is being managed by a different release, and nothing is created.Cause: both releases use the same credentialsSecretName. Secret names are org-wide, and the first release owns that secret.Fix: give the second release its own postgres.config.credentialsSecretName (or postgresHA.config.credentialsSecretName). The first release is unaffected.Render fails because the database password contains other characters
Render fails because the database password contains other characters
the database password must contain only letters, digits, '-' and '_' or the same message for redis.auth.password.Cause: both passwords are embedded in connection URLs, and other characters break the URL.Fix: choose a password made of letters, digits, - and _ only. For the database password this must be done before the first install — it seeds the database and is not changed by later value edits.Important Notes
- Create the app key secret before installing —
secrets.namemust point at an existing opaque secret (plain encoding). Without it the deployment wedges silently; see Prerequisites. - The app key is effectively write-once — rotate it only with
secrets.fallbackNameset to the previous key, or stored OAuth tokens, TOTP secrets and app variables become undecryptable and everyone is logged out. - Sign up immediately after install — there is no seeded account, and on a public endpoint the first visitor to sign up becomes the workspace administrator. Install with
publicAccess.enabled: falseuntil you are ready if that is a concern. - Set
twenty.serverUrlwhenever browsers open Twenty at anything other than its canonical endpoint — a custom domain or a port-forward — with the scheme. A mismatch breaks sign-in and CORS with opaque errors. - Change
postgres.credentials.passwordandredis.auth.passwordbefore installing — both are embedded in connection URLs, so use only letters, digits,-and_; the database password seeds the database on first boot and is not changed by later value edits. - Choose the database mode before you have data — the single-instance and HA paths run PostgreSQL 18 and 17 respectively on separate volume sets; switching is a dump and restore, not a values change.
twenty.replicasabove1requiresstorage.type: s3— the shared local volume set cannot be snapshotted and lives in one location, so S3 is the production recommendation regardless of replica count.- AWS S3 is keyless only — use a cloud account plus a bucket-scoped IAM policy. Static keys are accepted only when
storage.s3.endpointpoints at an S3-compatible server. - Give each release its own
credentialsSecretName— secret names are org-wide, and a second release on the default name is refused at install. - SMTP, AI provider keys, rate limits and OAuth/SSO are not values knobs — configure them in Settings → Admin Panel → Configuration Variables inside the app.
- Foreign data wrappers (“remote objects”) are unavailable — that feature needs upstream’s custom PostgreSQL image, which this template does not deploy.
- Data survives restarts and upgrades; uninstalling deletes it — records live in the database volume set and local attachments in the storage volume set, and both go with the release. Your prerequisite app key secret is yours and survives an uninstall.