Skip to Content

Production Guide: Deploy Twenty CRM with Docker Compose + Caddy + PostgreSQL on Ubuntu

Self-host a full Salesforce alternative with zero SaaS dependency

Running a CRM that leaks your customer data to a SaaS vendor is a liability you can eliminate. Twenty CRM is a fully open-source, Salesforce-style CRM built on a modern stack — it gives your team pipelines, contacts, companies, tasks, notes, and custom objects without a per-seat bill or a vendor lock-in clause. If your team already self-hosts its infrastructure with Docker Compose, Twenty fits right in.

This guide walks you through deploying Twenty CRM on Ubuntu with Docker Compose, Caddy as a TLS-terminating reverse proxy, and PostgreSQL as the database backend. By the end, you will have a production-ready instance at https://crm.yourdomain.com with Redis for background queuing, automated HTTPS, and a hardened environment file.

Architecture and flow overview

Twenty runs as three primary services: the API server (NestJS, handles REST/GraphQL), the frontend (React SPA), and a worker process (handles email sending, webhook delivery, and background jobs). A dedicated PostgreSQL instance stores all relational data — contacts, companies, notes, and custom object schemas. Redis handles the BullMQ job queue that the worker drains.

Caddy sits in front, handling ACME certificate provisioning and HTTP-to-HTTPS redirects. All containers communicate on an internal Docker network; only Caddy exposes ports 80 and 443 to the host.

Flow:
  Browser → Caddy (443) → twenty-front (3000) or twenty-server (3000/api)
  twenty-worker ← Redis queue ← twenty-server
  twenty-server → PostgreSQL

Prerequisites

  • Ubuntu 22.04 or 24.04 server with a public IP
  • DNS A record pointing crm.yourdomain.com to your server IP
  • Docker Engine 24+ and Docker Compose v2 installed
  • Ports 80 and 443 open in UFW / cloud firewall
  • At least 2 GB RAM (4 GB recommended for active use)
  • A domain you control (Caddy will obtain Let's Encrypt certificates automatically)

Step-by-step deployment

1. Prepare the project directory

Create an isolated directory for the stack and set strict permissions on files that will contain secrets:

mkdir -p /opt/twenty && cd /opt/twenty
chmod 700 /opt/twenty

2. Create the environment file

Generate two strong secrets before writing the file. Never reuse these between environments:

openssl rand -hex 32   # use output as APP_SECRET
openssl rand -hex 32   # use output as ACCESS_TOKEN_SECRET

Create /opt/twenty/.env:

# Database
POSTGRES_USER=twenty
POSTGRES_PASSWORD=change_me_strong_password
POSTGRES_DB=twenty
DATABASE_URL=postgres://twenty:change_me_strong_password@db:5432/twenty

# Redis
REDIS_URL=redis://redis:6379

# App secrets (use openssl output above)
APP_SECRET=replace_with_generated_64char_hex
ACCESS_TOKEN_SECRET=replace_with_generated_64char_hex
REFRESH_TOKEN_SECRET=replace_with_generated_64char_hex

# Deployment URL
FRONT_DOMAIN=crm.yourdomain.com
SERVER_URL=https://crm.yourdomain.com

# Optional: SMTP for email notifications
# MAILER_TYPE=smtp
# SMTP_HOST=smtp.yourdomain.com
# SMTP_PORT=587
# [email protected]
# SMTP_PASS=smtp_password
chmod 600 /opt/twenty/.env

3. Create the Docker Compose file

Create /opt/twenty/docker-compose.yml:

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "twenty"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  server:
    image: twentycrm/twenty:latest
    restart: unless-stopped
    env_file: .env
    environment:
      - STORAGE_TYPE=local
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - server_local:/app/packages/twenty-server/.local-storage
    networks:
      - internal
    command: ["node", "packages/twenty-server/dist/main"]

  worker:
    image: twentycrm/twenty:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - server_local:/app/packages/twenty-server/.local-storage
    networks:
      - internal
    command: ["node", "packages/twenty-server/dist/queue-worker/main"]

volumes:
  db_data:
  redis_data:
  server_local:

networks:
  internal:
    driver: bridge

4. Create the Caddyfile

Create /opt/twenty/Caddyfile. Caddy will obtain and renew TLS certificates automatically:

crm.yourdomain.com {
    reverse_proxy 127.0.0.1:3000
    encode gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }
    log {
        output file /var/log/caddy/twenty.log
        format json
    }
}

If Caddy is running as a system service (not in Docker), ensure the log directory exists:

mkdir -p /var/log/caddy && chown caddy:caddy /var/log/caddy

5. Run database migrations and start the stack

Twenty requires an initial database schema migration before first run. Run the migration job, then start the full stack:

cd /opt/twenty

# Run migrations
docker compose run --rm server node packages/twenty-server/dist/database/commands/migrate-database

# Start all services
docker compose up -d

# Verify all containers are healthy
docker compose ps

Watch the server logs for the ready signal:

docker compose logs -f server | grep -E "started|error|listening"

Configuration and secrets handling

All secrets live in /opt/twenty/.env with mode 600 — readable only by root. Never commit this file to version control. Use a tool like Infisical or Vaultwarden to store master copies.

