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.

AttributeValue
RuntimeOCI (Podman pod)
Port3000
Idle timeoutnull (always-on)
OCI image (entrypoint)twentycrm/twenty
Pod memberstwenty-db (PostgreSQL), twenty-redis (Redis), twenty-worker (Twenty image, worker command)

Blueprint environment defaults (entrypoint):

VariableDefault
TZEurope/Berlin
NODE_ENVproduction
STORAGE_TYPElocal
REDIS_URLredis://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:

  • id and 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).

The blueprint pins all four recommended.<key> values to digest-pinned image references (verified against merlin production 2026-05-17):

KeyPin
mainv2.0.0@sha256:0250911a2c44652772aaf8f59f42e8313e8d97fe300412ac3aa49a7092093576
postgres16.6@sha256:557fea37a744d5f4c8faab304b0a90858b53ab119735a88c131fd19dab802f36
redis7.4@sha256:a5995dfdf108997f8a7c9587f54fad5e94ed5848de5236a6b28119e99efd67e0
workerv2.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.


Back to Blueprint Catalog