Skip to main content

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

· 25 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.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:

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:

FeatureIn Cal.diy?Effect on this stack
Personal booking pages, event types, availabilityYesWorks as before
Calendar and video apps (Google Calendar, Outlook, Google Meet, Zoom, Jitsi, Stripe)Yes, the app store is keptWorks as before
Webhooks (BOOKING_CREATED, BOOKING_CANCELLED, and others)Yes, confirmed in the repo 2026-09-26The pipeline below still works
API v2Yes, as a separate calcom-api serviceNot started in this stack; add it if you need it
API v1RemovedOld integrations that call /v1 break
Teams, round-robin, collective events, OrganizationsRemovedNo shared sales-team booking page
Routing FormsRemovedNo lead-qualification form before booking
Workflows (reminder emails and SMS)RemovedRebuild reminders in n8n, or go without
SAML/SSO, Insights, Instant BookingRemovedCloud-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.com on Docker Hub stopped at v6.2.0 on 2026-03-02, before the split. The calcom/cal.com:v4.9.0 image this guide used in May is two major versions behind and pre-split.
  • calcom/cal.diy exists 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 pull and mention a v5.6.19-arm tag. Neither matches Docker Hub today. The repo's own docker-compose.yml has a build: section, and that is what works.
  • The old calcom/docker repository is archived.
  • The latest git tag is still v6.2.0 (2026-03-01), from before the split. Commits continue on main, so this guide builds from main and asks you to record the commit you built.

What you get​

ToolRoleReplaces
Cal.diyPersonal booking pages, availability, booking confirmations, webhooksCalendly Standard
ChatwootShared inbox: live chat, email, booking conversation threadsIntercom, Zendesk Support
Twenty CRMContacts, companies, deals, custom objects, pipelinesSalesforce Starter, HubSpot CRM
PostgreSQL 16Shared database (three databases: calcom, chatwoot_production, twenty)-
Redis 7Shared queue and cache (DB 0 for Twenty, DB 1 for Chatwoot)-
CaddyTLS 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.

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:

  1. Receives the booking webhook on n8n's generic Webhook trigger node
  2. 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)
  3. Opens a conversation in Chatwoot with the booking details
  4. 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:

  1. In Chatwoot: Settings → Inboxes → New Inbox → Email, receiving at an address such as bookings@yourdomain.com.
  2. 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.
  3. 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 RAMPeak RAMStill applies?
calcom (calcom/cal.com:v4.9.0)900 MB1,400 MBNo: Cal.diy is a different, locally built image
chatwoot (v4.13.0)520 MB1,100 MBRoughly; now v4.17.1
chatwoot-sidekiq (v4.13.0)380 MB700 MBRoughly; now v4.17.1
twenty-api (v2.1.0)450 MB900 MBNo: replaced by twenty-server
twenty-worker (v2.1.0)200 MB350 MBRoughly
twenty-front (v2.1.0)80 MB100 MBNo: container removed
db (shared postgres)400 MB600 MBRoughly
redis (shared)50 MB80 MBRoughly
caddy15 MB20 MBYes
Total2,995 MB5,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 StandardIntercom EssentialSalesforce Starter SuiteThis 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 pull and 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.

Frequently Asked Questions

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.

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.

Advertise on this page