Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions briefings/calcom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# calcom — maintainer briefing

## What it is

- **Cal.com** — self-hosted scheduling and booking pages; the open-source Calendly alternative.
- **AGPL-3.0** (strong copyleft: anyone offering a *modified* version as a service must publish
their changes). Free to self-host, nothing to register. We ship the unmodified upstream image,
so the obligation does not attach to us.
- Shipped on `calcom/cal.com:v6.2.0` — the newest published non-`latest`, non-arm tag, matching
upstream's newest GitHub release.

## Common use cases

- Team booking pages on your own domain, with your own data, instead of Calendly/SavvyCal.
- Round-robin and collective scheduling for sales/support rotas.
- Embedding a booking flow into an existing product via Cal.com's embed script.
- Keeping attendee PII (names, emails, meeting notes) inside the customer's own org rather than
a third-party SaaS.

## Architecture on cpln

| Resource | Purpose |
|---|---|
| workload `{release}-calcom` (`standard`) | The whole app — UI, booking pages, `/api/*`. Port 3000, HTTP. Runs Prisma migrations at boot |
| workload `{release}-calcom-cron` (`standard`, 1 replica) | Drives Cal.com's scheduled endpoints on a 60 s `wget` loop. Same image, entrypoint overridden |
| subchart `postgres` 3.4.1 (default) / `postgres-highly-available` 2.7.0 (flag) | All state. Nothing else is persisted |
| secret (dictionary, **user-created**) `my-calcom-auth` | `nextAuthSecret`, `encryptionKey`, `cronSecret`, `cronApiKey` |
| secret (dictionary, chart-created) `my-calcom-db-credentials` | Bundled DB `username`/`password`/`database` |
| secret (opaque, chart-created) `{release}-calcom-startup` | The app's boot script, mounted at `/cpln/start.sh` and run with `/bin/bash`. REPLACES the image entrypoint |
| identity + 2 policies | One identity for both workloads; `reveal` on exactly those secrets (plus the SMTP secret when configured), and `view` on the ONE install GVC for the boot-time location report |

- **No volumeset on the app** — it is genuinely stateless (upstream's compose mounts no volume;
avatars live in Postgres), which is what makes `replicas: 2+` work.
- **Private by default.** First run is: install → `cpln port-forward` → `/auth/setup` →
`helm upgrade --set publicAccess.enabled=true`.
- **Pinned to ONE location.** `defaultOptions.minScale/maxScale: 0` on both workloads, with a
single `localOptions` entry for `location`. An unasked-for GVC location starts nothing.

## Key knobs

| Knob | Default | Note |
|---|---|---|
| `location` | `aws-us-east-1` | The ONE GVC location Cal.com runs in. Must exist in the GVC or NOTHING starts |
| `calcom.image` | `calcom/cal.com:v6.2.0` | Official image |
| `calcom.replicas` | `1` | `2+` for zero-downtime rolling restarts; no clustering to form |
| `calcom.appUrl` | `""` | Empty = derived; set only for a custom domain, **with** the scheme |
| `calcom.auth.secretName` | `my-calcom-auth` | **Must exist before install** |
| `cron.enabled` | `true` | Turning it off silently stops every scheduled job |
| `email.enabled` | `false` | Off = no confirmations, invites or password resets |
| `publicAccess.enabled` | `false` | Flip only after claiming the admin account |
| `postgres.enabled` / `postgresHA.enabled` | `true` / `false` | Exactly one; the chart fails on both or neither |

## Troubleshooting / considerations

