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.
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:1164Only 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
.gitmodulescore/cal.diydocs/caldiy/setup.mdcontext-v/reminders/Creating-a-Railway-Service-Fires-a-Build-Before-You-Configure-It.mdchangelog/2026-08-22_01.mdhubs/lossless-at