twenty
Twenty CRM — open-source CRM for contact management, lead qualification, and pipeline tracking. Modern alternative to Salesforce.
The service runs as a Podman pod: an entrypoint container (API + frontend) plus three co-located containers (PostgreSQL, Redis, worker) sharing a network namespace. All four image references are digest-pinned per ADR-0005. The pod is exposed via a single zone IP (the entrypoint); pod members are reachable only via localhost from within the pod.
| Attribute | Value |
|---|---|
| Runtime | OCI (Podman pod) |
| Port | 3000 |
| Idle timeout | null (always-on) |
| OCI image (entrypoint) | twentycrm/twenty |
| Pod members | twenty-db (PostgreSQL), twenty-redis (Redis), twenty-worker (Twenty image, worker command) |
Blueprint environment defaults (entrypoint):
| Variable | Default |
|---|---|
TZ | Europe/Berlin |
NODE_ENV | production |
STORAGE_TYPE | local |
REDIS_URL | redis://localhost:6379 |
The twenty-worker pod member carries the same four environment defaults. The twenty-db pod
member defaults TZ, POSTGRES_DB, and POSTGRES_USER. The twenty-redis pod member runs
with cmd = [ "--maxmemory-policy" "noeviction" ].
Consumer must provide:
idand zone placement.- Environment overrides:
SERVER_URL, OIDC configuration. - Secrets via
environmentFiles:DATABASE_URL,APP_SECRET, OIDC credentials,POSTGRES_PASSWORD. - Named volumes for the entrypoint, worker, and database (see the example below).
Current Recommended Pins
The blueprint pins all four recommended.<key> values to digest-pinned image references (verified
against merlin production 2026-05-17):
| Key | Pin |
|---|---|
main | v2.0.0@sha256:0250911a2c44652772aaf8f59f42e8313e8d97fe300412ac3aa49a7092093576 |
postgres | 16.6@sha256:557fea37a744d5f4c8faab304b0a90858b53ab119735a88c131fd19dab802f36 |
redis | 7.4@sha256:a5995dfdf108997f8a7c9587f54fad5e94ed5848de5236a6b28119e99efd67e0 |
worker | v2.0.0@sha256:0250911a2c44652772aaf8f59f42e8313e8d97fe300412ac3aa49a7092093576 |
main and worker share an image and digest by design: the worker container is the same
twentycrm/twenty image run with a different command (yarn worker:prod). The digest equality
makes the pair-must-move-in-lockstep invariant byte-explicit — the blueprint cannot ship a state
where main and worker carry different content. See the Twenty blueprint header note #4 for
the consumer-facing implication.
Upgrade Notes — bodyV2 Schema Trap
Twenty v1.20 shipped a schema-migration step that corrupted bodyV2 field metadata
(RICH_TEXT_V2 was rewritten to TEXT on some installations). The defect is invisible until
v2.x reads the metadata literally, at which point the application crashes on every record that
has a rich-text body. Recorded as
nixda-stack#5 and upstream
twentyhq/twenty#20596.
The v2.0.0 pin is the recommended floor for clean installations and for post-fix migration
paths. Any installation that ran the v1.20 schema-migration step between 2026-03-13 and
2026-03-24 must apply the corrective UPDATE recorded in
nixda-stack#5 BEFORE any further upgrade.
The docs/runbooks/upgrade-sandbox.md runbook describes the per-blueprint upgrade procedure,
including a sandbox-validation step against a copy of merlin’s production database before the
production bump.
Usage
The canonical consumer pattern calls mk {} and merges host-specific identity, environment
overrides, and pod-member volumes via lib.recursiveUpdate:
nixda.zones.business.services.crm = lib.recursiveUpdate
((blueprint "twenty").mk {})
{
id = 1;
runtime.oci = {
environment = {
SERVER_URL = "https://crm.business.lan";
};
volumes = [
"twenty-local-storage:/app/packages/twenty-server/.local-storage"
];
pod."twenty-db".volumes = [
"twenty-db-data:/var/lib/postgresql/data"
];
pod."twenty-worker".volumes = [
"twenty-local-storage:/app/packages/twenty-server/.local-storage"
];
};
};
Secrets (DATABASE_URL, APP_SECRET, POSTGRES_PASSWORD, OIDC credentials) are injected per
pod member via environmentFiles in the host’s default.nix.
To opt into the recommended pins explicitly (suppressing the inheritance warning):
nixda.zones.business.services.crm = lib.recursiveUpdate
((blueprint "twenty").mk {
versions = (blueprint "twenty").recommended;
})
{
id = 1;
# ... runtime.oci as above
};
Pinning to a Different Tag
A consumer who needs a different Twenty release (or a different PostgreSQL / Redis major) must supply the full four-key digest-pinned form. The partial-pin rule from ADR-0004 rejects strict-subset keysets — supply all four keys or none.
nixda.zones.business.services.crm = lib.recursiveUpdate
((blueprint "twenty").mk {
versions = {
main = "v2.1.0@sha256:<64-lowercase-hex-chars>";
postgres = "16.6@sha256:557fea37a744d5f4c8faab304b0a90858b53ab119735a88c131fd19dab802f36";
redis = "7.4@sha256:a5995dfdf108997f8a7c9587f54fad5e94ed5848de5236a6b28119e99efd67e0";
worker = "v2.1.0@sha256:<64-lowercase-hex-chars>";
};
})
{
id = 1;
# ... runtime.oci as above
};
main and worker MUST share the same digest — the worker container is the same image. The
PostgreSQL major version (16.x at time of writing) is governed by upstream’s
docker-compose.yml;
do not bump to 17 without a sandbox-validated pg_upgrade plan per the upgrade-sandbox runbook.