Key configuration variables to review before production use:

  • APP_SECRET — signs session tokens; rotate by restarting the server (all sessions invalidated)
  • ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET — sign JWTs; changing these invalidates all active user sessions
  • STORAGE_TYPE=local — stores file attachments on disk inside server_local Docker volume; switch to s3 for cloud-hosted attachments
  • SERVER_URL — must match your public domain exactly; used in email links and OAuth callbacks

For production deployments that send email notifications, configure the MAILER_* variables and set MAILER_TYPE=smtp. Without SMTP, password-reset and invitation emails will not be delivered.

Verification

After starting the stack, run through this checklist before handing the instance to end users:

# 1. Check all containers are Up
docker compose ps

# 2. Confirm HTTPS is serving correctly
curl -I https://crm.yourdomain.com

# 3. Check the API health endpoint
curl https://crm.yourdomain.com/api/health

# 4. Verify Redis connectivity from server
docker compose exec server node -e "const r=require('ioredis'); const c=new r(process.env.REDIS_URL); c.ping().then(console.log).finally(()=>c.disconnect())"

# 5. Confirm PostgreSQL reachability
docker compose exec db psql -U twenty -c "\l"

Open https://crm.yourdomain.com in a browser, complete the workspace setup wizard, and create your first user account. Verify you can create a contact and a company record before going live.

Common issues and fixes

Server exits on startup with migration error: Run the migration command manually (docker compose run --rm server node packages/twenty-server/dist/database/commands/migrate-database) and check output for DATABASE_URL connectivity failures. Ensure the db service is healthy before running migrations.

Caddy returns 502 Bad Gateway: The server container may still be starting or running migrations. Check docker compose logs server. Caddy's reverse proxy target must be 127.0.0.1:3000 (host-level) when Caddy runs outside Docker; change to server:3000 if Caddy is in the same Compose network.

Redis connection refused errors in server logs: Confirm the REDIS_URL in .env matches the service name in Compose (redis://redis:6379). Containers on an internal Docker network reach each other by service name, not localhost.

File attachments not persisting after container restart: Verify the server_local named volume is mounted to both server and worker services. Both need access to the same local storage path for uploaded files.

Cannot send invitation emails: Set MAILER_TYPE=smtp and all SMTP_* variables. Test with docker compose exec server node -e "require('./dist/modules/email/email.service')" to confirm the mail module loads without errors.

FAQ

Can I migrate data from HubSpot or Salesforce into Twenty CRM?

Yes. Twenty provides a CSV import feature via the Settings → Import/Export panel. Export your contacts and companies from HubSpot or Salesforce as CSVs, map columns to Twenty's field schema, and import in batches. For large migrations, use the Twenty GraphQL API to batch-insert records programmatically with a script.

How do I add custom fields and objects?

Twenty supports custom objects and fields through its Settings → Data Model panel. Create a new object (e.g., "Deal" or "Vendor"), define field types (text, number, relation, select), and immediately query them via GraphQL or REST. Custom objects behave identically to built-in objects — you can create records, set up list views, and filter by custom fields.

How do I upgrade Twenty to a newer version?

Pull the latest image, run any pending migrations, and restart the stack:

cd /opt/twenty
docker compose pull
docker compose run --rm server node packages/twenty-server/dist/database/commands/migrate-database
docker compose up -d

Always back up PostgreSQL before upgrading: docker compose exec db pg_dump -U twenty twenty > twenty_backup_$(date +%F).sql.

How do I enable SSO with Keycloak or Authentik?

Twenty supports OIDC-based SSO. In .env, set AUTH_GOOGLE_ENABLED=true and provide your OIDC provider's client ID, client secret, and issuer URL. For Keycloak, create a client with the redirect URI https://crm.yourdomain.com/auth/oidc/callback. Users will see a "Sign in with SSO" option on the login page.

How do I back up and restore Twenty's data?

All relational data is in PostgreSQL. Back up daily with a cron job:

# Back up
docker compose -f /opt/twenty/docker-compose.yml exec db pg_dump -U twenty twenty | gzip > /backups/twenty_$(date +%F).sql.gz

# Restore
gunzip -c /backups/twenty_2025-06-04.sql.gz | docker compose -f /opt/twenty/docker-compose.yml exec -T db psql -U twenty twenty

For file attachments stored in the server_local volume, use docker run --rm -v twenty_server_local:/data -v /backups:/out alpine tar czf /out/twenty_files_$(date +%F).tar.gz /data.

Can I run Twenty CRM with an external managed PostgreSQL database?

Yes. Set DATABASE_URL to point to your managed database host (e.g., AWS RDS, DigitalOcean Managed Postgres, or Supabase) and remove the db service from docker-compose.yml. Ensure the database user has CREATE TABLE, CREATE SCHEMA, and CREATE EXTENSION privileges — Twenty requires the uuid-ossp and pg_trgm extensions. Run the migration command after updating the URL.

How do I scale Twenty for a larger team?

For teams over 50 users, run multiple server containers behind a load balancer and scale the worker service separately (docker compose up -d --scale worker=2). Use an external Redis cluster and a managed PostgreSQL instance with connection pooling (PgBouncer). Twenty's stateless API design makes horizontal scaling straightforward.

Internal links

Talk to us

If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.

Contact Us

Production Guide: Deploy Formbricks with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host an open-source form and survey platform with full data ownership on Ubuntu