Cal.diY
Cal.diY gives people a page where they book time with you, against your own calendar. They pick a free slot, it appears in your calendar, and both sides get a confirmation mail. Three containers — the application, PostgreSQL and Redis.
Try it locally
Section titled “Try it locally”On your own machine, with no proxy, domain, certificate or mail. From
apps/caldiy/:
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs{ echo "DB_PASSWORD=$(openssl rand -hex 32)" echo "NEXTAUTH_SECRET=$(openssl rand -base64 32 | tr -d '\n')" echo "ENCRYPTION_KEY=$(openssl rand -base64 24 | tr -d '\n')" echo "CRON_API_KEY=$(openssl rand -hex 16)" vapid=$(docker run --rm node:lts-alpine npx --yes web-push generate-vapid-keys --json 2>/dev/null) echo "VAPID_PUBLIC_KEY=$(echo "$vapid" | sed 's/.*"publicKey":"\([^"]*\)".*/\1/')" echo "VAPID_PRIVATE_KEY=$(echo "$vapid" | sed 's/.*"privateKey":"\([^"]*\)".*/\1/')"} >> .env.local
docker compose -f docker-compose.local.yml --env-file .env.local up -dThe VAPID pair comes from a throwaway Node container.
It listens on 127.0.0.1:3000 only. Open http://localhost:3000: signup is open
here, so you can create the first user and click through the booking flow. Mail
is unset, so no confirmations are sent.
Installation
Section titled “Installation”For a deployment on a server. From apps/caldiy/ — the stack’s compose file
routes through Traefik, so have it running with a
hostname pointing at the host.
-
Copy the environment file:
Terminal window cp .env.example .envSet
APP_TRAEFIK_HOST,TZand the branding variables. TheEMAIL_*block is what sends booking confirmations and invitations — without it the application runs and nobody is told about their appointment. Then setALLOWED_HOSTNAMESto match the host exactly:ALLOWED_HOSTNAMES='"cal.example.com"' -
Generate the secrets:
Terminal window mkdir -p .secrets volumes/postgres volumes/redisopenssl rand -hex 32 > .secrets/db_pwd.txtopenssl rand -base64 32 | tr -d '\n' > .secrets/nextauth_secret.txtopenssl rand -base64 24 | tr -d '\n' > .secrets/encryption_key.txtopenssl rand -hex 16 | tr -d '\n' > .secrets/cron_api_key.txtprintf '%s' 'YOUR-SMTP-PASSWORD' > .secrets/smtp_password.txtUse a dedicated SMTP credential for this deployment, not one shared with another application.
-
Generate the VAPID key pair — the application logs an error on every request without it:
Terminal window docker run --rm node:lts-alpine npx --yes web-push generate-vapid-keysPublic key into
VAPID_PUBLIC_KEYin.env; private key into.secrets/vapid_private_key.txt. -
Start it:
Terminal window docker compose up -ddocker compose logs caldiy-app --followWait for
ready on port 3000. -
Open
https://<APP_TRAEFIK_HOST>from the VPN — the route shipsaccess-vpnuntil the wizard has run, because the wizard hands the owner account to whoever reaches it first. The wizard runs on first visit and the first account becomes the owner. Then setAPP_TRAEFIK_ACCESS=access-publicin.envanddocker compose up -d, if bookings are to be public. Public self-registration stays disabled —NEXT_PUBLIC_DISABLE_SIGNUP=true.
Verify
Section titled “Verify”docker compose ps # three services, healthyThen create one event type and book it from a private browser window. Check both outcomes separately: the booking appears in the interface, and the confirmation mail arrives at the address that made it. Either can fail without the other.
/api/health returns 500 on this image because of a Node.js stream
incompatibility. The healthcheck uses a TCP probe instead, and the application
itself runs normally.
Going further
Section titled “Going further”Putting a booking page on the public internet
Section titled “Putting a booking page on the public internet”A booking page is meant to be reached by people you have not met, which makes it the surface in this blueprint most worth putting an edge layer in front of. Upstream Cal.diy describes itself as intended for personal, non-production use, and neither it nor the cal.forte fork comes with vendor support or an incident-response commitment — worth weighing before it carries real bookings.
For an internet-facing deployment this guide recommends Cloudflare as that layer.
Cal.diY itself does not depend on it.
docs/cloudflare.md
has the settings: the managed WAF, a geo allowlist on the human-facing surface,
rate limiting, origin hiding, and which parts need a paid plan.
If you do use Cloudflare, publish only the address family the origin actually
serves — an A record for IPv4, an AAAA record for IPv6, or both — and proxy
whichever exist. A record left on DNS-only reaches the origin directly, and none
of the edge protection applies to it:
dig +short cal.example.com # a Cloudflare address, not your originThe HSTS extension is a per-host decision
Section titled “The HSTS extension is a per-host decision”The stack ships access-vpn until the setup wizard has run, then access-public is
the documented next step; the route carries limit-standard and headers-standard:
HSTS max-age, nosniff, X-Frame-Options: DENY, a referrer policy, and a
permissions policy denying device APIs the application never uses. No request is
blocked at the proxy beyond the rate limit, and the proxy sets no content
security policy.
includeSubDomains and preload are not part of that profile. They are the
block hsts-include-subdomains@file, placed before ${APP_TRAEFIK_HEADERS}@file
on the router in docker-compose.yml, and no stack ships it.
HSTS with preload is hard to reverse. Browsers that received it keep
enforcing HTTPS for the host and everything below it for about two years, whether
or not you stop sending the header. Confirm every subdomain below the Cal.diY
hostname is HTTPS-capable first. A dedicated leaf host carries much less scope
than an apex.
Adding or removing the block is a label change, so the container is recreated:
docker compose up -d --force-recreate caldiy-appThe booking embed does not work as shipped
Section titled “The booking embed does not work as shipped”Cal.com’s inline, pop-up and button widgets put a booking page in an iframe on
another site. headers-standard sends X-Frame-Options: DENY, which overrides
the application’s own framing policy and blocks it.
That default is deliberate: a booking page any site may frame is a booking page
any site can overlay. If you want embeds, name the parent origins rather than
dropping the header — frame-self@file placed before ${APP_TRAEFIK_HEADERS}@file
for same-origin, or a per-app middleware replacing X-Frame-Options with a
frame-ancestors allowlist. Neither option is part of the currently verified
path.
Redeploying after a compromise
Section titled “Redeploying after a compromise”If the previous instance was breached, every secret is burned and none of them
can be reused. Revoke the SMTP credential at the provider before starting the
new stack, then generate all six secret files fresh — never copy .secrets/
across.
Two of them carry consequences beyond the rotation itself: rotating
CALENDSO_ENCRYPTION_KEY invalidates every stored calendar token, so all
connected users re-authorise; rotating NEXTAUTH_SECRET signs everyone out
immediately. The per-secret procedure is in
docs/ops-runbook.md.
Keeping the mail credential contained
Section titled “Keeping the mail credential contained”Set a daily sending cap at the provider and an alert at half of it, delivered to a channel other than the Cal.diY mailbox. Send from a dedicated address or subdomain rather than a personal one.
Backup
Section titled “Backup”| Database | PostgreSQL, container caldiy-db, database caldiy, user caldiy |
| Password | .secrets/db_pwd.txt |
| State | volumes/postgres |
| Also needed | .secrets/encryption_key.txt and .secrets/nextauth_secret.txt, kept off this host |
| Rebuildable | volumes/redis |
| Quiescing | Not needed — the dump is consistent on its own. |
For borgmatic:
postgresql_databases: - name: caldiy username: caldiy # The server's own client, inside its container pg_dump_command: docker exec caldiy-db pg_dump pg_restore_command: docker exec -i caldiy-db pg_restore psql_command: docker exec -i caldiy-db psqlencryption_key.txt decrypts the connected calendar tokens. The key and the
database are stored separately and a restore needs both: brought back without the
key, every calendar integration is broken and reconnectable only by hand. A
rehearsal therefore has to reach the point where a calendar reconnects, not stop
at the row count.
Restore order: database, then the application.
Updates
Section titled “Updates”The image is the hardened cal.forte fork rather than upstream Cal.diy,
published to GHCR under versioned tags — never latest — and pinned with a
digest.
-
Read the fork’s release notes and re-resolve the digest for the new tag.
-
Back up the database and the secret files together.
-
Raise
APP_TAGin.env— tag and digest — then:Terminal window docker compose pull && docker compose up -ddocker compose logs caldiy-app --follow -
Log in and open one event type. A migration that touched the encrypted columns shows up there.
Troubleshooting
Section titled “Troubleshooting”Error: No key set vapidDetails.publicKey on every request. The VAPID keys
were not generated. Go back to installation step 3.
CLIENT_FETCH_ERROR in the browser console. NEXTAUTH_URL has to include
the /api/auth path. The compose file sets it correctly, so check for a local
override.
/api/health returns 500. Known for this image version; the container
healthcheck uses TCP instead and the application is unaffected.
Features from Cal.com are missing. Teams, organisations, advanced insights, SSO and workflows are not part of the community edition.
Verification details
Section titled “Verification details”Last run on a host 8 Nov 2026
Pinned at v6.2.0-6 (released 1 Mar 2026) · the newest upstream release
In the upstream images: 24 CRITICAL · 711 HIGH with a published fix — per image
Repository files
Section titled “Repository files”apps/caldiy/README.md— setup, security model, known issuesapps/caldiy/docs/cloudflare.md— the Cloudflare edge settingsapps/caldiy/docs/ops-runbook.md— secret rotation and emergency proceduresapps/caldiy/docs/hardening-plan.md— the post-incident roadmapapps/caldiy/UPSTREAM.md— the fork, upgrade checklist, gotchas
Official sources
Section titled “Official sources”- cal.diy — the fork this image is built from
- Cal.com self-hosting documentation
© 2026 SecDockBlueCode Apache-2.0Site content CC BY-NC 4.0