Operations
This page covers OAuth integration setup, email configuration via Microsoft Graph API, security hardening, first login, and troubleshooting.
Meeting scheduling series
- Meeting scheduling
- Architecture
- Manifests
- Flux integration
- 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
- Go to https://console.cloud.google.com/
- Create a new project or select existing
- APIs & Services → Enable APIs → Enable Google Calendar API
- OAuth consent screen → External → Configure basic info
- Add scopes:
https://www.googleapis.com/auth/calendar.eventshttps://www.googleapis.com/auth/calendar.readonly
- Credentials → Create Credentials → OAuth Client ID → Web application
- Skip redirect URIs for now (Google validates URLs are reachable)
- Copy Client ID and Client Secret (you'll enter these in Cal.com admin UI later)
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:
- Keep your app in Testing mode (don't publish it)
- Go to Audience in Google Cloud Console
- 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
- Go to https://marketplace.zoom.us/ → Develop → Build App
- Select General App → Create
- App Name:
Cal.com Scheduling(or your preferred name) - You'll see Development and Production tabs - use Production for your live Cal.com
- Copy Client ID and Client Secret from the Production tab
- Skip redirect URL (Zoom validates URL is reachable)
- Add scopes (click Scopes → + Add Scopes):
- Search "meeting" and add:
meeting:write:meeting- Create a meeting for a usermeeting:update:meeting- Update a meetingmeeting:delete:meeting- Delete a meeting
- Search "user" and add:
user:read:pm_room- Verify a user's personal meeting roomuser:read:user- View a useruser:read:settings- View a user's settings (required for waiting room)
- Search "meeting" and add:
- 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. - 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
- Return to https://console.cloud.google.com/ → Your project → Credentials
- Edit your OAuth Client ID
- Add Authorized redirect URIs:
https://cal.example.com/api/integrations/googlecalendar/callback
https://cal.example.com/api/auth/callback/google - Save
Zoom redirect URLs
- Return to https://marketplace.zoom.us/ → Your app
- Go to Production tab → OAuth Information
- Add OAuth Redirect URL:
https://cal.example.com/api/integrations/zoomvideo/callback - Add to OAuth Allow Lists:
https://cal.example.com - Save
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:
- Log into Cal.com at
https://cal.example.com - Go to Settings → Admin → Apps
- Find Google Calendar and click to configure
- Enter your Client ID and Client Secret from Google Cloud Console
- Save the configuration
User installation:
- Go to Apps → App Store → Calendar → Google Calendar → Install app
- Select your Google account
- "Google hasn't verified this app" warning → Click Continue
- "yourdomain wants access to your Google Account" → Click Continue
- App installs, your profile picture updates from Google
Configure conflict checking:
- Go to Apps → Installed apps → Calendar
- Under "Check for conflicts", toggle additional calendars you want Cal.com to check
- Select calendars like Family, Holidays, or shared calendars to prevent double-bookings
Zoom (Admin setup required)
Admin configuration:
- Log into Cal.com at
https://cal.example.com - Go to Settings → Admin → Apps → Conferencing
- Find Zoom Video and click the pencil icon to configure
- Enter your Client ID and Client Secret from Zoom Marketplace Production tab
- Save → App becomes enabled immediately
User settings (optional but recommended):
- Go to Settings → General
- Set Time format to 24-hour
- Set Start of week to Monday
User installation:
- Go to Settings → Conferencing → + Add
- Click Zoom Video → Details → Install app
- Review Zoom permissions ("Cal would like permission to...") → Allow
- Select Event Types → Set up later (if you plan to delete default event types)
Make Zoom default:
- Go to Apps → Installed apps → Conferencing
- Click ⋯ on Zoom Video → Set as default
Delete default event types (optional):
- Go to Event types
- Click ⋯ on each default event type (15min, 30min, Secret meeting) → Delete
- Create your own event types with Zoom as the conferencing option
If you add new scopes to your Zoom app after initial setup, you must reinstall in Cal.com:
- Apps → Installed apps → Remove Zoom
- Apps → App Store → Conferencing → Zoom → Install
- 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:
-
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"
-
Cal.com Zoom App must have
user:read:settingsscope - without this, Cal.com defaults to open meetings (waiting_room: false) -
New meetings only - settings apply to meetings created after configuration
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:
- MX Validation - Rejects emails to non-existent domains
- Email Logging - All emails captured in Mailpit for debugging
- 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/icsattachments- Inline
text/calendarparts (nested in multipart/alternative)
After stripping, Microsoft Graph routes emails correctly based on the To header.
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
| Layer | Protection | Blocks |
|---|---|---|
| Cloudflare WAF | Block /signup, /auth paths | Unauthorized account creation |
| Cloudflare IP Rules | Admin path allowlist | Unauthorized admin access |
| MX Validator | DNS MX record lookup | Fake email domains |
| Email Verification | Requires email round-trip | Unverified bookers |
Enable email verification
To require bookers to verify their email before booking confirmation:
- Go to Event Types → Select your event type
- Click Advanced tab
- Enable "Requires booker email verification"
- Save
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:
- Cal.com sends a verification email to the booker
- mx-validator checks if the domain has MX records
- If no MX records → email rejected with
550error → booking fails - If MX records exist → email sent → booker must click verification link
- 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
- 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
- Internal:
- Create your account with a strong password (15+ characters recommended)
- On "Connect your calendar" step, click "I'll connect my calendar later" (OAuth apps not configured yet)
- On "Connect your video apps" step, click "Set up later" (Zoom OAuth not configured yet)
- 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;"
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 → Security → Security rules → Custom rules tab
- Click + Create rule
- Rule name:
Restrict Cal signup - Configure conditions:
| Field | Operator | Value |
|---|---|---|
| URI Path | contains | /signup |
| AND IP Source Address | does not equal | <your-public-ip> |
| AND Hostname | equals | cal.example.com |
| OR | ||
| URI Path | contains | /auth |
| AND IP Source Address | does not equal | <your-public-ip> |
| AND Hostname | equals | cal.example.com |
- Then take action: Block
- Place at: Last (or after existing rules)
- 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:
| URL | Outcome |
|---|---|
https://cal.example.com/signup | Blocked (unless from your IP) |
https://cal.example.com/auth/login | Blocked (unless from your IP) |
http://cal.example.local:3000/signup | Accessible (bypasses Cloudflare) |
OAuth callbacks (/api/*) | Unaffected |
| Client booking/reschedule/cancel | Unaffected (uses magic links, no login) |
Ingress rate limiting
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
- Go to https://dash.cloudflare.com/ → your domain
- DNS → Records → Add record
- Create CNAME:
- Type: CNAME
- Name:
cal - Target:
<tunnel-id>.cfargotunnel.com - Proxy status: Proxied (orange cloud)
- Save
Option 2: Via Zero Trust dashboard
- Zero Trust → Networks → Tunnels
- Select your tunnel → Public Hostname tab
- Add a public hostname:
- Subdomain:
cal - Domain:
example.com - Service:
https://ingress-nginx-controller.ingress-nginx.svc.cluster.local:443
- Subdomain:
This automatically creates the DNS record.
Booking type settings
Configure your booking types in Cal.com settings:
| Setting | Recommended value |
|---|---|
| Duration | 60 min |
| Slot intervals | 30 min |
| Buffer before | 15 min |
| Buffer after | 15 min |
| Minimum notice | 2 days |
| Maximum advance | 60 days |
| Location | Zoom (auto-generate) |
| Required fields | Name, Email |
Calendar availability
Connect calendars for availability checking:
- Primary calendar (read + write) - events are created here
- Family/shared calendar (read only) - blocks availability
- 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:
- Creates namespace (waits for Origin CA Issuer controller)
- Decrypts SOPS secrets
- Deploys PostgreSQL StatefulSet
- Runs Prisma migrations via init container
- Deploys Cal.com
- 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:
- Admin account: Create via
/signup, grant admin role via psql - 2FA: Re-enable in Settings → Security
- OAuth apps: Re-enter credentials in Admin → Apps (Google Calendar, Zoom)
- Organization: Re-create if using single-org mode
- 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.Sendpermission 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_URLorDATABASE_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:
- Add
user:read:settingsscope to your Zoom app - Reinstall Zoom in Cal.com to get a new OAuth token
- 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).
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