- **The image's own `scripts/start.sh` CANNOT be used, and this is the defect that nearly shipped.**
It gates the boot on `scripts/wait-for-it.sh`, which at v6.2.0 is the eficode POSIX-sh variant
testing readiness with `nc -w 1 -z` — and the Control Plane mesh sidecar completes the TCP
handshake for anything. Proven in-container with three controls that all had to fail and did
not: a dead port (9999), a nonexistent hostname, and the real port, all "up" in ~0 s. `start.sh`
also runs `set -x` without `set -e`, so the `prisma migrate deploy` that then failed did not stop
the boot: Cal.com served HTTP against a schema-less database with `ready: true`, `200` on
`/api/version` and **0 tables**. It is a race, and a fresh volumeset — the first-install case —
is the one that loses it. The template now mounts its own `/cpln/start.sh`, which waits by
sending a **Postgres SSLRequest** (`\x00\x00\x00\x08\x04\xd2\x16\x2f`) and requiring the
one-byte `S`/`N` reply, three times consecutively, for up to 240 s, and runs under `set -e` so a
failed migration crash-loops instead of serving. Verified against the pinned image: live
Postgres → ready; dead port, nonexistent host and an accepts-but-never-replies server (the
sidecar's shape) → not ready, and the gate exits 1 with a named FATAL; a bad-credentials
migration exits 1 before `yarn start`. Do not "simplify" this back to the image entrypoint.
No probe can catch this instead: v6.2.0 has no `/api/health` or equivalent under `apps/web` and
`/auth/setup` answers 307 against an empty database, so "HTTP listening implies migrations
applied" is now true by construction rather than by assumption — it was false before.
- **Multi-location GVCs silently split Cal.com — half-fixed, and the half that is not is the
database.** A workload runs in EVERY location its GVC has, so before pinning, a default install
into a 3-location GVC produced three app replicas, each bound by service DNS to its own local
Postgres: three separate databases sharing one `NEXTAUTH_SECRET`, so a session validated against
a database that did not contain the user. Measured — a table created in `aws-us-east-1` did not
exist in the other two, and every status surface read green. The app and cron caller are now
pinned via `defaultOptions` 0/0 + `localOptions`. **The bundled `postgres` /
`postgres-highly-available` subcharts are NOT pinned**: a parent cannot template a subchart's
values and neither exposes a location knob (same gap as `plane`, `docmost`, `metabase`). So in a
multi-location GVC an empty database still starts in every location with its own volumeset —
idle, never read, and billed. The app reads its own GVC at boot and logs a warning naming them;
that warning is the only signal a user gets. **The honest statement is: data-splitting is fixed,
cost sprawl is not.** Measured on a 3-location GVC: each extra location runs a live idle
PostgreSQL (`ready: true`, `minCpu 250m`, `minMemory 512Mi`) with a bound 10 GiB EBS volume.
- **`location` naming a location the GVC lacks starts NOTHING, with no failed deployment.** The
platform stores such a `localOptions` entry verbatim and it is simply inert. It cannot be caught
at boot either — no container runs to complain. README Prerequisites tells the user to check
`cpln gvc get` first; that is all the defence there is.
- **READINESS, NOT LIVENESS, IS WHAT KILLS A CONTAINER THAT NEVER BECOMES READY — and the number
it kills at is a hard ceiling on boot time.** This cost the template two rounds. The chart
originally sized its 600 s database wait against *liveness* on the stated assumption that
"readiness exhausting its threshold during a slow database wait is benign — it keeps probing".
That assumption is false here, and it made the script's FATAL diagnostic unreachable: with a
600 s budget the platform terminated the container at **~515 s**, having reached attempt
**104/120**, with `exitCode: 143` and `Health Check Failed: check events for details.` —
`|= "FATAL"` returned **0 lines**. The operator saw a probe failure where the chart had written
a sentence naming the database.
**Measured directly, 2026-08-31, on four throwaway busybox workloads in `test-gvc` with an
unsatisfiable readiness probe (port declared, nothing listening) and NO liveness probe at all:**

| readiness `initialDelay` / `period` / `failureThreshold` | deadline | container start → restart | grace over deadline |
|---|---|---|---|
| 0 / 5 / 1 | 5 s | 237.6 s, 240.0 s, 243.4 s (four cycles) | ~233–238 s |
| 30 / 15 / 20 (calcom's own numbers) | 330 s | 563.2 s | 233.2 s |
| 60 / 30 / 20 | 660 s | 1043.7 s | 383.7 s |
| *no probes at all* — control | — | **never restarted** (and `ready: true` with nothing listening) | — |

**What is settled:** the terminator is the READINESS probe. The no-probe control was never
restarted, which rules out a platform-wide "must become ready within N" timer, and none of the
four had a liveness probe at all. The kill always lands *after* readiness's own deadline
(`initialDelaySeconds + failureThreshold × periodSeconds`) and moves with it — 5 s → 238 s,
330 s → 563 s, 660 s → 1044 s.
**What is not settled:** the size of the grace on top. It was 233 s at both the 5 s and the 330 s
deadline (so 1:1 with the deadline across that range, slope 1.00 over a 325 s change) and 384 s at
660 s, so it is not a constant and the relationship is not linear across the whole range. calcom
itself showed ~185 s at the same 330 s deadline where busybox showed 233 s, which suggests part of
it is shutdown behaviour (busybox `sh` ignores the signal as PID 1 and is force-killed; calcom
exits on it). **Design against the DEADLINE, not the observed kill** — the deadline is the floor
the kill can never precede, and it is the only number under our control.
**Consequence for any template with a long boot gate:** the gate's own timeout must fire inside
`initialDelaySeconds + failureThreshold × periodSeconds`, or its diagnostic is dead code. calcom's
readiness deadline is 330 s and its wait budget is now **240 s** (48 × 5 s), which clears it with
~65 s to spare even after the GVC read's worst-case ~25 s. Liveness (600 / 30 / 10 → 900 s) is not
the load-bearing bound and never was.
- **Shortening the wait did not shorten how long a slow database can take.** The gate re-runs in
full on every restart, so a database that needs 20 minutes is still waited out — across cycles,
each one printing `[calcom] FATAL: PostgreSQL at ... did not answer` instead of dying silently.
The only thing that changed is that the operator is now told which component to look at.
- **A missing `my-calcom-auth` secret wedges the install almost invisibly.** `cpln logs` returns
*zero* lines. The only place the missing secret is named is
`cpln workload get-deployments {workload} --gvc {gvc} -o yaml` → `status.versions[].message`.
It self-heals ~5.5–10.5 minutes after the secret is created, or immediately with
`cpln workload force-redeployment`.
- **`encryptionKey` must be exactly 32 characters** (`openssl rand -base64 24` — verified to
produce exactly 32) and is effectively unrotatable: it encrypts every stored calendar and app
credential, so changing it orphans them all. Treat it as write-once for the life of the install.
- **`cronSecret` is a real security control, not decoration — measured, not inferred.** Running
the pinned image with `CRON_SECRET` unset: `GET /api/tasks/cron` with no header returns **401**,
and with `Authorization: Bearer undefined` returns **400** — i.e. it got *past* the auth check
and failed later. Any unauthenticated caller sending that header can trigger `tasker.cleanup()`
and queue processing (upstream issue #29565, unfixed at v6.2.0). The template always sets it
from the prerequisite secret; there is no valid unset path. Do not let anyone "simplify" it away.
- **Self-service signup cannot be turned off with an environment variable.** Cal.com reads
`NEXT_PUBLIC_DISABLE_SIGNUP`, and every `NEXT_PUBLIC_*` value is compiled into the app when the
image is *built*, so setting it at runtime does nothing. The working path is the database-backed
feature flag `disable-signup` in Settings → Admin → Features, after the admin account exists.
This is why public access defaults to off.
- **Narrowing the bundled Postgres to a `workload-list` that omits the app is a boot hang, not an
error.** `postgres.internalAccess` is the subchart's own knob and a parent cannot inject into it,
so the chart hard-fails at render instead, naming the exact link to add;
`postgres.internalAccess.type: none` is refused outright. `postgres-highly-available` exposes no
such knob, so the HA path is unaffected.
- **Whoever reaches `/auth/setup` first becomes the instance admin**, and there is no way to
pre-create the owner from a secret (unlike keycloak or langfuse). Publishing the endpoint before
finishing the wizard hands out admin.
- **Without the cron workload, nothing scheduled ever runs** — queued outbound email, calendar
sync, credential refresh — and every health surface still reads green. This is the failure users
will report as "reminders stopped working": check that `{release}-calcom-cron` exists and that
its log shows `calcom-cron /api/tasks/cron -> 200` roughly every 60 s. `cron.enabled` with
`internalAccess.type: none` is refused at render, because the loop's calls arrive on the app's
internal inbound.
- **`HOSTNAME: "0.0.0.0"` on the app container is load-bearing insurance, not dead config.** The
image builds a Next.js *standalone* bundle (`/calcom/apps/web/.next/standalone` exists at this
tag) and a standalone server binds `process.env.HOSTNAME`, which the platform sets to the replica
name — the `langfuse` `502 Unable to connect to upstream workload` failure. At v6.2.0 the
container runs `next start` instead, which ignores `HOSTNAME`: verified by running the pinned
image with `HOSTNAME` set to a replica-shaped name and getting `Local: http://localhost:3000`
plus a 200 on `127.0.0.1:3000/api/version`. The variable is a no-op today and one upstream line
away from being essential, and the whole private-first story depends on loopback. The API accepts
and stores it verbatim (probed 2026-08-31; only `CPLN_`-prefixed names are reserved).
- **First boot is slow by design** — the container waits for the database, rewrites built assets
when the public URL differs from the baked one, runs `prisma migrate deploy`, and seeds the app
store, all before the HTTP server starts. Readiness exhausting its threshold during that wait is
**not** benign — see the readiness bullet above; it is what terminates the container, and the
240 s wait budget is sized to stay inside it. The private default is the *fast* path: the
baked `BUILT_NEXT_PUBLIC_WEBAPP_URL` is `http://localhost:3000` (read out of the pinned image),
so `replace-placeholder.sh` prints "Nothing to replace" and skips the rewrite entirely.
- **Enterprise-gated / not shipped**: organizations, SAML/SSO and the v2 API. The first two are
build-ARG-only in the official prebuilt image or licence-gated; the v2 API is a separate image
with its own Redis dependency. Core scheduling, teams, workflows and booking pages are not gated.
- **A firewall flip takes ~30 s to ~10 minutes to propagate.** After
`--set publicAccess.enabled=true`, keep re-polling; the catalog's measured high-water mark is
559 s.
- **The first `helm upgrade` after an install may re-apply the bundled Postgres** and bounce it for
a minute or two; at `replicas: 1` that is a visible app outage. It is probabilistic, not a chart
defect.
- **Upstream is mid-rebrand.** `cal.com/docs/self-hosting/docker` now redirects to `cal.diy/docker`
and names `calcom/cal.diy` — a Docker Hub repository with **zero tags and zero pulls**, and
`github.com/calcom/cal.com` redirects to `calcom/cal.diy`. Designing from the docs would have
pinned an image that does not exist. We ship `calcom/cal.com:v6.2.0` (4.7M pulls); expect the
image line to move, and re-check the repository name at the next version bump.
Binary file added calcom/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions calcom/versions/1.0.0/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
apiVersion: v2
name: calcom
description: Self-hosted Cal.com scheduling and booking pages with a PostgreSQL backing store
type: application
version: 1.0.0
appVersion: "6.2.0"

dependencies:
- name: cpln-common
version: 1.0.0
repository: "oci://ghcr.io/controlplane-com/templates"
- name: postgres
version: 3.4.1
repository: "oci://ghcr.io/controlplane-com/templates"
condition: postgres.enabled
- name: postgres-highly-available
alias: postgresHA
version: 2.7.0
repository: "oci://ghcr.io/controlplane-com/templates"
condition: postgresHA.enabled

annotations:
created: "2026-08-31"
lastModified: "2026-08-31"
category: "scheduling"
createsGvc: false
Loading
Loading