How to Self-Host a Booking Page with Cal.com (Let Clients Pick a Slot Without the Email Tennis)

A booking page on your server: Cal.com reads your calendar, shows free slots, takes the booking and adds it — no Calendly subscription, no email tennis.

"When are you free?" "Tuesday or Thursday?" "Thursday 2?" "Can we do 3?" — six emails for one appointment. A booking page shows your free slots and lets the other person pick; the event lands in your calendar with a confirmation to both. Cal.com is the open-source Calendly, and it self-hosts: your calendar via CalDAV (Radicale, 150, or Nextcloud, 32) or Google, event types for the things you offer, working hours, buffers, and an embeddable page for the website (110). For a computer-repair business, a tutor, a consultant, it replaces a £10-a-month tool.

Quick reference

TaskWhat
Folder~/docker/calcom/compose.yaml (app + PostgreSQL)
Webhttps://book.example.com — public
NeedsNEXTAUTH_SECRET, CALENDSO_ENCRYPTION_KEY, NEXT_PUBLIC_WEBAPP_URL, SMTP
CalendarSettings → Calendars → CalDAV (Radicale/Nextcloud) or Google
Event types"30-min call", "Home visit (90 min)" with location, buffers, limits
Embedor the embed snippet on the site

1. The compose file

Cal.com's image is large and wants a bit of RAM (2 GB). The official docker repo provides the compose; the essentials:

mkdir -p ~/docker/calcom && cd ~/docker/calcom
nano compose.yaml

services:
calcom:
image: calcom/cal.com:latest
container_name: calcom
ports:
- "127.0.0.1:3016:3000"
environment:
NEXT_PUBLIC_WEBAPP_URL: https://book.example.com
NEXTAUTH_URL: https://book.example.com
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
CALENDSO_ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DATABASE_URL: postgresql://calcom:${DB_PASSWORD}@calcom-db:5432/calcom
DATABASE_DIRECT_URL: postgresql://calcom:${DB_PASSWORD}@calcom-db:5432/calcom
NEXT_PUBLIC_LICENSE_CONSENT: agree
EMAIL_FROM: bookings@example.com
EMAIL_SERVER_HOST: smtp.gmail.com
EMAIL_SERVER_PORT: 587
EMAIL_SERVER_USER: bookings@example.com
EMAIL_SERVER_PASSWORD: ${SMTP_PASSWORD}
NEXT_PUBLIC_DISABLE_SIGNUP: "true"
depends_on: [calcom-db]
restart: unless-stopped

calcom-db:
image: postgres:16-alpine
container_name: calcom-db
environment:
POSTGRES_USER: calcom
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: calcom
volumes:
- ./db:/var/lib/postgresql/data
restart: unless-stopped

.env: two secrets (openssl rand -base64 32 each), the DB password, the SMTP app password (49). Set DISABLE_SIGNUP to "false" for the very first start so you can create your account, then back to "true".

docker compose up -d
docker compose logs -f calcom # migrations run on first start; a few minutes

2. Proxy and account

book.example.com → 127.0.0.1:3016 with the WebSocket lines (28), HTTPS — public, since clients use it. Open it, Sign up, then flip DISABLE_SIGNUP back and up -d. The onboarding asks for your name, time zone and working hours (Mon–Fri 9–5, say).

3. Connect your calendar

Settings → Calendars → Add: CalDAV with the Radicale URL (https://cal.example.com/john/) and login, or Nextcloud's CalDAV URL, or Google (needs OAuth app setup — CalDAV is simpler). Pick which calendar to check for conflicts (all of them) and which to add bookings to (your work one). Now busy times are blocked and bookings appear in your calendar on your phone (150).

4. Event types

Event types → New:

Each has a URL (book.example.com/john/home-visit). Workflows add reminders (email 24 h before; SMS needs a provider). Confirmation required for the ones you want to vet. Payments per event type via Stripe if deposits make sense.

5. Put it on the website

A link from the Hugo site (110), or embed: the event type's Embed button gives an iframe or a script snippet for an inline calendar, a popup button or a floating "Book a time" tab. Email signature: the link.

6. The booking

The client picks a slot in their own time zone, fills the questions, confirms. Both get an email with an .ics; the event is in your calendar; the client gets a reschedule/cancel link; reminders fire. You get an ntfy (93) via a Cal.com webhook → n8n (77) if you want a buzz.

Back up

./db dumped nightly (65) plus .env (the encryption key is needed to read stored calendar credentials). It's small; it's also your diary.

Troubleshooting

Calendar connection fails. The CalDAV URL needs the trailing slash and HTTPS with a trusted certificate; test it in a desktop client first.

No slots shown. Working hours or the event's availability schedule; time zone mismatch between your profile and the calendar.

Emails not sent. SMTP settings; docker compose logs calcom | grep -i smtp.

Slow first load. It's a big Next.js app; give it 2 GB and an SSD. Subsequent loads are fine.

Wrapping up

Two containers behind a public HTTPS name, your CalDAV calendar connected, three event types with buffers and questions, a link in the email signature. Clients book themselves into gaps you've already blessed, the appointment is on your phone, and the email tennis is over.

Call us