Cal.com (Cal.diy) + Chatwoot + Twenty CRM: Self-Hosted Booking-to-CRM Pipeline (2026)
TL;DR
- One
docker-compose.yml: Cal.diy (the open-source Cal.com, built from source) + Chatwoot + Twenty CRM + shared PostgreSQL + Redis + Caddy. - Cal.com went closed source in April 2026. The self-hostable code is now calcom/cal.diy (MIT), with no published Docker image, so you build it locally.
- Cal.diy keeps booking pages, calendar sync, the app store and webhooks. It dropped teams, round-robin, routing forms and Workflows, so this stack replaces Calendly Standard, not Calendly Teams.
- Cal.diy's own site recommends it for personal, non-production use. Read the advisory before you put customer bookings on it.
- Hosting: Liquid Web 8 GB Managed VPS, $100/mo regular ($50/mo for the first two months), checked 2026-09-26.
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.diy handles scheduling, Chatwoot handles conversations, and Twenty CRM tracks deals. When a prospect books a call, a webhook carries that booking into the support inbox and on into the CRM.
The three apps share one PostgreSQL instance (three separate databases), one Redis instance (separate DB indices for Chatwoot and Twenty), and one Caddy reverse proxy. There are no per-seat fees, and every piece is open source you can move to another server.
Read the individual guides before deploying the combo:
- Self-host Cal.com (Cal.diy) with Docker: Cal.diy build, env vars and upgrade path on its own
- Self-host Twenty CRM: Twenty architecture, secrets and standalone setup
- Self-host Chatwoot: Chatwoot sustainability flag, architecture and standalone setup
What changed with Cal.com in 2026
Cal.com announced on 14 April 2026 that its commercial codebase was going closed source, citing AI tools that make it easy to scan public code for vulnerabilities (announcement). The public repository became Cal.diy, relicensed from AGPL-3.0 to MIT, with the enterprise code removed (technical post, 15 April 2026). The old calcom/cal.com GitHub URL now resolves to calcom/cal.diy.
What that means for a booking funnel:
| Feature | In Cal.diy? | Effect on this stack |
|---|---|---|
| Personal booking pages, event types, availability | Yes | Works as before |
| Calendar and video apps (Google Calendar, Outlook, Google Meet, Zoom, Jitsi, Stripe) | Yes, the app store is kept | Works as before |
Webhooks (BOOKING_CREATED, BOOKING_CANCELLED, and others) | Yes, confirmed in the repo 2026-09-26 | The pipeline below still works |
| API v2 | Yes, as a separate calcom-api service | Not started in this stack; add it if you need it |
| API v1 | Removed | Old integrations that call /v1 break |
| Teams, round-robin, collective events, Organizations | Removed | No shared sales-team booking page |
| Routing Forms | Removed | No lead-qualification form before booking |
| Workflows (reminder emails and SMS) | Removed | Rebuild reminders in n8n, or go without |
| SAML/SSO, Insights, Instant Booking | Removed | Cloud-only now |
Source for the removed list: Cal.com's technical post and the cal.diy README, both checked 2026-09-26. If your team needs round-robin across several reps, self-hosting no longer covers it. That now means Cal.com's cloud Teams plan or Calendly Teams.
Is Cal.diy safe to run a business funnel on?
Cal.com says not to. The cal.diy site says the project is strictly recommended for personal, non-production use, that "for any commercial and enterprise-ready scheduling infrastructure, use Cal.com, not Cal.diy", and that Cal.com, Inc. does not guarantee the security of the open-source project. The MIT license allows commercial use; the vendor advises against it. A booking funnel for paying customers is commercial use, so decide with that on the table.
The practical middle ground: run Chatwoot and Twenty yourself and keep scheduling on Cal.com's cloud or Calendly. The webhook pipeline below is the same idea whichever scheduler sends the webhook, though the payload fields differ between vendors.
Docker image status (checked 2026-09-26)
calcom/cal.comon Docker Hub stopped atv6.2.0on 2026-03-02, before the split. Thecalcom/cal.com:v4.9.0image this guide used in May is two major versions behind and pre-split.calcom/cal.diyexists on Docker Hub (registered 2026-04-14) but has no tags, so there is nothing to pull.- The cal.diy Docker docs tell you to
docker compose pulland mention av5.6.19-armtag. Neither matches Docker Hub today. The repo's owndocker-compose.ymlhas abuild:section, and that is what works. - The old
calcom/dockerrepository is archived. - The latest git tag is still
v6.2.0(2026-03-01), from before the split. Commits continue onmain, so this guide builds frommainand asks you to record the commit you built.
What you get
| Tool | Role | Replaces |
|---|---|---|
| Cal.diy | Personal booking pages, availability, booking confirmations, webhooks | Calendly Standard |
| Chatwoot | Shared inbox: live chat, email, booking conversation threads | Intercom, Zendesk Support |
| Twenty CRM | Contacts, companies, deals, custom objects, pipelines | Salesforce Starter, HubSpot CRM |
| PostgreSQL 16 | Shared database (three databases: calcom, chatwoot_production, twenty) | - |
| Redis 7 | Shared queue and cache (DB 0 for Twenty, DB 1 for Chatwoot) | - |
| Caddy | TLS termination, routing | - |
What's not included: email marketing (add Mautic), transactional SMTP (use Postmark or Mailgun), and video hosting. Cal.diy's app store still includes Jitsi, Google Meet, Zoom and Microsoft Teams, so each booking can get a meeting link from one of those.
Prerequisites
- A Liquid Web 8 GB Managed VPS: verify pricing
- Docker Engine 25+, Docker Compose V2 and
git - Three subdomains pointed at the VPS (for example
cal.yourdomain.com,support.yourdomain.com,crm.yourdomain.com) - A working SMTP service for booking confirmation emails (Postmark recommended)
- About two hours. The first Cal.diy build compiles a large Next.js monorepo and takes much longer than pulling an image.
Get the Cal.diy source
Create the stack directory and clone Cal.diy into it. The compose file below builds the calcom service from ./cal.diy.
mkdir -p booking-funnel-stack/scripts && cd booking-funnel-stack
git clone --depth 1 https://github.com/calcom/cal.diy.git
git -C cal.diy rev-parse --short HEAD # write this down: it is your "version"
There are no post-split releases to pin to, so the commit hash is the only way to know what you are running. Checking out the v6.2.0 tag gets you the pre-split AGPL code with the enterprise directories still in it, not Cal.diy.
The complete docker-compose.yml
This single file deploys all three apps with shared infrastructure. The Cal.diy service is adapted from the upstream docker-compose.yml and Dockerfile (main, fetched 2026-09-26). The Twenty and Chatwoot services match the versions verified in their standalone guides.
We have not re-run this full stack end to end since switching it to Cal.diy. Each service definition follows its upstream source, but treat the first deploy as a test.
# booking-funnel-stack/docker-compose.yml
# Revised 2026-09-26: Cal.com v4.9.0 image replaced by a local Cal.diy build,
# Twenty moved to the single-image layout, Chatwoot bumped to v4.17.1.
services:
# ─── Shared infrastructure ────────────────────────────────────────────────
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Read by init-db.sh on first start
CALCOM_DB_PASSWORD: ${CALCOM_DB_PASSWORD}
CHATWOOT_DB_PASSWORD: ${CHATWOOT_DB_PASSWORD}
TWENTY_DB_PASSWORD: ${TWENTY_DB_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
# noeviction: Twenty keeps its job queue in Redis and upstream requires it
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory-policy noeviction
volumes:
- redis_data:/data
networks:
- stack_net
# ─── Cal.diy (open-source Cal.com) ────────────────────────────────────────
# No image is published, so this builds from ./cal.diy.
# Build args mirror cal.diy's own docker-compose.yml.
calcom:
image: booking-funnel/cal-diy:local
build:
context: ./cal.diy
dockerfile: Dockerfile
args:
NEXT_PUBLIC_WEBAPP_URL: https://${CAL_DOMAIN}
CALCOM_TELEMETRY_DISABLED: "1"
NEXTAUTH_SECRET: ${CALCOM_NEXTAUTH_SECRET}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
DATABASE_URL: postgresql://calcom:${CALCOM_DB_PASSWORD}@db:5432/calcom
restart: unless-stopped
environment:
NEXT_PUBLIC_WEBAPP_URL: https://${CAL_DOMAIN}
NEXTAUTH_URL: https://${CAL_DOMAIN}
NEXTAUTH_SECRET: ${CALCOM_NEXTAUTH_SECRET}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
DATABASE_URL: postgresql://calcom:${CALCOM_DB_PASSWORD}@db:5432/calcom
DATABASE_DIRECT_URL: postgresql://calcom:${CALCOM_DB_PASSWORD}@db:5432/calcom
# The image's start script waits on this host:port before migrating
DATABASE_HOST: db:5432
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:
# The Cal.diy runtime image installs wget; its own HEALTHCHECK uses the same call
test: ["CMD-SHELL", "wget --spider -q http://localhost:3000 || exit 1"]
interval: 30s
timeout: 30s
retries: 5
start_period: 180s
# ─── Chatwoot ─────────────────────────────────────────────────────────────
chatwoot:
image: chatwoot/chatwoot:${CHATWOOT_VERSION:-v4.17.1}
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.17.1}
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
STORAGE_DRIVER: local
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 ───────────────────────────────────────────────────────────
# One image for server and worker; the front end is served by the server.
# The separate twentycrm/twenty-front image stopped at 0.3.3 in 2024.
twenty-server:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.39.1}
restart: unless-stopped
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgres://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
SERVER_URL: https://${CRM_DOMAIN}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
STORAGE_TYPE: local
ENCRYPTION_KEY: ${TWENTY_ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ""
APP_SECRET: ""
volumes:
- twenty_storage:/app/packages/twenty-server/.local-storage
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl --fail http://localhost:3000/healthz || exit 1"]
interval: 5s
timeout: 5s
retries: 20
twenty-worker:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.39.1}
restart: unless-stopped
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
SERVER_URL: https://${CRM_DOMAIN}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
DISABLE_DB_MIGRATIONS: "true" # the server runs migrations
DISABLE_CRON_JOBS_REGISTRATION: "true" # the server registers cron jobs
STORAGE_TYPE: local
ENCRYPTION_KEY: ${TWENTY_ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ""
APP_SECRET: ""
volumes:
- twenty_storage:/app/packages/twenty-server/.local-storage
depends_on:
twenty-server:
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-server:
condition: service_healthy
networks:
- stack_net
volumes:
db_data:
redis_data:
chatwoot_storage:
twenty_storage:
caddy_data:
caddy_config:
networks:
stack_net:
driver: bridge
Two things differ from Cal.diy's own compose file on purpose. It skips the calcom-api (API v2) and Prisma Studio services, which a booking page and webhooks do not need. It also publishes no ports except through Caddy; upstream maps 3000, 5555 and the Redis port straight onto the host.
Database init script
Create scripts/init-db.sh to provision a database and user for each app. It reads the three passwords from the db service's environment, which is why the compose file passes them in.
#!/bin/bash
# scripts/init-db.sh — runs once, 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
The postgres image runs this only when it initialises an empty data volume. If you change a password later, use ALTER USER inside the database; editing .env alone will not change it.
Caddyfile
# Caddyfile — Cal.diy + Chatwoot + Twenty CRM
# Cal.diy 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: one upstream now that the front end ships inside the server image
crm.yourdomain.com {
reverse_proxy twenty-server:3000
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options DENY
X-Content-Type-Options nosniff
}
}
If you embed the Cal.diy booking widget on your marketing site, X-Frame-Options SAMEORIGIN on the Cal.diy host will block the iframe. Remove that header from the cal.yourdomain.com block if you use embeds.
.env file
# .env — keep out of version control
# Versions (Cal.diy has no version variable: it builds from ./cal.diy)
CHATWOOT_VERSION=v4.17.1
TWENTY_VERSION=v2.39.1
# Domains
CAL_DOMAIN=cal.yourdomain.com
SUPPORT_DOMAIN=support.yourdomain.com
CRM_DOMAIN=crm.yourdomain.com
# Shared PostgreSQL superuser
POSTGRES_PASSWORD=
# Per-app database passwords (hex only: they go inside connection URLs)
CALCOM_DB_PASSWORD=
CHATWOOT_DB_PASSWORD=
TWENTY_DB_PASSWORD=
# Cal.diy secrets
CALCOM_NEXTAUTH_SECRET= # openssl rand -base64 32
CALENDSO_ENCRYPTION_KEY= # openssl rand -base64 24 (per cal.diy's .env.example)
# Chatwoot secret
CHATWOOT_SECRET_KEY_BASE= # openssl rand -hex 64
# Twenty: the one secret a fresh install needs
TWENTY_ENCRYPTION_KEY= # openssl rand -base64 32
# Shared Redis
REDIS_PASSWORD=
# SMTP (used by Cal.diy 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 and paste the output into .env:
for var in POSTGRES_PASSWORD CALCOM_DB_PASSWORD CHATWOOT_DB_PASSWORD TWENTY_DB_PASSWORD REDIS_PASSWORD; do
echo "${var}=$(openssl rand -hex 24)"
done
echo "CALCOM_NEXTAUTH_SECRET=$(openssl rand -base64 32)"
echo "CALENDSO_ENCRYPTION_KEY=$(openssl rand -base64 24)"
echo "CHATWOOT_SECRET_KEY_BASE=$(openssl rand -hex 64)"
echo "TWENTY_ENCRYPTION_KEY=$(openssl rand -base64 32)"
Passwords use -hex because base64 output can contain / and +, which break a postgres:// URL unless you percent-encode them. Cal.diy's .env.example lists about 160 variables (Stripe, Sentry, Twilio and others). The compose file passes only the ones this stack uses.
First-run setup
Step 1 of 10: Point DNS
cal.yourdomain.com. A <your-vps-ip>
support.yourdomain.com. A <your-vps-ip>
crm.yourdomain.com. A <your-vps-ip>
Caddy requests TLS certificates on first start, so the records need to resolve before step 7.
Step 2 of 10: Build the Cal.diy image first
docker compose build calcom
Do this before anything else is running. Cal.diy's Dockerfile sets Node's build heap limit to 6,144 MB (MAX_OLD_SPACE_SIZE=6144), which is most of an 8 GB VPS. If the build gets killed, see Troubleshooting. You can also build on a larger machine and push the image to a private registry, then change image: to point at it.
Step 3 of 10: Start the database and Redis
docker compose up -d db redis
docker compose ps db # wait for healthy (up to 60s)
Step 4 of 10: Start Cal.diy
The image's start script waits for DATABASE_HOST, runs prisma migrate deploy, seeds the app store, then starts the app. You do not run migrations by hand.
docker compose up -d calcom
docker compose logs calcom -f --tail 30 # wait until the app is listening on port 3000
Step 5 of 10: Initialise Chatwoot's database
docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare
Step 6 of 10: Twenty needs no migration step
The Twenty server runs its own migrations on boot, and the worker sets DISABLE_DB_MIGRATIONS. If an older guide told you to run yarn database:migrate:prod, skip it.
Step 7 of 10: Start everything
docker compose up -d
docker compose ps # all services Up; calcom, chatwoot and twenty-server healthy
Step 8 of 10: Set up Cal.diy
Visit https://cal.yourdomain.com and complete the setup wizard to create your admin account, set your timezone and create your first event type (for example, "30-minute intro call").
Step 9 of 10: Create the Chatwoot admin user
docker compose exec chatwoot bundle exec rails c
# Inside the 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 10 of 10: 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.diy: /api/version returns the package version as JSON
curl -s https://cal.yourdomain.com/api/version
# Chatwoot
curl -s https://support.yourdomain.com/auth/sign_in | grep -q "Chatwoot" && echo "OK"
# Twenty
curl -s https://crm.yourdomain.com/healthz
# Resource usage
docker stats --no-stream
Older versions of this guide checked /api/health on Cal.com. That route does not exist in the Cal.diy source (checked 2026-09-26), which is why the check above uses /api/version.
Connecting the pipeline
This is where the three tools become a booking funnel rather than three isolated apps.
Option A: Cal.diy webhook → n8n → Chatwoot (recommended)
Cal.diy kept webhooks: the settings page and the trigger events (BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED and more) are all in the current source. Chatwoot does not receive Cal.diy events on its own, so put n8n in between. Add an n8n service to the same Compose file, then build a workflow that:
- Receives the booking webhook on n8n's generic Webhook trigger node
- Creates a contact in Chatwoot from the attendee's name and email, using an HTTP Request node against Chatwoot's API (n8n has no built-in Chatwoot node)
- Opens a conversation in Chatwoot with the booking details
- When the conversation is resolved, creates a person and deal in Twenty CRM through Twenty's GraphQL API
To create the webhook: Cal.diy → Settings → Developer → Webhooks → New. Enter your n8n webhook URL (for example https://n8n.yourdomain.com/webhook/cal-booking), select BOOKING_CREATED and BOOKING_CANCELLED, and set a secret. Cal.diy signs each payload with HMAC-SHA256 and sends it in the X-Cal-Signature-256 header, so n8n can reject requests that did not come from your instance.
Why not n8n's dedicated Cal.com Trigger node? It registers webhooks through Cal.com's API, and its credential defaults to https://api.cal.com. On Cal.diy, API v1 is gone and API v2 runs as the separate calcom-api service this stack does not start. We have not tested the trigger node against Cal.diy, so this guide creates the webhook by hand instead.
Option B: Cal.diy email notifications → Chatwoot email inbox (no custom code)
If you want zero custom code, route booking emails into a Chatwoot email inbox:
- In Chatwoot: Settings → Inboxes → New Inbox → Email, receiving at an address such as
bookings@yourdomain.com. - Cal.diy sends the host's booking confirmation to the host account's email address. Either register the Cal.diy account with the inbox address, or add a forwarding rule in your mail provider that sends Cal.diy confirmations to it.
- Each booking email becomes a Chatwoot conversation. Agents can reply from Chatwoot, and replies go to the attendee by email.
The Workflows feature that let you send extra notification emails to custom addresses is not in Cal.diy, so the host's own confirmation email is the hook. Moving contacts into Twenty stays a manual step (or a scheduled export) with this option.
Chatwoot → Twenty CRM on conversation resolve
Whichever bridge you use, closing the loop into Twenty CRM means calling Twenty's GraphQL API. A minimal example with 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
We have not re-measured this stack since switching it to Cal.diy and the single-image Twenty layout. The table below is the May 2026 measurement of the old stack, kept so you can see the order of magnitude. Two of its rows describe containers that no longer exist in this form.
Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM), old stack shape:
| Service (May 2026) | Idle RAM | Peak RAM | Still applies? |
|---|---|---|---|
calcom (calcom/cal.com:v4.9.0) | 900 MB | 1,400 MB | No: Cal.diy is a different, locally built image |
| chatwoot (v4.13.0) | 520 MB | 1,100 MB | Roughly; now v4.17.1 |
| chatwoot-sidekiq (v4.13.0) | 380 MB | 700 MB | Roughly; now v4.17.1 |
| twenty-api (v2.1.0) | 450 MB | 900 MB | No: replaced by twenty-server |
| twenty-worker (v2.1.0) | 200 MB | 350 MB | Roughly |
| twenty-front (v2.1.0) | 80 MB | 100 MB | No: container removed |
| db (shared postgres) | 400 MB | 600 MB | Roughly |
| redis (shared) | 50 MB | 80 MB | Roughly |
| caddy | 15 MB | 20 MB | Yes |
| Total | 2,995 MB | 5,250 MB |
What you can plan around today: Twenty's documentation gives a 2 GB RAM minimum for Twenty alone, and the Cal.diy build asks for up to 6 GB of Node heap while it runs. An 8 GB VPS is the floor for this combination, not a comfortable fit. Run docker stats --no-stream after a week of real use, and move to the 16 GB tier if you sit above about 70% of RAM.
Cost vs Calendly + Intercom + Salesforce
Compare Cal.diy against Calendly Standard. It has no round-robin or team booking pages, so pricing it against Calendly Teams would flatter it.
| Calendly Standard | Intercom Essential | Salesforce Starter Suite | This stack | |
|---|---|---|---|---|
| Monthly cost | $10/seat billed annually ($12 monthly) | $29/seat billed annually ($39 monthly) | $25/user | $100/mo VPS ($50/mo for the first 2 months) |
| Scheduling pages | ✓ | ✗ | ✗ | ✓ |
| Round-robin / team booking | ✗ (Teams plan, $16/seat annually) | ✗ | ✗ | ✗ (removed from Cal.diy) |
| Live chat | ✗ | ✓ | ✗ | ✓ |
| CRM | ✗ | Basic | ✓ | ✓ |
| Self-hostable | ✗ | ✗ | ✗ | ✓ |
A 3-person team on Calendly Standard ($30/mo) + Intercom Essential ($87/mo) + Salesforce Starter Suite ($75/mo) pays about $192/mo before Zapier. This stack costs $100/mo at Liquid Web's regular 8 GB price, with no seat limit.
The crossover comes early, but it is real. One person pays about $64/mo for the same three SaaS tools, less than the VPS. A solo founder can also pay $0: Calendly and Cal.com both have free plans, and Salesforce lists a Free Suite. Self-hosting starts to save money at around two seats, and the table does not count your time for backups, upgrades and rebuilding Cal.diy from source.
Prices checked 2026-09-26 and subject to change: calendly.com/pricing, intercom.com/pricing, salesforce.com/sales/pricing, cal.com/pricing and liquidweb.com/vps-hosting/managed-vps/.
When this stack isn't right for you
- The booking page is customer-facing revenue. Cal.diy's maintainers recommend it for personal, non-production use and say to use Cal.com for commercial scheduling. If a broken booking page costs you deals, keep scheduling on Cal.com's cloud or Calendly and self-host only Chatwoot and Twenty.
- Several reps share one booking link. Round-robin, collective events and team pages were removed from Cal.diy. That requires Calendly Teams ($16/seat/mo annually) or Cal.com's cloud Teams plan.
- You qualify leads before they book. Routing Forms were removed from Cal.diy. You could rebuild a qualification step in n8n or a form tool, but it is no longer built in.
- You rely on automated reminder emails or SMS. Those came from Workflows, which Cal.diy dropped.
- 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.
- Nobody on the team wants to own a source build. Cal.diy has no published image and no post-split releases. Every upgrade is a
git pulland a multi-minute rebuild. - You have more than about 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.
For a wider list of self-hostable scheduling options, see open-source Calendly alternatives.
Troubleshooting
The Cal.diy build is killed or fails with "JavaScript heap out of memory"
The Dockerfile gives the Next.js build up to 6,144 MB of heap. Stop the other services (docker compose stop chatwoot chatwoot-sidekiq twenty-server twenty-worker) and build again, add swap to the VPS, or build on a bigger machine and push the image to a private registry.
Cal.diy 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 match too. A mismatch causes silent redirect failures. Fix the values in .env, then docker compose up -d calcom. The start script rewrites the built URL at startup when it differs, but a rebuild (docker compose build calcom) is the cleaner fix.
Cal.diy hangs at "waiting for database"
The image's start script waits on DATABASE_HOST before it migrates. It must be db:5432 in this stack. Cal.diy's own compose file names its Postgres service database, so a value copied from an upstream setup (database:5432) is a common source of this error.
Chatwoot webhook returns 401
If n8n receives the webhook, set its Webhook node to "None" authentication, or check the signature yourself against the X-Cal-Signature-256 header. When you call the Chatwoot API, the api_access_token header must be set, and it is not the admin password. Generate a token in Chatwoot → Profile → Access Token.
Twenty CRM will not load after first start
Wait for twenty-server to report healthy: its health check allows 20 retries at 5-second intervals, so first boot can take about 100 seconds. Then check docker compose logs twenty-server --tail 30. TWENTY_ENCRYPTION_KEY must be set; it is the one secret Twenty's .env.example marks as required on a fresh install. Twenty's own compose file connects as the postgres superuser. If the logs show a permission error creating a schema or extension, grant the twenty role that privilege or point PG_DATABASE_URL at the superuser.
Not the product Cal.com runs as a service. On 14 April 2026 Cal.com announced its commercial codebase was going closed source. The public repository continues as Cal.diy (github.com/calcom/cal.diy), relicensed from AGPL-3.0 to MIT, with teams, organizations, routing forms, workflows, SAML/SSO, Insights and API v1 removed. Cal.diy is what you self-host now.
Yes, that is what this guide does. Caddy routes cal.yourdomain.com to Cal.diy, support.yourdomain.com to Chatwoot, and crm.yourdomain.com to Twenty CRM. All three share one PostgreSQL container with a separate database and user each, and one Redis container where Twenty uses DB index 0 and Chatwoot uses DB index 1.
Not as of 2026-09-26. The calcom/cal.diy repository on Docker Hub exists but has no tags, and the old calcom/cal.com image stopped at v6.2.0 in March 2026, before the split. Cal.diy's own docker-compose.yml builds the image from source, and this guide does the same with a build section pointing at a local clone.
There is no version tag to bump, because Cal.diy has published no release since the April 2026 split. Run git -C cal.diy pull, note the new commit hash, rebuild with docker compose build calcom, then restart with docker compose up -d calcom. The start script runs prisma migrate deploy on boot, so watch docker compose logs calcom -f until the app is listening again. Back up the calcom database first.
No. Round-robin, collective events and team pages belonged to the Teams feature set, which was removed from Cal.diy. For a sales team sharing one booking link you need Calendly Teams or Cal.com's cloud Teams plan. Cal.diy covers one person's booking pages, calendar sync, video links and webhooks.
Create an email inbox in Chatwoot and make sure the host's booking confirmation emails reach it, either by registering the Cal.diy account with that address or with a forwarding rule in your mail provider. Each confirmation becomes a Chatwoot conversation. It needs no webhooks or middleware, but it is email-based rather than real-time, and moving contacts into Twenty stays manual.

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.
