Skip to main content

Operations

info

This page covers OAuth integration setup, email configuration via Microsoft Graph API, security hardening, first login, and troubleshooting.

Meeting scheduling series

  1. Meeting scheduling
  2. Architecture
  3. Manifests
  4. Flux integration
  5. Operations - You are here

OAuth setup overview

Both Google Calendar and Zoom validate redirect URLs, which requires your Cal.com domain to be live and responding. This creates a two-phase deployment:

Phase 1: Deploy Cal.com, get domain live, create OAuth apps and copy credentials (skip redirect URLs)

Phase 2: After domain is reachable, add redirect URLs to OAuth apps, then connect integrations in Cal.com

Phase 1: Pre-deployment OAuth setup

Create the OAuth apps and get credentials before deploying. Skip redirect URL configuration until after deployment.

Google Calendar app

  1. Go to https://console.cloud.google.com/
  2. Create a new project or select existing
  3. APIs & ServicesEnable APIs → Enable Google Calendar API
  4. OAuth consent screen → External → Configure basic info
  5. Add scopes:
    • https://www.googleapis.com/auth/calendar.events
    • https://www.googleapis.com/auth/calendar.readonly
  6. CredentialsCreate CredentialsOAuth Client ID → Web application
  7. Skip redirect URIs for now (Google validates URLs are reachable)
  8. Copy Client ID and Client Secret (you'll enter these in Cal.com admin UI later)
note

Google Calendar credentials are NOT stored in Kubernetes secrets. They are entered through the Cal.com admin UI after deployment.

Google OAuth verification (not required)

The calendar scopes are marked as "sensitive" and show "Approval required" in Google Cloud Console. For self-hosted/internal use, you do NOT need Google's verification:

  1. Keep your app in Testing mode (don't publish it)
  2. Go to Audience in Google Cloud Console
  3. Under Test users, add your Google account

In Testing mode, you can use sensitive scopes without verification. Google only requires verification if you want to publish the app to external users.

Zoom app

  1. Go to https://marketplace.zoom.us/DevelopBuild App
  2. Select General App → Create
  3. App Name: Cal.com Scheduling (or your preferred name)
  4. You'll see Development and Production tabs - use Production for your live Cal.com
  5. Copy Client ID and Client Secret from the Production tab
  6. Skip redirect URL (Zoom validates URL is reachable)
  7. Add scopes (click Scopes+ Add Scopes):
    • Search "meeting" and add:
      • meeting:write:meeting - Create a meeting for a user
      • meeting:update:meeting - Update a meeting
      • meeting:delete:meeting - Delete a meeting
    • Search "user" and add:
      • user:read:pm_room - Verify a user's personal meeting room
      • user:read:user - View a user
      • user:read:settings - View a user's settings (required for waiting room)
  8. Add Scope description (required by Zoom):
    Creates Zoom meetings for calendar bookings. Meeting URLs stored encrypted in database for booking confirmations. Data deleted when bookings cancelled.
  9. The app will be in "Beta Test" mode - this is sufficient for self-hosted use

Phase 2: Post-deployment OAuth setup

After cal.example.com is live and responding, complete the OAuth configuration.

Google Calendar redirect URLs

  1. Return to https://console.cloud.google.com/ → Your project → Credentials
  2. Edit your OAuth Client ID
  3. Add Authorized redirect URIs:
    https://cal.example.com/api/integrations/googlecalendar/callback
    https://cal.example.com/api/auth/callback/google
  4. Save

Zoom redirect URLs

  1. Return to https://marketplace.zoom.us/ → Your app
  2. Go to Production tab → OAuth Information
  3. Add OAuth Redirect URL:
    https://cal.example.com/api/integrations/zoomvideo/callback
  4. Add to OAuth Allow Lists:
    https://cal.example.com
  5. Save
Troubleshooting URL validation

If Zoom shows "A secure URL using HTTPS is required" even though your URL has HTTPS, type the URL manually instead of pasting. Copy-paste can include invisible Unicode characters that break validation.

Connecting integrations in Cal.com

After OAuth apps are fully configured with redirect URLs:

Google Calendar (admin setup required)

Admin configuration:

  1. Log into Cal.com at https://cal.example.com
  2. Go to SettingsAdminApps
  3. Find Google Calendar and click to configure
  4. Enter your Client ID and Client Secret from Google Cloud Console
  5. Save the configuration

User installation:

  1. Go to AppsApp StoreCalendarGoogle CalendarInstall app
  2. Select your Google account
  3. "Google hasn't verified this app" warning → Click Continue
  4. "yourdomain wants access to your Google Account" → Click Continue
  5. App installs, your profile picture updates from Google

Configure conflict checking:

  1. Go to AppsInstalled appsCalendar
  2. Under "Check for conflicts", toggle additional calendars you want Cal.com to check
  3. Select calendars like Family, Holidays, or shared calendars to prevent double-bookings

Zoom (Admin setup required)

Admin configuration:

  1. Log into Cal.com at https://cal.example.com
  2. Go to SettingsAdminAppsConferencing
  3. Find Zoom Video and click the pencil icon to configure
  4. Enter your Client ID and Client Secret from Zoom Marketplace Production tab
  5. Save → App becomes enabled immediately

User settings (optional but recommended):

  1. Go to SettingsGeneral
  2. Set Time format to 24-hour
  3. Set Start of week to Monday

User installation:

  1. Go to SettingsConferencing+ Add
  2. Click Zoom VideoDetailsInstall app
  3. Review Zoom permissions ("Cal would like permission to...") → Allow
  4. Select Event Types → Set up later (if you plan to delete default event types)

Make Zoom default:

  1. Go to AppsInstalled appsConferencing
  2. Click ⋯ on Zoom VideoSet as default

Delete default event types (optional):

  1. Go to Event types
  2. Click ⋯ on each default event type (15min, 30min, Secret meeting) → Delete
  3. Create your own event types with Zoom as the conferencing option
Reinstalling after scope changes

If you add new scopes to your Zoom app after initial setup, you must reinstall in Cal.com:

  1. AppsInstalled apps → Remove Zoom
  2. AppsApp StoreConferencing → Zoom → Install
  3. Re-authorize to get a new OAuth token with updated permissions

Zoom waiting room configuration

Cal.com reads your Zoom account settings and applies them to new meetings. For waiting room to work:

  1. Zoom Account Settings (https://zoom.us/profile/setting):

    • Enable "Enable waiting room" under Meeting > Security
    • Set "Everyone will go in the waiting room"
    • Disable "Allow participants to join before host"
  2. Cal.com Zoom App must have user:read:settings scope - without this, Cal.com defaults to open meetings (waiting_room: false)

  3. New meetings only - settings apply to meetings created after configuration

Missing user:read scope

If your Zoom integration was set up without user:read:settings, meetings will not have waiting room enabled. Add the scope to your Zoom app and reinstall in Cal.com.

Email via shared email-relay

Cal.com uses the shared email-relay infrastructure for sending emails. This provides:

  1. MX Validation - Rejects emails to non-existent domains
  2. Email Logging - All emails captured in Mailpit for debugging
  3. Graph API Delivery - Sends via Microsoft 365 (bypasses Security Defaults blocking SMTP AUTH)

Cal.com email configuration

Point Cal.com to the shared email-relay namespace:

env:
- name: EMAIL_FROM
valueFrom:
secretKeyRef:
name: calcom-secret
key: EMAIL_FROM
- name: EMAIL_SERVER_HOST
value: "email-relay.email-relay.svc.cluster.local"
- name: EMAIL_SERVER_PORT
value: "25"

Full email relay documentation

For complete details on:

  • Azure App Registration setup for Graph API
  • smtp2graph configuration
  • MX validator code and metrics
  • Prometheus alerting

See: Email relay

Microsoft Graph ICS routing fix

Cal.com sends booking notifications with ICS calendar attachments. Microsoft Graph API has a bug where it parses the ICS ATTENDEE field and routes emails there, ignoring the actual SMTP recipient. This causes organizer notifications to be delivered to attendees instead of the organizer.

The problem

Email To: organizer@yourdomain.com
ICS ATTENDEE: mailto:attendee@example.com

Result: Email delivered to attendee@example.com (wrong!)

The solution

The mx-validator in the email-relay namespace strips ICS content from emails to specific recipients before they reach the Graph API:

# In email-relay deployment
env:
- name: STRIP_ICS_RECIPIENTS
value: "organizer@yourdomain.com" # Your organizer email(s)

This removes both:

  • application/ics attachments
  • Inline text/calendar parts (nested in multipart/alternative)

After stripping, Microsoft Graph routes emails correctly based on the To header.

Known Microsoft bug

This is a documented Microsoft Graph API bug with no official fix. The ICS stripping workaround is required when using Graph API for calendar notification emails.

See Email relay - Architecture for full technical details.

Spam protection

Cal.com is protected by multiple layers that prevent spam bookings and unauthorized access.

Protection layers

LayerProtectionBlocks
Cloudflare WAFBlock /signup, /auth pathsUnauthorized account creation
Cloudflare IP RulesAdmin path allowlistUnauthorized admin access
MX ValidatorDNS MX record lookupFake email domains
Email VerificationRequires email round-tripUnverified bookers

Enable email verification

To require bookers to verify their email before booking confirmation:

  1. Go to Event Types → Select your event type
  2. Click Advanced tab
  3. Enable "Requires booker email verification"
  4. Save
Do NOT enable "Hide organizer's email"

The "Hide organizer's email" setting in Event Type → Advanced replaces the organizer email with no-reply@cal.com in calendar events. This causes:

  • Meeting decline/accept responses sent to no-reply@cal.com (non-routable)
  • Undeliverable bounce messages: "no-reply wasn't found at cal.com"
  • Declined meetings stuck in calendar because response can't be delivered

Keep this setting OFF (the default). It has no impact on the Microsoft Graph routing bug - the ICS stripping fix alone resolves that issue.

How it works together

When a user submits a booking:

  1. Cal.com sends a verification email to the booker
  2. mx-validator checks if the domain has MX records
  3. If no MX records → email rejected with 550 error → booking fails
  4. If MX records exist → email sent → booker must click verification link
  5. Only after verification is the booking confirmed

This prevents:

  • Bookings with fake domains (user@nonexistent.xyz)
  • Bookings with typo domains (user@gmial.com)
  • Automated spam form submissions

First login and admin setup

Create your account

  1. Navigate to signup via the internal URL (if WAF rules are configured):
    • Internal: http://cal.example.local:3000/signup
    • Or public before WAF rule: https://cal.example.com/signup
  2. Create your account with a strong password (15+ characters recommended)
  3. On "Connect your calendar" step, click "I'll connect my calendar later" (OAuth apps not configured yet)
  4. On "Connect your video apps" step, click "Set up later" (Zoom OAuth not configured yet)
  5. Complete profile setup, then sign out

Make yourself admin

After creating your account, grant admin privileges via psql:

kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom
UPDATE users SET role = 'ADMIN' WHERE email = 'you@yourdomain.com';
\q

Sign back in using your email address (not username) - the login screen changes from name to email after initial signup.

Access the admin panel at /settings/admin.

Enable 2FA

Required for full admin access: Enable TOTP 2FA in Settings → Security → Two factor authentication. Compatible with Google Authenticator, Duo Mobile, and similar apps.

Important: After enabling 2FA, sign out and sign back in for admin access to take effect.

Without 2FA and a strong password, some admin features may be locked.

Hide Cal.com branding

Remove the "Powered by Cal.com" footer from booking pages:

kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom -c \
"UPDATE users SET \"hideBranding\" = true WHERE id = 1;"

Disable unwanted apps

Fresh installs only seed daily-video. Check what apps exist:

kubectl exec -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "SELECT slug, enabled FROM \"App\" ORDER BY slug;"

If additional apps are seeded, disable unwanted ones:

kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "
UPDATE \"App\" SET enabled = false
WHERE slug NOT IN ('google-calendar', 'daily-video');
"

Note: Zoom is installed separately via OAuth, not from the app list. Google Calendar appears after OAuth configuration.

Create organization (required for single-org mode)

If using NEXT_PUBLIC_SINGLE_ORG_SLUG, you must create the matching organization in the database. Without these records, public booking pages show "This page does not exist".

# Step 1: Create organization with slug matching NEXT_PUBLIC_SINGLE_ORG_SLUG
kubectl exec -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "
INSERT INTO \"Team\" (name, slug, \"isOrganization\", \"createdAt\")
VALUES ('Cal', 'cal', true, NOW()) RETURNING id;"

# Step 2: Create membership (user 1 as OWNER of org)
kubectl exec -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "
INSERT INTO \"Membership\" (\"teamId\", \"userId\", accepted, role, \"disableImpersonation\", \"createdAt\", \"updatedAt\")
VALUES ((SELECT id FROM \"Team\" WHERE slug='cal'), 1, true, 'OWNER', false, NOW(), NOW());"

# Step 3: Create Profile linking user to org
kubectl exec -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "
INSERT INTO \"Profile\" (uid, \"userId\", \"organizationId\", username, \"createdAt\", \"updatedAt\")
VALUES ('prof_' || gen_random_uuid(), 1, (SELECT id FROM \"Team\" WHERE slug='cal'), '<your-username>', NOW(), NOW());"

# Step 4: Update user's organizationId
kubectl exec -n calcom calcom-db-0 -- psql -U calcom -d calcom -c "
UPDATE users SET \"organizationId\" = (SELECT id FROM \"Team\" WHERE slug='cal') WHERE id = 1;"
note

Replace cal with your NEXT_PUBLIC_SINGLE_ORG_SLUG value, and <your-username> with your Cal.com username.

Security hardening

Block public signup via Cloudflare WAF

After initial setup, block /signup and /auth on the public endpoint using Cloudflare WAF rules.

Navigation: https://dash.cloudflare.com/ → Select your domain → SecuritySecurity rulesCustom rules tab

  1. Click + Create rule
  2. Rule name: Restrict Cal signup
  3. Configure conditions:
FieldOperatorValue
URI Pathcontains/signup
AND IP Source Addressdoes not equal<your-public-ip>
AND Hostnameequalscal.example.com
OR
URI Pathcontains/auth
AND IP Source Addressdoes not equal<your-public-ip>
AND Hostnameequalscal.example.com
  1. Then take action: Block
  2. Place at: Last (or after existing rules)
  3. Click Deploy

Expression preview:

(http.request.uri.path contains "/signup" and ip.src ne <your-public-ip> and http.host eq "cal.example.com") or
(http.request.uri.path contains "/auth" and ip.src ne <your-public-ip> and http.host eq "cal.example.com")

Result:

URLOutcome
https://cal.example.com/signupBlocked (unless from your IP)
https://cal.example.com/auth/loginBlocked (unless from your IP)
http://cal.example.local:3000/signupAccessible (bypasses Cloudflare)
OAuth callbacks (/api/*)Unaffected
Client booking/reschedule/cancelUnaffected (uses magic links, no login)

Ingress rate limiting

warning

Do not enable ingress rate limiting for Cal.com. It's a heavy SPA that makes many parallel API calls on page load, easily exceeding typical limits and causing 503 errors. Cloudflare provides DDoS protection at the edge.

Cloudflare DNS setup

The tunnel routes traffic, but you also need a DNS record pointing to the tunnel.

Option 1: Via DNS dashboard

  1. Go to https://dash.cloudflare.com/ → your domain
  2. DNSRecordsAdd record
  3. Create CNAME:
    • Type: CNAME
    • Name: cal
    • Target: <tunnel-id>.cfargotunnel.com
    • Proxy status: Proxied (orange cloud)
  4. Save

Option 2: Via Zero Trust dashboard

  1. Zero TrustNetworksTunnels
  2. Select your tunnel → Public Hostname tab
  3. Add a public hostname:
    • Subdomain: cal
    • Domain: example.com
    • Service: https://ingress-nginx-controller.ingress-nginx.svc.cluster.local:443

This automatically creates the DNS record.

Booking type settings

Configure your booking types in Cal.com settings:

SettingRecommended value
Duration60 min
Slot intervals30 min
Buffer before15 min
Buffer after15 min
Minimum notice2 days
Maximum advance60 days
LocationZoom (auto-generate)
Required fieldsName, Email

Calendar availability

Connect calendars for availability checking:

  1. Primary calendar (read + write) - events are created here
  2. Family/shared calendar (read only) - blocks availability
  3. Public holidays calendar (read only) - blocks availability

Verification commands

# Check Flux status
flux get kustomizations -n flux-system | grep calcom

# Check pods
kubectl get pods -n calcom

# Check services
kubectl get svc -n calcom

# Check ingress
kubectl get ingress -n calcom

# Check certificate
kubectl get certificate -n calcom

# View Cal.com logs
kubectl logs -n calcom -l app=calcom -f

# View email relay logs (shared namespace)
kubectl logs -n email-relay -l app=email-relay -c mx-validator -f
kubectl logs -n email-relay -l app=email-relay -c mailpit -f
kubectl logs -n email-relay -l app=email-relay -c smtp2graph -f

# View migration logs
kubectl logs -n calcom -l app=calcom -c migrate

# Database access
kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom

Rollback / clean uninstall

To completely remove Cal.com:

# Suspend Flux
flux suspend kustomization calcom-app -n flux-system
flux suspend kustomization calcom-ns -n flux-system

# Delete namespace (removes all resources including database PVC)
kubectl delete namespace calcom

# Remove Flux config (in flux-config repo)
# Delete clusters/my-cluster/calcom/ folder
# Commit and push

# Remove from cloudflared ConfigMap
# Restart cloudflared

# Revoke Azure App Registration
# https://entra.microsoft.com → App registrations → Delete

# Revoke OAuth apps (Google, Zoom)

Clean install via GitOps

This deployment was tested by deleting everything and letting Flux rebuild from Git. The process validates that the GitOps approach works end-to-end.

Rebuild workflow

After a clean uninstall, Flux will automatically recreate the entire stack:

# 1. Ensure secrets are encrypted in app repo (SOPS)
# 2. Ensure Flux config exists in flux-config repo

# 3. Resume Flux Kustomizations (or re-add to flux-config)
flux resume kustomization calcom-ns -n flux-system
flux resume kustomization calcom-app -n flux-system

# 4. Watch the rebuild
kubectl get pods -n calcom -w

Flux handles the dependency ordering automatically:

  1. Creates namespace (waits for Origin CA Issuer controller)
  2. Decrypts SOPS secrets
  3. Deploys PostgreSQL StatefulSet
  4. Runs Prisma migrations via init container
  5. Deploys Cal.com
  6. Issues certificate and configures ingress

Email relay is managed separately in the email-relay namespace and does not need to be rebuilt with Cal.com.

Post-rebuild steps

After Flux rebuilds the stack, you need to reconfigure:

  1. Admin account: Create via /signup, grant admin role via psql
  2. 2FA: Re-enable in Settings → Security
  3. OAuth apps: Re-enter credentials in Admin → Apps (Google Calendar, Zoom)
  4. Organization: Re-create if using single-org mode
  5. Event types: Re-create through the UI

The OAuth app registrations (Google, Zoom, Azure) persist externally and don't need recreation unless you revoked them during uninstall.

Troubleshooting

503 errors on authenticated routes

If /bookings, /event-types, or other authenticated routes return nginx 503 errors while public booking pages work fine, check for hostname validation errors:

kubectl logs -n calcom -l app=calcom | grep -i "orgDomains\|ALLOWED_HOSTNAMES"

If you see Match of WEBAPP_URL with ALLOWED_HOSTNAMES failed, the ALLOWED_HOSTNAMES environment variable is missing or malformed.

Fix: Add ALLOWED_HOSTNAMES to your deployment with a JSON array of allowed domains:

- name: ALLOWED_HOSTNAMES
value: '["yourdomain.com"]'

This is required for Cal.com v6+ due to organization/multi-tenant hostname validation.

OAuth callback fails

The redirect URL in the OAuth app does not match what Cal.com expects.

# Check Cal.com logs for OAuth errors
kubectl logs -n calcom -l app=calcom | grep -i oauth

Verify the callback URLs match exactly:

  • Google: https://cal.example.com/api/integrations/googlecalendar/callback
  • Zoom: https://cal.example.com/api/integrations/zoomvideo/callback

Emails not sending

Check email-relay logs for errors:

# Check MX validator (rejects invalid domains)
kubectl logs -n email-relay -l app=email-relay -c mx-validator -f

# Check smtp2graph (Graph API delivery)
kubectl logs -n email-relay -l app=email-relay -c smtp2graph -f

Common issues:

  • MX validation failure: Recipient domain has no MX records (expected behaviour for fake domains)
  • Azure App Registration: Missing Mail.Send permission or admin consent not granted
  • Client secret expired: Create new secret in Entra portal
  • Wrong tenant ID: Verify in smtp2graph config

Database connection errors

The password may contain special characters that need URL encoding.

# Check if database is ready
kubectl exec -it -n calcom calcom-db-0 -- pg_isready -U calcom

# Check database logs
kubectl logs -n calcom calcom-db-0

If password contains /, =, or +, URL-encode them in DATABASE_URL.

Migrations failing

Check the migrate init container logs:

kubectl logs -n calcom -l app=calcom -c migrate

Common causes:

  • Database not ready when migrations run
  • Missing DATABASE_URL or DATABASE_DIRECT_URL
  • Permission issues on database

Pod stuck in CrashLoopBackOff

Check both init container and main container logs:

# Init containers
kubectl logs -n calcom -l app=calcom -c wait-for-postgres
kubectl logs -n calcom -l app=calcom -c migrate

# Main container
kubectl logs -n calcom -l app=calcom -c calcom

# Previous crashed container
kubectl logs -n calcom -l app=calcom --previous

Out of memory errors

If Cal.com crashes with OOM errors, increase memory limits:

env:
- name: NODE_OPTIONS
value: "--max-old-space-size=1536"
resources:
requests:
memory: "1Gi"
limits:
memory: "2Gi"

Force full reconciliation

# Reconcile the source
flux reconcile source git calcom-app -n flux-system

# Reconcile the app Kustomization
flux reconcile kustomization calcom-app -n flux-system --with-source

Zoom waiting room not working

If attendees can join meetings without being placed in a waiting room:

Cause: Cal.com's Zoom integration fetches your user settings via API. If the user:read:settings scope is missing, the API call fails silently and Cal.com defaults to waiting_room: false.

Fix:

  1. Add user:read:settings scope to your Zoom app
  2. Reinstall Zoom in Cal.com to get a new OAuth token
  3. Create a new test booking (existing meetings aren't updated)

Verify settings are being read:

kubectl logs -n calcom -l app=calcom | grep -i "zoom\|waiting"

Event types disappear in single-org mode

If event types briefly appear on the Event Types page then vanish:

Cause: Single organization mode (NEXT_PUBLIC_SINGLE_ORG_SLUG) filters the UI to show only organization event types. Personal event types (with teamId = NULL) are hidden.

Diagnosis:

kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom -c \
"SELECT id, title, \"teamId\", \"schedulingType\" FROM \"EventType\";"

Fix (Recommended): Delete the problematic event type and recreate through the UI while viewing the organization context (top-left dropdown shows your organization name, not your personal name):

# Delete problematic event type
kubectl exec -it -n calcom calcom-db-0 -- psql -U calcom -d calcom -c \
"DELETE FROM \"EventType\" WHERE id = <event-type-id>;"

Then create a new event type through the UI - it will automatically set all required fields (teamId, schedulingType, Host records).

warning

Do not try to retrofit personal event types to organization event types via SQL. The UI sets additional required fields that are difficult to replicate manually.

Validation checklist

After deployment, verify the complete flow:

  • Book appointment via cal.example.com
  • Google Calendar event created with attendee
  • Zoom URL generated (unique per meeting)
  • Confirmation email sent
  • Reschedule works (same Zoom link preserved)
  • Cancel sends emails and clears calendar