No description
  • Nix 56.7%
  • JavaScript 28.1%
  • Shell 15.2%
Find a file
2026-06-12 18:28:38 -07:00
apps/cal-diy dashboard: declare category + description on service-config 2026-06-12 18:02:16 -07:00
lib Cal.diy HomeFree plugin with auto-provisioned Zitadel SSO 2026-06-06 22:09:55 -07:00
flake.nix Cal.diy HomeFree plugin with auto-provisioned Zitadel SSO 2026-06-06 22:09:55 -07:00
README.md Cal.diy HomeFree plugin with auto-provisioned Zitadel SSO 2026-06-06 22:09:55 -07:00

homefree-cal-diy

A HomeFree flake plugin that adds Cal.diy — the MIT-licensed, community build of Cal.com — as a self-hosted scheduling service with fully auto-provisioned Zitadel SSO.

It is packaged as a standalone Nix flake so it can be registered through the HomeFree admin panel without forking the HomeFree base repo. You keep receiving upstream HomeFree updates while running this custom app alongside them.

What it adds

Cal.diy runs as the official calcom/cal.com container, reverse-proxied at cal.<your-domain>. Once registered it appears in the admin panel under Services → Cal.diy with these options:

  • enable — turn the service on/off
  • public — expose it on the WAN port (required so external bookers can reach your booking pages)

After Apply Changes, you visit https://cal.<your-domain>, get redirected to Zitadel, sign in with your existing HomeFree credentials, and land in cal.diy as ADMIN. There is no setup wizard, no bootstrap password, no admin-UI clicks. SAML is wired both sides at boot.

Authentication model

