Skip to main content

Cal.com + Chatwoot + Twenty CRM: Self-Hosted Booking-to-CRM Pipeline (2026)

· 14 min read
Yassine El Haddad
Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.

TL;DR

  • One docker-compose.yml: Cal.com + Chatwoot + Twenty CRM + shared PostgreSQL + Redis + Caddy
  • Measured idle RAM: ~3.2 GB; peak: ~4.5 GB
  • Minimum Liquid Web tier: 8 GB Managed VPS (~$33–$40/mo)
  • Calendly Teams + Intercom Starter + Salesforce Essentials: ~$80+/user/mo; this stack: ~$30/mo flat

Most growth-stage teams stitch together Calendly for scheduling, Intercom for support chat, and Salesforce for pipeline — then pay Zapier to connect them. This guide deploys the open-source equivalents on a single VPS: Cal.com handles scheduling, Chatwoot handles conversations, and Twenty CRM tracks deals. When a prospect books a call, that event flows through the entire pipeline automatically.

The three tools share one PostgreSQL instance (three separate databases), one Redis instance (three separate DB indices), and one Caddy reverse proxy. The result is a complete booking-to-CRM funnel with no per-seat fees and no vendor lock-in.

Read the individual guides before deploying the combo:

Cal.com Docker disclaimer: The Cal.com Docker image (calcom/cal.com) is community-supported and is not an officially maintained distribution from Cal.com Inc. Behaviour, environment variable names, and image tags may change between releases without notice. Verify your setup against the Cal.com Docker documentation at deploy time and test thoroughly before using in production.

What you get

ToolRoleReplaces
Cal.comScheduling pages, availability management, booking confirmationsCalendly, Acuity Scheduling
ChatwootShared inbox — live chat, email, booking conversation threadsIntercom, Zendesk Support
Twenty CRMContacts, companies, deals, custom objects, pipelinesSalesforce Essentials, HubSpot CRM
PostgreSQL 16Shared database (three databases: calcom, chatwoot, twenty)
Redis 7Shared queue + cache (three DB indices)
CaddyTLS termination, routing

What's not included: email marketing (add Mautic), transactional SMTP (use Postmark or Mailgun), video conferencing (Cal.com integrates with Jitsi or your own Meet link).

Prerequisites

  • A Liquid Web 8 GB Managed VPS — verify pricing
  • Docker Engine 25+ and Docker Compose V2
  • Three subdomains pointed at the VPS (e.g., cal.yourdomain.com, support.yourdomain.com, crm.yourdomain.com)
  • A working SMTP service for booking confirmation emails (Postmark recommended)
  • About 90 minutes

The complete docker-compose.yml

This single file deploys all three apps with shared infrastructure. PostgreSQL runs one container with three databases. Redis runs one container with three DB indices (0 = Twenty, 1 = Chatwoot, 2 = Cal.com).

# booking-funnel-stack/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 8 GB RAM)
# Idle: ~3.2 GB RAM | Peak: ~4.5 GB

services:
# ─── Shared infrastructure ────────────────────────────────────────────────

db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
- ./scripts/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- stack_net

redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- stack_net

# ─── Cal.com ──────────────────────────────────────────────────────────────
# Community-supported Docker image — not officially maintained by Cal.com Inc.
# Verify env vars and image tags at deploy time.

calcom:
image: calcom/cal.com:v4.9.0
restart: unless-stopped
environment:
NEXTAUTH_SECRET: ${CALCOM_NEXTAUTH_SECRET}
NEXTAUTH_URL: https://${CAL_DOMAIN}
NEXT_PUBLIC_WEBAPP_URL: https://${CAL_DOMAIN}
DATABASE_URL: postgresql://calcom:${CALCOM_DB_PASSWORD}@db:5432/calcom
# Opt out of Cal.com telemetry
CALCOM_TELEMETRY_DISABLED: "1"
EMAIL_FROM: ${CALCOM_FROM_EMAIL}
EMAIL_SERVER_HOST: ${SMTP_ADDRESS}
EMAIL_SERVER_PORT: ${SMTP_PORT:-587}
EMAIL_SERVER_USER: ${SMTP_USERNAME}
EMAIL_SERVER_PASSWORD: ${SMTP_PASSWORD}
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
interval: 20s
timeout: 10s
retries: 5

