- Nix 56.7%
- JavaScript 28.1%
- Shell 15.2%
|
|
||
|---|---|---|
| apps/cal-diy | ||
| lib | ||
| flake.nix | ||
| README.md | ||
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:
- Registers cal.diy as a SAML SP in Zitadel. Reads the
FirstInstance PAT that
~/homefree/apps/zitadelwrites to/var/lib/zitadel/pat-bootstrap, looks up thehomefreeproject 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. - Downloads Zitadel's IdP metadata from
sso.<domain>/saml/v2/metadataand stashes it under/var/lib/homefree-secrets/cal-diy/. - Runs an in-container companion script (
provision-cal-saml.js) viapodman exec -i cal-diy node, which:- Installs a Postgres trigger
cal_diy_saml_admin_promoteon theuserstable. Any row whose email is inSAML_ADMINSis promoted torole = '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 consultSAML_ADMINS; this trigger is the workaround.) - Plants a sentinel placeholder user
(
__placeholder__@cal-diy.invalid) so cal.com'suserCount === 0 → /auth/setupredirect gate goes away. - Calls cal.com's own BoxyHQ Jackson
connectionController .createSAMLConnectiondirectly with the Zitadel IdP metadata. This bypasses the tRPCviewer.sso.updatemutation's auth gate entirely — we're calling the underlying library, not the wrapper.
- Installs a Postgres trigger
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
- 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 rungit init && git add -A && git commitinside it. - Open the HomeFree admin panel and go to Developers → Custom Flakes.
- Click Add a custom flake and choose the type:
- Local repository — file browser, point at the directory.
- Remote URL —
github:owner/homefree-cal-diyor similar.
- Optionally click Validate to confirm the flake is reachable.
- Click Register flake.
- Click Apply Changes in the sidebar to rebuild the system.
- 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 forhomefree-config.jsonand the source the admin Services page reads.homefree.service-options.<name>— what the app'sconfigblock reads;module.nixmirrors 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:
- Both units green.
systemctl status podman-cal-diy cal-diy-sso-provision→ bothactive. - SAML SP exists in Zitadel.
Zitadel admin UI: HomeFree project → Applications →
cal-diy. - Jackson connection persisted.
sudo -u postgres psql cal-diy -c "SELECT key, namespace FROM jackson_store LIMIT 5;"should show rows. - Promotion trigger installed.
sudo -u postgres psql cal-diy -c "\dft cal_diy*"should listcal_diy_saml_admin_promote_fn. - 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.comb. Cal.com bounces to Zitadel c. Already signed in to Zitadel → bounces back to/api/auth/saml/callbackd. 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.20is the last MIT-licensed tag published before cal.com went closed-source.calcom/cal.diyon Docker Hub exists but has zero published tags as of packaging time; switch theimagevalue inapps/cal-diy/default.nixwhen upstream starts publishing. ARM hosts need the-armtag 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_restorecycle 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.postgresqlpartOf = [ "postgresql.service" ]).
- Holds long-lived encryption keys (vendor
lib/secrets-anchor.nixand 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.kindenum supporting it natively (read the Zitadel PAT directly from/var/lib/zitadel/pat-bootstrapand call its management API; bypass the app's tRPC auth gate by calling the underlying SSO library inside the container viapodman exec -i node).