Cal.com + Chatwoot + Twenty CRM: Self-Hosted Booking-to-CRM Pipeline (2026)
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:
- Self-Host Twenty CRM — Twenty architecture, footprint, and standalone setup
- Self-Host Chatwoot — Chatwoot sustainability flag, architecture, and standalone setup
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
| Tool | Role | Replaces |
|---|---|---|
| Cal.com | Scheduling pages, availability management, booking confirmations | Calendly, Acuity Scheduling |
| Chatwoot | Shared inbox — live chat, email, booking conversation threads | Intercom, Zendesk Support |
| Twenty CRM | Contacts, companies, deals, custom objects, pipelines | Salesforce Essentials, HubSpot CRM |
| PostgreSQL 16 | Shared database (three databases: calcom, chatwoot, twenty) | — |
| Redis 7 | Shared queue + cache (three DB indices) | — |
| Caddy | TLS 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.
Option A: Cal.com webhook → n8n → Chatwoot (recommended)
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:
- Receive the booking confirmation webhook from Cal.com
- Create a new contact in Chatwoot using the attendee's name and email
- Open a new conversation in Chatwoot with the booking details pre-filled
- 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:
- 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). - In Chatwoot: Settings → Inboxes → New Inbox → Email. Configure it to receive at the same address.
- 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.
| Service | Idle RAM | Peak RAM |
|---|---|---|
| calcom | 900 MB | 1,400 MB |
| chatwoot | 520 MB | 1,100 MB |
| chatwoot-sidekiq | 380 MB | 700 MB |
| twenty-api | 450 MB | 900 MB |
| twenty-worker | 200 MB | 350 MB |
| twenty-front | 80 MB | 100 MB |
| db (shared postgres) | 400 MB | 600 MB |
| redis (shared) | 50 MB | 80 MB |
| caddy | 15 MB | 20 MB |
| Total | 2,995 MB | 5,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 Teams | Intercom Starter | Salesforce Essentials | This stack | |
|---|---|---|---|---|
| Monthly cost | $20/user | $39/seat | $25/user | ~$30/mo VPS |
| Scheduling pages | ✓ | ✗ | ✗ | ✓ |
| Live chat | ✗ | ✓ | ✗ | ✓ |
| CRM | ✗ | Basic | ✓ | ✓ |
| 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.
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.

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.