# ─── Chatwoot ─────────────────────────────────────────────────────────────

chatwoot:
image: chatwoot/chatwoot:${CHATWOOT_VERSION:-v4.13.0}
restart: unless-stopped
command: bundle exec rails server -p 3000 -b 0.0.0.0
environment:
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
FRONTEND_URL: https://${SUPPORT_DOMAIN}
DEFAULT_LOCALE: en
POSTGRES_DATABASE: chatwoot_production
POSTGRES_USERNAME: chatwoot
POSTGRES_PASSWORD: ${CHATWOOT_DB_PASSWORD}
POSTGRES_HOST: db
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
STORAGE_DRIVER: local
ACTIVE_STORAGE_SERVICE: local
MAILER_SENDER_EMAIL: ${SUPPORT_FROM_EMAIL}
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_TLS: "true"
volumes:
- chatwoot_storage:/app/storage
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"]
interval: 15s
timeout: 10s
retries: 5

chatwoot-sidekiq:
image: chatwoot/chatwoot:${CHATWOOT_VERSION:-v4.13.0}
restart: unless-stopped
command: bundle exec sidekiq -C config/sidekiq.yml
environment:
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
FRONTEND_URL: https://${SUPPORT_DOMAIN}
POSTGRES_DATABASE: chatwoot_production
POSTGRES_USERNAME: chatwoot
POSTGRES_PASSWORD: ${CHATWOOT_DB_PASSWORD}
POSTGRES_HOST: db
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
RAILS_ENV: production
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_TLS: "true"
volumes:
- chatwoot_storage:/app/storage
depends_on:
chatwoot:
condition: service_healthy
networks:
- stack_net

# ─── Twenty CRM ───────────────────────────────────────────────────────────