The plugin runs a cal-diy-sso-provision.service oneshot after both cal.diy and Zitadel are up. It:

  1. Registers cal.diy as a SAML SP in Zitadel. Reads the FirstInstance PAT that ~/homefree/apps/zitadel writes to /var/lib/zitadel/pat-bootstrap, looks up the homefree project ID, and POSTs a new SAML app via Zitadel's management API (/management/v1/projects/{id}/apps/saml). Idempotent — re-runs skip when the app already exists.
  2. Downloads Zitadel's IdP metadata from sso.<domain>/saml/v2/metadata and stashes it under /var/lib/homefree-secrets/cal-diy/.
  3. Runs an in-container companion script (provision-cal-saml.js) via podman exec -i cal-diy node, which:
    • Installs a Postgres trigger cal_diy_saml_admin_promote on the users table. Any row whose email is in SAML_ADMINS is promoted to role = 'ADMIN' on INSERT/UPDATE — fires synchronously so SAML JIT-creates land as ADMIN by the time the cookie is issued. (Cal.com's own SAML JIT code does NOT consult SAML_ADMINS; this trigger is the workaround.)
    • Plants a sentinel placeholder user (__placeholder__@cal-diy.invalid) so cal.com's userCount === 0 → /auth/setup redirect gate goes away.
    • Calls cal.com's own BoxyHQ Jackson connectionController .createSAMLConnection directly with the Zitadel IdP metadata. This bypasses the tRPC viewer.sso.update mutation's auth gate entirely — we're calling the underlying library, not the wrapper.

The HomeFree Caddy reverse-proxy adds a single extraCaddyConfig rule:

@cal_diy_login path /auth/login
redir @cal_diy_login /api/auth/saml/idp/login?tenant=Cal.com&product=Cal.com 302

So https://cal.<domain>/auth/login (and the bare https://cal.<domain>, which cal.com itself 307s to /auth/login) shortcut straight into the SAML flow — no email/password form, no "Sign in with SAML" button to hunt for. The booking pages (cal.<domain>/<username>/<event-type>) stay untouched.

How to add it to HomeFree

  1. Put this repository on the HomeFree machine (it must be a git repository — a git+file:// flake requires one). Either clone it there, or copy the directory and run git init && git add -A && git commit inside it.
  2. Open the HomeFree admin panel and go to Developers → Custom Flakes.
  3. Click Add a custom flake and choose the type:
    • Local repository — file browser, point at the directory.
    • Remote URLgithub:owner/homefree-cal-diy or similar.
  4. Optionally click Validate to confirm the flake is reachable.
  5. Click Register flake.
  6. Click Apply Changes in the sidebar to rebuild the system.
  7. Open Services, enable Cal.diy, and Apply Changes again.

To remove it later: delete the entry on the Custom Flakes page and Apply Changes.

How it works

flake.nix                            # exposes nixosModules.default
lib/secrets-anchor.nix               # vendored from homefree/lib
apps/cal-diy/
  default.nix                        # NixOS module — declares all wiring
  icon.svg
  provision-sso.sh                   # bash, runs on the host as a oneshot
  provision-cal-saml.js              # node, piped into `podman exec -i cal-diy`

flake.nix's nixosModules.default is what HomeFree composes into the system build (the admin panel writes it into /etc/nixos/custom-flakes.nix). apps/cal-diy/default.nix is an ordinary NixOS module, evaluated with the same config/lib/pkgs as an in-tree HomeFree app.

Option namespaces

HomeFree splits a service's options across two namespaces, and the base repo's module.nix normally declares both halves per in-tree app:

  • homefree.services.<name> — the binding target for homefree-config.json and the source the admin Services page reads.
  • homefree.service-options.<name> — what the app's config block reads; module.nix mirrors one into the other.

A plugin flake cannot edit the base module.nix, so this app declares both halves itself.

Database

Cal.diy's Postgres database (cal-diy) and role (cal-diy) are provisioned through the host's services.postgresql — the same pattern apps/joplin, apps/nextcloud, and apps/freshrss use in the base repo. The container bind-mounts /run/postgresql and connects via Unix socket. The systemd.services.podman-cal-diy unit is marked partOf = [ "postgresql.service" ] so a Postgres restart also restarts cal.diy — otherwise the bind-mounted socket file becomes a stale inode handle.

BoxyHQ's SAML_DATABASE_URL points at the same database; Jackson manages its own schema inside it.

Secrets

Four secrets are auto-generated on first boot and anchored into the encrypted /etc/nixos/secrets/secrets.yaml via the vendored lib/secrets-anchor.nix:

key role
nextauth-secret NextAuth session JWT signing
calendso-encryption-key AES key for encrypted Postgres columns
calcom-app-credential-encryption-key AES key for stored OAuth tokens
calcom-service-account-encryption-key AES key for Google service-account credentials

Anchoring matters because /var/lib/homefree-secrets/cal-diy/ is not in the restic backup set — only /etc/nixos/ is. Without anchoring, a restore would regenerate fresh keys and orphan every encrypted row in the restored Postgres dump (bricked OAuth tokens, unreadable user data). The anchor helper persists the generated values back into secrets.yaml so they replay correctly after a restore.

Backups

backup.postgres-databases = [ "cal-diy" ] adds the cal.diy database to HomeFree's restic schedule. Combined with the secrets anchoring above, those two artefacts are everything needed to fully restore the service on fresh hardware. (The Jackson SAML connection lives in the same DB — it rides along automatically.)

Verification

After a successful Apply Changes:

  1. Both units green. systemctl status podman-cal-diy cal-diy-sso-provision → both active.
  2. SAML SP exists in Zitadel. Zitadel admin UI: HomeFree project → Applications → cal-diy.
  3. Jackson connection persisted. sudo -u postgres psql cal-diy -c "SELECT key, namespace FROM jackson_store LIMIT 5;" should show rows.
  4. Promotion trigger installed. sudo -u postgres psql cal-diy -c "\dft cal_diy*" should list cal_diy_saml_admin_promote_fn.
  5. End-to-end login. In a fresh browser, visit https://cal.<domain>/. Expect: a. Caddy 302 → /api/auth/saml/idp/login?tenant=Cal.com&product=Cal.com b. Cal.com bounces to Zitadel c. Already signed in to Zitadel → bounces back to /api/auth/saml/callback d. Land at cal.diy dashboard as the operator's email, role ADMIN.

Known caveats

  • Third-party asset loading. The upstream cal.com frontend fetches fonts and a few telemetry/CDN assets from third parties (Google Fonts, etc.). HomeFree's rule against external asset loading applies to HomeFree-served surfaces (the admin panel, landing pages). Cal.com is third-party software whose payload we don't control; if strict no-third-party loading matters to you, expect to either patch the upstream Next.js build or proxy the assets yourself.
  • Image pinning. calcom/cal.com:v5.6.20 is the last MIT-licensed tag published before cal.com went closed-source. calcom/cal.diy on Docker Hub exists but has zero published tags as of packaging time; switch the image value in apps/cal-diy/default.nix when upstream starts publishing. ARM hosts need the -arm tag suffix.
  • Migrations on upgrade. Cal.diy runs Prisma migrations on startup. During a version bump the container looks unhealthy for a few seconds; systemd's restart-on-failure handles it.
  • Trigger survives restore. The Postgres trigger is part of the cal-diy schema, so a pg_dump/pg_restore cycle preserves it. A rebuild also re-installs it idempotently, so even if a restore loses it the next boot replants it.

Writing your own HomeFree plugin

See homefree-navidrome's README for the canonical write-up — its "Writing your own HomeFree plugin" section is the framework reference. This plugin demonstrates the additional patterns a plugin needs when the upstream app:

  • Wants a dedicated Postgres database (use host services.postgresql
    • partOf = [ "postgresql.service" ]).
  • Holds long-lived encryption keys (vendor lib/secrets-anchor.nix and anchor every key the app uses to encrypt persistent data).
  • Has its own multi-tenant user model (planting a sentinel user lets you bypass first-run setup wizards that block external auth flows).
  • Needs auto-provisioned SAML SSO without HomeFree's sso.kind enum supporting it natively (read the Zitadel PAT directly from /var/lib/zitadel/pat-bootstrap and call its management API; bypass the app's tRPC auth gate by calling the underlying SSO library inside the container via podman exec -i node).