← lab / twenty

Field note · 2026-08-31

Notes from a self-hosted Twenty

We run Twenty next to Attio, not instead of it. These are the things the docs do not tell you, written after actually shipping apps against a self-hosted instance.

Why it sits next to Attio

5050Growth is an Attio partner. Most of the work we take is still Attio: funds, PE, relationship-heavy SaaS. That does not change.

Twenty is the answer to a different buyer. They want a modern CRM. They also want a price they can write down, data that never leaves their region, and a codebase they can fork if the vendor disappears. Seat-price anxiety kills otherwise good Attio deals. Self-hosted Twenty is the fallback we can actually stand behind, because we run it.

Cloud Twenty at twenty.com is fine if you just want to try the product. The interesting part, for us, is the self-hosted path plus the app SDK that shipped in 2026. The catalog is still small. That is the window.

Three env vars that unblock the platform

Docker Compose brings up server, worker, Postgres 16, Redis. That part is documented. These three are not, or they are easy to miss, and they are why a fresh self-host feels broken. Tick what you have set. Copy the .env when you want the file.

  1. LOGIC_FUNCTION_TYPE=LOCAL. Server-side logic functions are disabled by default on self-hosted. Every call 403s with no useful message until this is set. LOCAL runs each call in a real Node child process that inherits container env, so process.env.X works for secrets. You do not need the Admin Panel variable machinery for a self-built app.
  2. TWENTY_API_URL and TWENTY_API_KEY. Logic functions call back into Twenty through CoreApiClient. If the URL is wrong, or the container cannot resolve its own public hostname, the function dies after it has already started. Put the public hostname in extra_hosts on server and worker, pointing at the docker-bridge gateway, not at 127.0.0.1. Loopback has no TLS.
  3. Google sync flags on both server and worker: MESSAGING_PROVIDER_GMAIL_ENABLED and CALENDAR_PROVIDER_GOOGLE_ENABLED. The BullMQ cron jobs register themselves on boot. The docs still tell you to register cron:messaging:* by hand. Skip that on current versions.

App SDK traps that cost a day

Scaffold with (Node 24). Dev loop: typecheck, , then . Prefer the generated schema.graphql over guessing field shapes.

Front components run in a worker inside a sandboxed iframe. More of the DOM is allowed than the docs suggest: real CSS including hover and keyframes, the full SVG set, paste events with clipboard text. A few things will still empty a side panel with zero error surface.

  • Always unmount in a finally. Headless command buttons disable while the component is mounted. If it crashes or never finishes, the toolbar button sticks disabled until a refresh.
  • Never RestApiClient inside the sandbox. Relative URLs do not resolve. Transport is plain fetch against TWENTY_FUNCTIONS_URL with the injected app token.
  • frontComponentId is the database id, not the universal identifier. Pass the wrong one and FindOneFrontComponent returns null forever. Resolve at runtime against metadata.
  • recordId without objectNameSingular is dropped. The panel then sees an empty selection.
  • Multi-select never reaches side panels. The host collapses to a single record context. Workaround we use: the headless trigger packs ids comma-separated into recordId, the panel splits them back out. Fragile. Flag it in code.

CSS injected from a front component lands on the host page and is global. Namespace every selector. SVG presentation attributes do not accept var(); put the variable on a style or a class. There is no focus() bridge, so do not plan arrow-key cell navigation.

Rate limit on the core API is 100 requests per minute per token. Plural mutations take a filter, not an id list. Deletes do not cascade: remove participants before messages before threads, or ghosts stay attached to the person.

What we shipped against it

In one day on the self-hosted instance: a HeyReach connector (select people, pick a campaign in a side panel, batch add) and a finance mini-spreadsheet tab on every Company record. Spreadsheet paste works because the sandbox forwards clipboardText. The host still applies the native paste afterwards, so guard the following onChange or it overwrites what you just spilled.

Apps can attach tabs to standard record-page layouts. That is how the finance widget lives on Company without forking Twenty. Fields on standard objects use defineField plus the standard universal identifier. FieldType.RAW_JSON round-trips objects through GraphQL.

When Twenty is the right CRM

Pick Twenty when at least two of these are true: the buyer will not sign an uncapped SaaS CRM; data must stay on their infrastructure; someone on the team can run Docker and read a GraphQL schema; they want custom record-page UI, not just custom fields.

Pick Attio when the buyer wants a polished relationship CRM this quarter, native email and calendar that just work, an Expert who has done their exact shape of business fifteen times, and they are fine paying for that. That is most of our clients. Pretending otherwise would be a different company.

Notion is a third lane, not a CRM. Use it when the operating system is a wiki with a few databases, not a pipeline with ownership, email sync, and reporting. We will write that note next.