Self-Hosting Twenty CRM with Docker Compose

What Is Twenty?

Twenty is an open-source CRM that aims to be a modern alternative to Salesforce. It handles contacts, companies, deals, tasks, and email integration with a clean interface that doesn’t look like it was designed in 2005. Built on TypeScript with a GraphQL API, it’s designed for teams that want CRM functionality without Salesforce’s complexity or per-seat pricing.

Why Self-Host a CRM?

FactorCloud CRM (Salesforce/HubSpot)Self-Hosted Twenty
Cost$25-300/user/month$0 (your hardware)
Data ownershipVendor’s serversYour server
CustomizationLimited/paid add-onsFull source access
Vendor lock-inHigh (data export pain)None
API accessTiered/limitedUnlimited GraphQL API

A 10-person team on Salesforce Professional ($80/user/month) spends $9,600/year. Twenty on a $20/month VPS costs $240/year.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • 2 GB of free RAM minimum (4 GB recommended)
  • 10 GB of free disk space
  • A domain name (recommended — some browser APIs require HTTPS)

Docker Compose Configuration

Twenty requires four services: the main server, a background worker, PostgreSQL, and Redis. Create a docker-compose.yml:

services:
  server:
    image: twentycrm/twenty:v1.18.1
    container_name: twenty-server
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      NODE_PORT: 3000
      PG_DATABASE_URL: "postgres://twenty:${PG_PASSWORD:-changeme_no_special_chars}@db:5432/twenty"
      SERVER_URL: "${SERVER_URL:-http://localhost:3000}"  # CHANGE to your domain
      REDIS_URL: "redis://redis:6379"
      APP_SECRET: "${APP_SECRET:-changeme_generate_secret}"  # CHANGE — openssl rand -base64 32
      STORAGE_TYPE: "local"
    volumes:
      - twenty-local-data:/app/packages/twenty-server/.local-storage
    ports:
      - "3000:3000"   # Web UI and API
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:3000/healthz"]
      interval: 5s
      timeout: 5s
      retries: 20
    restart: unless-stopped

  worker:
    image: twentycrm/twenty:v1.18.1
    container_name: twenty-worker
    command: ["yarn", "worker:prod"]
    depends_on:
      db:
        condition: service_healthy
      server:
        condition: service_healthy
    environment:
      PG_DATABASE_URL: "postgres://twenty:${PG_PASSWORD:-changeme_no_special_chars}@db:5432/twenty"
      SERVER_URL: "${SERVER_URL:-http://localhost:3000}"
      REDIS_URL: "redis://redis:6379"
      APP_SECRET: "${APP_SECRET:-changeme_generate_secret}"
      STORAGE_TYPE: "local"
      DISABLE_DB_MIGRATIONS: "true"       # Server handles migrations
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    volumes:
      - twenty-local-data:/app/packages/twenty-server/.local-storage
    restart: unless-stopped

  db:
    image: postgres:16.6
    container_name: twenty-db
    environment:
      POSTGRES_DB: twenty
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: "${PG_PASSWORD:-changeme_no_special_chars}"  # CHANGE
    volumes:
      - twenty-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U twenty"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:7.4
    container_name: twenty-redis
    command: ["--maxmemory-policy", "noeviction"]  # Required for BullMQ job queue
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

volumes:
  twenty-local-data:
  twenty-db-data:

Create a .env file alongside:

# Generate these before first start:
# openssl rand -base64 32
APP_SECRET=your_generated_secret_here

# Database password — NO special characters (Twenty limitation)
# openssl rand -hex 16
PG_PASSWORD=your_generated_password_here

# Your public URL (must match how users access it in browser)
SERVER_URL=https://crm.example.com

Start the stack:

docker compose up -d

The server takes 30-60 seconds to run database migrations on first start. Monitor progress:

docker logs -f twenty-server

