Skip to main content

Twenty CRM Self-Hosted: The Docker Compose Stack

Twenty CRM self-hosts as four containers: server, worker, db (PostgreSQL 16) and redis. There is no separate front-end container — the twentycrm/twenty image serves the API and the UI together on port 3000. A fresh install needs exactly one secret, ENCRYPTION_KEY, generated with openssl rand -base64 32. The server applies its own database migrations on start, so first run is docker compose up -d and nothing else. Twenty's documentation puts the minimum at 2 GB RAM. Source: twentyhq/twenty, packages/twenty-docker/docker-compose.yml, fetched 2026-09-09.

Twenty's stack used to run a separate twentycrm/twenty-front container. That image is now archived on Docker Hub, labelled "Deprecated - DO NOT USE", and its newest tag is 0.3.3 from 2024-03-22 (Docker Hub API, checked 2026-09-09). The four token secrets that went with it — ACCESS_TOKEN_SECRET, LOGIN_TOKEN_SECRET, REFRESH_TOKEN_SECRET, FILE_TOKEN_SECRET — no longer appear anywhere in the upstream compose file either. ENCRYPTION_KEY replaced them.

Any guide that still lists a front service fails at docker compose pull with a manifest error. The version of this page published on 2026-05-27 was one of them. It is corrected below and the withdrawn numbers are named rather than quietly deleted.

What do you need before you start?​

  • A VPS with root access and at least 2 GB RAM (Twenty's stated minimum). The 8 GB Liquid Web Managed VPS is $100/mo subject to change · verified 2026-09-09 and leaves room for Chatwoot or Mautic alongside.
  • Docker Engine 25+ with Compose V2.
  • A domain with an A record pointing at the VPS.
  • Twenty ships no email campaigns, no SMS and no dialer. If you need those, plan for Mautic or Listmonk on the same box before you migrate anything.

The docker-compose.yml​

This is upstream's file, trimmed for readability and hardened for a public host. Three changes are marked in the comments: a pinned image tag instead of latest, the published port bound to localhost so only the reverse proxy can reach it, and Caddy added for TLS. Four more are worth naming rather than hiding. Upstream's forty commented-out Gmail, Google, Microsoft and SMTP placeholder lines are cut, and the STORAGE_S3_REGION, STORAGE_S3_NAME and STORAGE_S3_ENDPOINT passthroughs with them, because this stack runs STORAGE_TYPE=local. PG_DATABASE_URL hardcodes db:5432 in place of upstream's ${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}, since the database is a service in this same file. The :-postgres fallback on PG_DATABASE_PASSWORD is removed, so a missing password fails at startup instead of quietly booting Postgres with a guessable one. And the server's DISABLE_DB_MIGRATIONS / DISABLE_CRON_JOBS_REGISTRATION passthroughs are dropped, since this stack never sets them. Diff against upstream if you want the byte-level list.

# docker-compose.yml — Twenty CRM behind Caddy
# Base: github.com/twentyhq/twenty, packages/twenty-docker/docker-compose.yml (main, fetched 2026-09-09)
# Deviations from upstream: pinned TAG, port bound to 127.0.0.1, caddy service added.
# Also trimmed: STORAGE_S3_*, the commented-out Google/Microsoft/SMTP placeholders,
# PG_DATABASE_HOST/PORT indirection, and the :-postgres password fallback.

name: twenty

services:
server:
image: twentycrm/twenty:${TAG:-v2.39.1}
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
ports:
- "127.0.0.1:3000:3000" # upstream publishes 3000:3000 on all interfaces
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD}@db:5432/${PG_DATABASE_NAME:-default}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
STORAGE_TYPE: ${STORAGE_TYPE:-local}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY}
APP_SECRET: ${APP_SECRET:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost:3000/healthz
interval: 5s
timeout: 5s
retries: 20
restart: always

worker:
image: twentycrm/twenty:${TAG:-v2.39.1}
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD}@db:5432/${PG_DATABASE_NAME:-default}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
DISABLE_CRON_JOBS_REGISTRATION: "true" # it already runs on the server
STORAGE_TYPE: ${STORAGE_TYPE:-local}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY}
APP_SECRET: ${APP_SECRET:-}
depends_on:
db:
condition: service_healthy
server:
condition: service_healthy
restart: always