twenty-api:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgresql://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
FRONT_BASE_URL: https://${CRM_DOMAIN}
SERVER_URL: https://${CRM_DOMAIN}
ACCESS_TOKEN_SECRET: ${TWENTY_ACCESS_TOKEN_SECRET}
LOGIN_TOKEN_SECRET: ${TWENTY_LOGIN_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${TWENTY_REFRESH_TOKEN_SECRET}
FILE_TOKEN_SECRET: ${TWENTY_FILE_TOKEN_SECRET}
APP_SECRET: ${TWENTY_APP_SECRET}
STORAGE_TYPE: local
MESSAGE_QUEUE_TYPE: bull-mq
volumes:
- twenty_storage:/app/packages/twenty-server/.local-storage
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/healthz || exit 1"]
interval: 15s
timeout: 5s
retries: 3

twenty-worker:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgresql://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
SERVER_URL: https://${CRM_DOMAIN}
ACCESS_TOKEN_SECRET: ${TWENTY_ACCESS_TOKEN_SECRET}
MESSAGE_QUEUE_TYPE: bull-mq
depends_on:
twenty-api:
condition: service_healthy
networks:
- stack_net

twenty-front:
image: twentycrm/twenty-front:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
environment:
REACT_APP_SERVER_BASE_URL: https://${CRM_DOMAIN}
depends_on:
twenty-api:
condition: service_healthy
networks:
- stack_net

# ─── Caddy TLS proxy ──────────────────────────────────────────────────────

caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
calcom:
condition: service_healthy
chatwoot:
condition: service_healthy
twenty-api:
condition: service_healthy
networks:
- stack_net

volumes:
db_data:
redis_data:
chatwoot_storage:
twenty_storage:
caddy_data:
caddy_config:

networks:
stack_net:
driver: bridge

Database init script

Create scripts/init-db.sh to provision separate databases and users for all three apps:

#!/bin/bash
# scripts/init-db.sh — runs on first postgres start
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE USER calcom WITH PASSWORD '${CALCOM_DB_PASSWORD}';
CREATE DATABASE calcom OWNER calcom;

CREATE USER chatwoot WITH PASSWORD '${CHATWOOT_DB_PASSWORD}';
CREATE DATABASE chatwoot_production OWNER chatwoot;

CREATE USER twenty WITH PASSWORD '${TWENTY_DB_PASSWORD}';
CREATE DATABASE twenty OWNER twenty;
EOSQL

Make it executable: chmod +x scripts/init-db.sh

Caddyfile

# Caddyfile — Cal.com + Chatwoot + Twenty CRM

# Cal.com scheduling
cal.yourdomain.com {
reverse_proxy calcom:3000

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

# Chatwoot support inbox
support.yourdomain.com {
reverse_proxy chatwoot:3000

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

# Twenty CRM
crm.yourdomain.com {
reverse_proxy /api/* twenty-api:3000
reverse_proxy /graphql twenty-api:3000
reverse_proxy /auth/* twenty-api:3000
reverse_proxy /metadata twenty-api:3000
reverse_proxy /files/* twenty-api:3000
reverse_proxy twenty-front:3000

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

.env file

# .env — keep out of version control

# Versions
CALCOM_VERSION=v4.9.0
CHATWOOT_VERSION=v4.13.0
TWENTY_VERSION=v2.1.0

# Domains
CAL_DOMAIN=cal.yourdomain.com
SUPPORT_DOMAIN=support.yourdomain.com
CRM_DOMAIN=crm.yourdomain.com

# Shared PostgreSQL superuser
POSTGRES_PASSWORD=change-me-postgres-superuser-password

# Cal.com database
CALCOM_DB_PASSWORD=change-me-calcom-db-password

# Cal.com secrets — generate with: openssl rand -hex 32
CALCOM_NEXTAUTH_SECRET=

# Chatwoot database
CHATWOOT_DB_PASSWORD=change-me-chatwoot-db-password

# Chatwoot secret — generate with: openssl rand -hex 32
CHATWOOT_SECRET_KEY_BASE=

# Twenty database
TWENTY_DB_PASSWORD=change-me-twenty-db-password

# Twenty secrets — generate each with: openssl rand -hex 32
TWENTY_ACCESS_TOKEN_SECRET=
TWENTY_LOGIN_TOKEN_SECRET=
TWENTY_REFRESH_TOKEN_SECRET=
TWENTY_FILE_TOKEN_SECRET=
TWENTY_APP_SECRET=

# Shared Redis
REDIS_PASSWORD=change-me-redis-password

# SMTP (used by Cal.com and Chatwoot)
CALCOM_FROM_EMAIL=bookings@yourdomain.com
SUPPORT_FROM_EMAIL=support@yourdomain.com
SMTP_ADDRESS=smtp.postmarkapp.com
SMTP_PORT=587
SMTP_USERNAME=your-postmark-token
SMTP_PASSWORD=your-postmark-token

Generate all secrets in one pass:

for var in CALCOM_NEXTAUTH_SECRET CHATWOOT_SECRET_KEY_BASE TWENTY_ACCESS_TOKEN_SECRET TWENTY_LOGIN_TOKEN_SECRET TWENTY_REFRESH_TOKEN_SECRET TWENTY_FILE_TOKEN_SECRET TWENTY_APP_SECRET; do
echo "${var}=$(openssl rand -hex 32)"
done

First-run setup

Step 1 of 9: Point DNS

cal.yourdomain.com. A <your-vps-ip>
support.yourdomain.com. A <your-vps-ip>
crm.yourdomain.com. A <your-vps-ip>

Allow DNS to propagate (typically 1–5 minutes on modern registrars).

Step 2 of 9: Start the database and Redis

docker compose up -d db redis
docker compose ps db # wait for healthy (up to 60s)

Step 3 of 9: Initialise Cal.com's database

Cal.com runs Prisma migrations on first startup. Start the container and watch for migration completion:

docker compose up -d calcom
docker compose logs calcom -f # wait for "Ready" log line

Step 4 of 9: Initialise Chatwoot's database

docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare

Step 5 of 9: Initialise Twenty's database

docker compose run --rm twenty-api yarn database:migrate:prod

Step 6 of 9: Start all services

docker compose up -d

Step 7 of 9: Set up Cal.com

Visit https://cal.yourdomain.com. The onboarding wizard will prompt you to create an admin account, configure your timezone, and set up your first event type (e.g., "30-minute intro call").

Step 8 of 9: Create the Chatwoot admin user

docker compose exec chatwoot bundle exec rails c
# Inside Rails console:
User.create!(name: 'Admin', email: 'admin@yourdomain.com',
password: 'your-strong-password', role: :administrator,
confirmed_at: Time.now.utc)
exit

Visit https://support.yourdomain.com and log in.

Step 9 of 9: Create the Twenty workspace

Visit https://crm.yourdomain.com and follow the Twenty onboarding wizard to create your workspace and first pipeline.

Verify the stack

# All services healthy
docker compose ps

# Cal.com
curl -s https://cal.yourdomain.com/api/health
# {"status":"ok"}

# Chatwoot
curl -s https://support.yourdomain.com/auth/sign_in | grep -q "Chatwoot" && echo "OK"

# Twenty
curl -s https://crm.yourdomain.com/api/healthz
# {"status":"ok"}

# Resource usage
docker stats --no-stream

Connecting the pipeline

This is where the three tools become a booking funnel rather than three isolated apps.

Chatwoot does not natively receive Cal.com booking events. The cleanest bridge is n8n, a self-hosted workflow automation tool. You can add an n8n service to the same Compose file and use its Cal.com trigger node and Chatwoot action node to:

  1. Receive the booking confirmation webhook from Cal.com
  2. Create a new contact in Chatwoot using the attendee's name and email
  3. Open a new conversation in Chatwoot with the booking details pre-filled
  4. When the Chatwoot conversation is resolved (call completed), create a person and deal in Twenty CRM via Twenty's GraphQL API

To configure the Cal.com webhook endpoint: Cal.com → Settings → Developer → Webhooks → New Webhook. Enter your n8n webhook URL (e.g., https://n8n.yourdomain.com/webhook/cal-booking), select the BOOKING_CREATED and BOOKING_CANCELLED events, and optionally set a webhook secret for payload verification.

Option B: Cal.com email notifications → Chatwoot email inbox (no custom code)

If you prefer zero custom code, use Cal.com's built-in email notifications routed into Chatwoot's email inbox:

  1. In Cal.com event settings, enable attendee and organizer confirmation emails. Set the organizer notification email to your Chatwoot inbox address (e.g., bookings@yourdomain.com).
  2. In Chatwoot: Settings → Inboxes → New Inbox → Email. Configure it to receive at the same address.
  3. Each new booking generates an email that Chatwoot converts into a conversation automatically. Agents can reply from Chatwoot; replies go to the attendee as email.

This approach requires no webhook infrastructure but is less automated — moving contacts to Twenty CRM remains a manual step (or a scheduled export).

Chatwoot → Twenty CRM on conversation resolve

Regardless of the bridge method, closing the loop into Twenty CRM requires calling Twenty's GraphQL API. A minimal example using curl:

# Create a person in Twenty CRM when a conversation is resolved
curl -X POST https://crm.yourdomain.com/graphql \
-H "Authorization: Bearer YOUR_TWENTY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { createPerson(data: { name: { firstName: \"Jane\", lastName: \"Smith\" }, emails: { primaryEmail: \"jane@example.com\" } }) { id } }"
}'

Generate a Twenty API key in: Twenty CRM → Settings → API & Webhooks → API Keys → New API Key.

Runtime footprint

Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM) — equivalent to a Liquid Web 8 GB Managed VPS.

ServiceIdle RAMPeak RAM
calcom900 MB1,400 MB
chatwoot520 MB1,100 MB
chatwoot-sidekiq380 MB700 MB
twenty-api450 MB900 MB
twenty-worker200 MB350 MB
twenty-front80 MB100 MB
db (shared postgres)400 MB600 MB
redis (shared)50 MB80 MB
caddy15 MB20 MB
Total2,995 MB5,250 MB

An 8 GB Managed VPS has approximately 5.9 GB free after OS overhead. This stack idles at ~3 GB and peaks at ~5.2 GB under active booking + conversation load. There is limited headroom — do not add additional services without upgrading to a 16 GB VPS.

Cost vs Calendly + Intercom + Salesforce

Calendly TeamsIntercom StarterSalesforce EssentialsThis stack
Monthly cost$20/user$39/seat$25/user~$30/mo VPS
Scheduling pages
Live chat
CRMBasic
Self-hostable

A 3-person team on Calendly Teams ($60/mo) + Intercom Starter ($117/mo) + Salesforce Essentials ($75/mo) pays ~$252/mo before Zapier. This stack: ~$30/mo flat, unlimited seats.

Prices subject to change — verify at calendly.com/pricing, intercom.com/pricing, salesforce.com/pricing, and liquidweb.com/vps-hosting/managed-vps/.

When this stack isn't right for you

  • Your team books more than ~200 calls/day — Cal.com's Next.js app is not optimised for high-volume scheduling without a dedicated database. Consider Cal.com Cloud for that scale.
  • You need deep Salesforce integrations — if your existing tools expect Salesforce's API, replacing it mid-stream is a migration project, not a configuration change.
  • Cal.com Docker image stability is a concern — this is a community-supported image. If you cannot tolerate breakage on upgrades, use Cal.com Cloud for the scheduling layer and self-host only Chatwoot and Twenty.
  • You need telephony or video conferencing — none of these tools include a dialer. Cal.com supports adding a custom video conferencing link (Google Meet, Zoom, Jitsi) but doesn't host video.
  • Your team has more than ~50 concurrent support agents — at that scale, Chatwoot's single-server PostgreSQL setup needs tuning or a managed database. Liquid Web Managed Databases (quote-based) are the upgrade path.

Troubleshooting

Cal.com shows a blank page after login

NEXT_PUBLIC_WEBAPP_URL must exactly match the URL in your browser address bar — including https:// and without a trailing slash. NEXTAUTH_URL must also match. A mismatch causes silent redirect failures. Check both values in your .env and restart the container: docker compose restart calcom.

Chatwoot webhook returns 401

The Cal.com webhook endpoint must be publicly reachable and must authenticate correctly. If you are using n8n as the webhook receiver, ensure the n8n webhook URL is set to "No Authentication" or that you pass the correct token. If calling the Chatwoot API directly, the api_access_token header must be set — it is not the same as the admin password. Generate a token in: Chatwoot → Profile → Access Token.

Twenty CRM shows "Cannot connect to metadata server"

Twenty's metadata server runs as a background process within the twenty-api container and may take 20–40 seconds to start after the container becomes healthy. Wait 30 seconds and refresh the browser. If the error persists: docker compose logs twenty-server --tail 20 (or docker compose logs twenty-api --tail 30) to check for startup errors. A missing or incorrect APP_SECRET or ACCESS_TOKEN_SECRET is a common cause.

Frequently Asked Questions

Yes — that is exactly what this guide does. Caddy routes cal.yourdomain.com to Cal.com, support.yourdomain.com to Chatwoot, and crm.yourdomain.com to Twenty CRM. All three share the same PostgreSQL and Redis containers but use separate databases and Redis key namespaces (DB index 0 for Twenty, 1 for Chatwoot, 2 reserved for Cal.com).

Update the image tag in your .env file (e.g., CALCOM_VERSION=v4.10.0), pull the new image with `docker compose pull calcom`, then restart with `docker compose up -d calcom`. Cal.com runs Prisma migrations automatically on startup — check the logs with `docker compose logs calcom -f` to confirm migrations complete successfully before declaring the upgrade done. Always test on a staging environment first.

Use Cal.com's organizer notification email routed into a Chatwoot email inbox. Configure the organizer email in your Cal.com event settings to point at your Chatwoot inbox address. Each booking confirmation email becomes a Chatwoot conversation automatically, and agents can reply from Chatwoot. It requires no webhooks or middleware — the tradeoff is that it is reactive (email-based) rather than real-time API-driven.

Photo of Yassine El Haddad

Yassine El Haddad

Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.