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.
Prerequisites
- Ubuntu 22.04 or 24.04 server with a public IP
- DNS A record pointing
crm.yourdomain.comto 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/twenty2. 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_SECRETCreate /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_passwordchmod 600 /opt/twenty/.env3. 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: bridge4. 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/caddy5. 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 psWatch 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_SECRETandREFRESH_TOKEN_SECRET— sign JWTs; changing these invalidates all active user sessionsSTORAGE_TYPE=local— stores file attachments on disk insideserver_localDocker volume; switch tos3for cloud-hosted attachmentsSERVER_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 -dAlways 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 twentyFor 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
- Production Guide: Deploy Infisical with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy Chatwoot with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy Mautic with Docker Compose + Caddy + MariaDB + Redis on Ubuntu
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.