db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${PG_DATABASE_NAME:-default}
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
healthcheck:
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
interval: 5s
timeout: 5s
retries: 10
restart: always

redis:
image: redis
command: ["--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
restart: always

caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
server:
condition: service_healthy
restart: always

volumes:
db-data:
server-local-data:
caddy-data:
caddy-config:

The noeviction policy on Redis is upstream's, not a preference. Twenty's job queue lives in Redis; an eviction policy that drops keys under memory pressure drops queued jobs.

Caddyfile​

One upstream target now, not five. The old split between /api/*, /graphql and the front end existed only because there were two containers.

crm.example.com {
reverse_proxy server:3000

encode zstd gzip

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options DENY
X-Content-Type-Options nosniff
}
}

Which secrets does Twenty actually need?​

One, on a fresh install. Upstream's .env.example documents the other two as conditional, and generating them anyway is a common way to break a rotation later:

VariableFresh installWhat upstream says
ENCRYPTION_KEYRequiredGenerate with openssl rand -base64 32
FALLBACK_ENCRYPTION_KEYLeave empty"set to the previous ENCRYPTION_KEY during a rotation"
APP_SECRETLeave empty"legacy: only required for instances that pre-date ENCRYPTION_KEY"
PG_DATABASE_PASSWORDRequiredSet before first start; Postgres bakes it into the volume

Source: packages/twenty-docker/.env.example, fetched 2026-09-09.

# .env — keep out of version control

TAG=v2.39.1
SERVER_URL=https://crm.example.com

PG_DATABASE_USER=postgres
PG_DATABASE_NAME=default
PG_DATABASE_PASSWORD=

STORAGE_TYPE=local

ENCRYPTION_KEY=
FALLBACK_ENCRYPTION_KEY=
APP_SECRET=

Generate the two values you need:

echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 24)"
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)"

Use -hex for the Postgres password. Base64 output contains / and +, which have to be percent-encoded inside PG_DATABASE_URL and are a reliable source of connection failures that look like authentication failures.

How do you start it the first time?​

# 1. Bring the whole stack up. The server runs migrations itself on boot.
docker compose up -d

# 2. Watch the server come healthy (20 retries at 5s, so up to ~100s on first boot)
docker compose ps

# 3. Confirm the health endpoint
curl -s https://crm.example.com/healthz

# 4. Create the first workspace and admin user in the browser
# https://crm.example.com

There is no separate migration step. The worker sets DISABLE_DB_MIGRATIONS: "true" with the comment "it already runs on the server" — that is upstream telling you the server owns migrations. If you have followed a guide that told you to run yarn database:migrate:prod by hand before first boot, that step is now redundant.

How much RAM does Twenty need?​

Twenty's own documentation states a minimum of 2 GB RAM. Beyond that this page has no measured number to give you today, and that is a change from the previous version.

The figures published here on 2026-05-27 — 1.14 GB idle, 1.95 GB peak, broken out per service — were measured against the old five-container shape that included twentycrm/twenty-front. That container no longer exists, so those per-service rows cannot be honestly mapped onto the current stack. They have been withdrawn rather than re-labelled. When this stack is re-measured against v2.39.x the table comes back with a fresh date on it.

What you can still plan around: four application containers plus Caddy, PostgreSQL as the heaviest single service, and Redis holding the job queue in memory with eviction disabled. If you intend to run Chatwoot on the same host, size for both from the start — Postgres tuning for two apps sharing a box is a different exercise from tuning for one.

Is self-hosting Twenty actually cheaper?​

Not at small team sizes, and this is the part most self-hosting guides skip. Twenty sells its own cloud at $9/user/mo subject to change · verified 2026-09-09 on the Pro plan billed annually, with a free self-hostable open-source core. The VPS is a fixed cost; the cloud is a per-seat cost. So there is a crossover.

SeatsTwenty Cloud Pro ($9/user/mo)Twenty Organization ($19/user/mo)Self-hosted on 8 GB VPS ($100/mo)
3$27/mo$57/mo$100/mo
5$45/mo$95/mo$100/mo
11$99/mo$209/mo$100/mo
12$108/mo$228/mo$100/mo
25$225/mo$475/mo$100/mo

Prices from twenty.com/pricing and liquidweb.com/vps-hosting/managed-vps/, both verified 2026-09-09. The 8 GB Managed VPS is $50/mo for the first two months at 50% off; the $100/mo regular price is the one the arithmetic has to use, because the promotional rate expires long before the migration pays for itself.

Against Twenty Cloud Pro, self-hosting breaks even between 11 and 12 seats. Against the Organization plan, which is where SAML SSO lives, it breaks even between 5 and 6. Below those thresholds you are paying more for the privilege of running Postgres yourself, and the table does not price your time at all — backups, upgrades, and the hour you lose the first time a migration surprises you.

Self-hosting below the crossover is a defensible decision. It is a data-residency decision or a customisation decision, not a savings decision, and a page that tells you otherwise is selling you a VPS.

How does it compare with HubSpot?​

Twenty is usually evaluated against HubSpot Sales Hub, so that is the comparison worth making. Sales Hub Professional is $90/seat/mo subject to change · verified 2026-09-09 billed annually, $100/seat/mo billed monthly, plus a one-time $1,500 onboarding fee.

HubSpot Sales Hub ProTwenty, self-hosted
5 seats, annual billing$450/mo + $1,500 once$100/mo VPS
Per-seat cost$90/seat/mo$0 — seats are unlimited
Custom objectsYesYes
Email campaignsMarketing Hub, sold separatelyNo — add Mautic or Listmonk
Phone / dialerYesNo
SAML SSOYesOrganization plan only, not the free core
Self-hostableNoYes
LicenseProprietaryAGPL-3.0 core, open core

Verified at hubspot.com/pricing/sales on 2026-09-09. A note on a number that circulates widely, including in the earlier version of this page: "HubSpot CRM Pro at $890/mo" is not a real product. HubSpot has no product called CRM Pro. The $890 figure comes from a Marketing Hub Professional calculation, which is a different product with email campaigns, SMS and landing pages that Twenty does not ship. Comparing it to a CRM overstates the saving by roughly 2x.

And the honest floor: HubSpot's free tier covers up to 2 users at $0, and Sales Hub Starter is $7/seat/mo billed annually. For a two-person team, nothing self-hosted competes with free.

Upgrading and backups​

# 1. Back up first. Migrations are forward-only.
docker compose exec -T db pg_dump -U postgres default | gzip > twenty-$(date +%F).sql.gz

# 2. Change the pin in .env, e.g. TAG=v2.40.0
docker compose pull

# 3. Recreate. The server runs any new migrations as it boots.
docker compose up -d
docker compose ps

Note the default credentials in that pg_dump: user postgres, database default. Those are upstream's defaults, not twenty/twenty — a detail that silently breaks copied backup scripts.

A daily cron version:

#!/bin/bash
# scripts/backup.sh
set -euo pipefail
cd /opt/twenty
mkdir -p backups
docker compose exec -T db pg_dump -U postgres default \
| gzip > "backups/twenty-$(date +%F-%H%M).sql.gz"
find backups/ -name '*.sql.gz' -mtime +14 -delete

Check the Twenty release notes before each upgrade. One wrinkle worth knowing: the Docker image tags run slightly ahead of the tagged GitHub releases. On 2026-09-09 the newest GitHub release was twenty/v2.39.0, tagged that same day, while Docker Hub had published twentycrm/twenty:v2.39.1 on 2026-09-08 — the image tag briefly ran ahead of the GitHub release. Pin to a tag you can find release notes for.

When you should not self-host Twenty​

  • You have fewer than about ten seats. See the arithmetic above. Twenty Cloud at $9/user/mo is cheaper and someone else owns the upgrades.
  • You need SAML SSO. Twenty's SSO documentation scopes it to the "Organization plan (cloud and self-hosted workspaces)" — it is not part of the free open-source core. You can put Authentik in front of Twenty as a workaround, but that is an extra service to run, not a supported configuration.
  • You need telephony or outbound email inside the CRM. Twenty has neither. Pairing it with Mautic or Listmonk works, but you are now operating three applications instead of buying one.
  • You need SOC 2 or HIPAA attestation on the CRM itself. Twenty carries no compliance certifications. A Liquid Web environment can be HIPAA-compliant at the infrastructure layer; that says nothing about the application's audit logging or access controls.
  • You do not want to be the on-call engineer. A Managed VPS manages the operating system. The containers, the migrations and the 2am restore are yours.

Exit strategy​

Two routes out, both worth testing before you commit data to the box.

# Full database dump — the complete escape hatch
docker compose exec -T db pg_dump -U postgres default > twenty_export.sql

Twenty also exports CSV from the UI, per object, with two documented limits worth knowing before you rely on it: up to 20,000 records per export, and only the columns visible in your current view are included (Twenty data-migration docs, fetched 2026-09-09). Add your hidden columns to the view first, or you will export a tidy file that quietly omits half your fields.

The data model is ordinary PostgreSQL, so any BI tool or migration script can read the dump directly.

Frequently Asked Questions

Four: server, worker, db (PostgreSQL 16) and redis, all defined in packages/twenty-docker/docker-compose.yml on the twentyhq/twenty main branch (fetched 2026-09-09). A fifth for a reverse proxy such as Caddy or nginx is your addition, not upstream's. The separate twentycrm/twenty-front container that older guides list no longer exists — that image is archived on Docker Hub and marked 'Deprecated - DO NOT USE', with its newest tag dating to 2024-03-22.

No. The server container runs them on boot. Upstream's compose file sets DISABLE_DB_MIGRATIONS to 'true' on the worker with the comment 'it already runs on the server', which is the clearest statement available that the server owns migrations. First run is docker compose up -d. Migrations are forward-only, so take a pg_dump before every version change.

ENCRYPTION_KEY and PG_DATABASE_PASSWORD. Generate the encryption key with openssl rand -base64 32. Upstream's .env.example documents FALLBACK_ENCRYPTION_KEY as the value you set to your previous key during a rotation, and APP_SECRET as legacy, 'only required for instances that pre-date ENCRYPTION_KEY' — so leave both empty on a new install. The four token secrets some guides still list (ACCESS_TOKEN_SECRET, LOGIN_TOKEN_SECRET, REFRESH_TOKEN_SECRET, FILE_TOKEN_SECRET) no longer exist upstream.

Only above roughly a dozen seats. Twenty Cloud Pro is $9/user/month billed annually (twenty.com/pricing, 2026-09-09); a Liquid Web 8 GB Managed VPS is $100/month regular (liquidweb.com, 2026-09-09). Eleven cloud seats cost $99, twelve cost $108, so the crossover sits between them. Against the $19/user Organization plan it falls between five and six seats. Neither figure prices your own time.

Twenty is open core, not purely AGPL. Its LICENSE file states the project is 'mostly licensed under' AGPLv3 with two carve-outs: files marked with an '@license Enterprise' comment are under a commercial license, and several packages (twenty-sdk, twenty-client-sdk, twenty-ui, twenty-apps and others) are MIT. For the AGPL portion, self-hosting for internal use triggers no publication obligation. Offering a modified Twenty to third parties as a network service does. Read the LICENSE file rather than assuming a plain AGPL project, and ask a lawyer if you plan to build a product on it.

Yes, via CSV. Twenty imports CSV, XLSX and XLS, one object type per file, through the command menu on the object you are importing into. One ordering requirement catches people out: fields must already exist before the import runs, because uploading a file creates records but not fields. Create your custom fields under Settings then Data Model first (docs.twenty.com data-migration guide, fetched 2026-09-09).

Common mistakes and fixes

The server container starts, then exits, and the last log line is a Postgres authentication failure.

`PG_DATABASE_PASSWORD` in `.env` does not match the password Postgres was initialised with. Postgres only reads `POSTGRES_PASSWORD` the first time the data volume is created, so changing the value in `.env` afterwards changes what the server sends but not what the database expects. Either set the password back to the original value, or wipe the volume and start over: `docker compose down -v` (this deletes all CRM data).

Login succeeds, then bounces straight back to the login screen, or the UI loads with broken asset URLs.

`SERVER_URL` does not match the URL you are actually browsing. It must be the exact public origin including scheme and any port: `https://crm.example.com` behind Caddy, or `http://localhost:3000` for a local test. Change it in `.env`, then `docker compose up -d` to recreate the server and worker containers.

The UI works but background jobs never run: emails don't send, imports sit unprocessed.

That is the worker, not the server. Check `docker compose logs worker`. The worker needs the same `REDIS_URL` and database credentials as the server, and it will not start until the `redis` service passes its `redis-cli ping` healthcheck. `docker compose ps redis` should show `healthy`.