← Changelog

The Tool We Recommended Had No Image

Cal.diy is live on the lossless stack, with a calendar actually connected. Getting there meant discovering the tool we pointed readers at in July ships no Docker image at all — and then chasing an error message that blamed the user for a mistake we had made.

· Michael Staton · with Claude Code on Claude Opus 5

Why Care?

In July we corrected the README's Calendar & Scheduling row. Cal.com had gone closed-source in April, and the genuinely open remainder lived on as Cal.diy under MIT. That entry made a point of saying a recommendation table is a perishable good, and that being wrong quietly is worse than being wrong loudly.

Today we tried to deploy the thing we had recommended, and found the recommendation was still only half true.

What's New?

Cal.diy runs on the lossless stack, with a calendar connected and syncing. Two Railway services — caldiy and caldiy-postgres — reachable from the portal at lossless.at/lossless/scheduling. Third bundle on our own stack after Twenty and Plane, and the first one we build from source rather than pull.

It deletes a Calendly line item, but the reason it earned a slot is narrower than "scheduling": one identity can front any number of calendars, with a separate booking link per audience, each writing to a different calendar. A humain.vc intro link whose bookings land on the humain.vc calendar, distinct from the general link, without a second seat anywhere.

The portal gained a new scheduling service handle to carry it — role-based like the rest, and honest in a way postiz could not be, since Cal.diy really is the whole scheduling surface rather than one slice of it.

The image that does not exist

docker.io/calcom/cal.diy returns NAME_UNKNOWN. Not "no matching tag" — the repository is not there. DockerHub's API reports count: 0. GHCR denies anonymous pulls because there is no public package behind it.

Meanwhile the fork's own apps/docs/content/docker.mdx instructs you to docker pull calcom/cal.diy:<tag>, and its docker-compose.yml names calcom.docker.scarf.sh/calcom/cal.diy. Both are inherited Cal.com text nobody rewired after the split. The tell is the build: block sitting directly under that image reference.

The last real image, calcom/cal.com:v6.2.0, was published 2026-03-02 — before the April split. Mixed AGPL-plus-commercial-EE codebase, frozen five months, unpatched. Deploying it would have reintroduced precisely the rot the July entry called out, while letting us claim we had shipped the open fork.

So: build from source, fork first. core/cal.diy points at lossless-group/cal.diy rather than upstream, because a source build has no image tag to pin — the fork's main is the pin.

The build took eight minutes. The runbook had budgeted 30–45 and warned about OOM at the 6 GB heap ceiling. Neither happened.

Two errors that blamed the wrong thing

The genuinely expensive part of the day was not the build. It was a chain of two error messages, each of which pointed at the user.

"Invalid key length." Shown in the connect-a-calendar dialog, directly beneath the app-specific-password field. It reads as a rejected credential. It is not. CALENDSO_ENCRYPTION_KEY must be exactly 32 characters, because packages/lib/crypto.ts does Buffer.from(key, "latin1") — one byte per character — before createCipheriv("aes256", …). We generated the key with openssl rand -base64 32, which is 44 characters, and which is exactly what TWENTY_ENCRYPTION_KEY two blocks up in the same secretspec.toml wants. Cal does not base64-decode; it counts characters.

The throw happens before any request reaches Apple, so the password in the field is never evaluated. Every retry produced the identical error, and the natural response — generate another app-specific password — could never have worked.

"Please ensure you're using an app-specific password rather than your account password." Shown once the key was fixed, and also wrong. The server log carried the real reason, and there were two different ones thirty seconds apart:

Error: Invalid credentials   tsdav.cjs.js:1140
Error: cannot find homeUrl   tsdav.cjs.js:1164

Only the first is an auth failure. cannot find homeUrl is thrown after a successful login, when CalDAV service discovery asks iCloud for the account's calendar-home-set and gets nothing — the signature of an Apple ID that authenticates fine but has no iCloud Calendar provisioned. You cannot reach that line with a bad password. The UI showed the same canned string for both.

The lesson is not about Cal.diy. When a self-hosted tool renders a generic error next to a credential field, read the server log before touching the credential. The dialog is guessing; the log is not.

The correction we made to ourselves

The first build's log showed replace-placeholder.sh using the Dockerfile's ARG default rather than the domain sitting in the variable panel. The obvious reading — Railway does not pass service variables as Docker build args — got written into the runbook as observed fact.

It was false. Creating a Railway service fires a build immediately, before a domain exists or a variable is set. That first build is a race you cannot win. The second build, with everything in place, baked the real URL.

That is now context-v/reminders/Creating-a-Railway-Service-Fires-a-Build-Before-You-Configure-It.md, and the durable part is not about Railway. A single observation of a default value is equally consistent with "the value was never passed" and "the value did not exist yet" — and those have opposite fixes. Two runs beat one confident inference.

docs/caldiy/setup.md now marks every gotcha ✅ observed, ⚠️ bit us, or ❌ hypothesis-was-wrong, so the next deployment can tell which lines were earned and which were guessed.

What this deliberately does not include

The fork strips the enterprise edition — that is the point of the fork — so there is no /team/<slug> booking page and no organizations. The Team, Membership and Profile tables survive in schema.prisma; nothing renders them.

Worth stating plainly, because it is easy to oversell: per-audience routing is free, per-audience branding is not. Unlimited event types, each with its own slug, destination calendar, availability schedule and hidden flag — all free. But brandColor, avatarUrl and bio are User-level. A branded landing page means either a second user account or embedding the [user]/[type]/embed route into a page you already control. The second is better anyway: brand belongs on the brand's own site.

Also not included yet: backups. The only irreplaceable state is Postgres plus the encryption key, and restore-runbook.md is written, but no pg-dump-caldiy service exists. And no email_sender on the portal card — mail is wired to the shared Resend key from birth, but nothing has been observed to send, and the page states that field as fact.

Files changed

  • .gitmodules
  • core/cal.diy
  • docs/caldiy/setup.md
  • context-v/reminders/Creating-a-Railway-Service-Fires-a-Build-Before-You-Configure-It.md
  • changelog/2026-08-22_01.md
  • hubs/lossless-at