Initial Setup

  1. Open your SERVER_URL (or http://your-server-ip:3000) in a browser
  2. Create your admin account with email and password
  3. Complete the onboarding wizard — set your workspace name and invite team members
  4. You’ll land on the contacts view with an empty CRM ready to populate

Import Existing Data

Twenty supports importing contacts, companies, and opportunities from CSV:

  1. Navigate to any object view (Contacts, Companies, etc.)
  2. Click the ”+” button → “Import”
  3. Upload your CSV and map columns to Twenty fields
  4. Review and confirm the import

Key Features

Twenty organizes around four core objects:

ObjectPurposeFields
PeopleIndividual contactsName, email, phone, company, city, job title
CompaniesOrganizationsName, domain, employees, address, industry
OpportunitiesSales pipelineStage, amount, close date, point of contact
TasksAction itemsTitle, body, due date, assignee, status

Pipeline and Stages

The Opportunities view provides a Kanban board with customizable pipeline stages. Drag deals between stages to track your sales process.

Email Integration

Twenty can sync with your email to automatically log conversations with contacts. Configure in Settings → Accounts → Email.

Custom Fields and Objects

Create custom fields on any object, or define entirely new objects to match your business workflow. Navigate to Settings → Data model.

Reverse Proxy

HTTPS is strongly recommended — clipboard operations and some browser APIs require a secure context. Caddy example:

crm.example.com {
    reverse_proxy localhost:3000
}

After setting up HTTPS, update SERVER_URL in your .env to match:

SERVER_URL=https://crm.example.com
docker compose up -d  # Restarts with new URL

See our Reverse Proxy Guide for Nginx Proxy Manager and Traefik configurations.

Backup

Back up the PostgreSQL database and file storage:

# Database dump
docker exec twenty-db pg_dump -U twenty twenty > twenty-backup-$(date +%Y%m%d).sql

# File storage (uploads, avatars)
docker run --rm -v twenty-local-data:/data -v $(pwd):/backup \
  alpine tar czf /backup/twenty-files-$(date +%Y%m%d).tar.gz /data

Restore:

cat twenty-backup-20260224.sql | docker exec -i twenty-db psql -U twenty twenty

See our Backup Strategy Guide for automated backup approaches.

Troubleshooting

Server Won’t Start — Migration Errors

Symptom: twenty-server logs show database migration failures.

Fix: Ensure PostgreSQL is fully healthy before the server starts. The depends_on: service_healthy condition handles this, but if you see issues:

# Check PostgreSQL health
docker exec twenty-db pg_isready -U twenty

# If DB is healthy but migrations fail, check logs for specific error
docker logs twenty-server 2>&1 | grep -i "migration"

“Invalid APP_SECRET” or Session Issues

Symptom: Users are randomly logged out. API calls return 401.

Fix: APP_SECRET changed between container recreations. Set it explicitly in .env and never change it after initial setup. If already corrupted:

# Set a permanent secret
echo "APP_SECRET=$(openssl rand -base64 32)" >> .env
docker compose up -d
# All users will need to log in again

Worker Not Processing Jobs

Symptom: Emails not syncing, webhooks not firing, background tasks stuck.

Fix: Check the worker container:

docker logs twenty-worker
docker ps | grep twenty-worker

The worker must start after the server (it depends on the server’s health check). If the worker keeps restarting, check that Redis is running:

docker exec twenty-redis redis-cli ping
# Should return: PONG

Slow Performance with Many Records

Symptom: UI becomes sluggish with 10K+ records.

Fix: Ensure PostgreSQL has enough shared memory. Add to the db service:

shm_size: '256m'
command: >
  postgres
  -c shared_buffers=256MB
  -c effective_cache_size=512MB
  -c work_mem=16MB

Resource Requirements

MetricValue
RAM (idle)~400 MB (server) + ~200 MB (worker) + ~100 MB (PostgreSQL) + ~50 MB (Redis)
RAM (active, 5+ users)1-2 GB total
CPUMedium (Node.js server + background jobs)
Disk~1 GB for application + database growth

Verdict

Twenty is the most promising open-source CRM for small to mid-size teams. The UI is genuinely modern — it feels like a product built in 2026, not a Salesforce skin from 2010. The four-service Docker setup is heavier than a single-container app, but the trade-off is real CRM functionality: pipeline management, email sync, custom objects, and a proper API.

The main caveat is maturity. Twenty is actively developed (v1.18 as of writing) and some features are still being refined. If you need battle-tested stability with thousands of contacts and complex workflows, EspoCRM has more years behind it. But if you value a clean developer experience and modern UI, Twenty is the better bet.

Comments