Skip to content

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.

On your own machine, with no proxy, domain, certificate or mail. From apps/caldiy/:

Terminal window
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 -d

The 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.

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.

  1. Copy the environment file:

    Terminal window
    cp .env.example .env

    Set APP_TRAEFIK_HOST, TZ and the branding variables. The EMAIL_* block is what sends booking confirmations and invitations — without it the application runs and nobody is told about their appointment. Then set ALLOWED_HOSTNAMES to match the host exactly:

    ALLOWED_HOSTNAMES='"cal.example.com"'
  2. Generate the secrets:

    Terminal window
    mkdir -p .secrets volumes/postgres volumes/redis
    openssl rand -hex 32 > .secrets/db_pwd.txt
    openssl rand -base64 32 | tr -d '\n' > .secrets/nextauth_secret.txt
    openssl rand -base64 24 | tr -d '\n' > .secrets/encryption_key.txt
    openssl rand -hex 16 | tr -d '\n' > .secrets/cron_api_key.txt
    printf '%s' 'YOUR-SMTP-PASSWORD' > .secrets/smtp_password.txt

    Use a dedicated SMTP credential for this deployment, not one shared with another application.

  3. 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-keys

    Public key into VAPID_PUBLIC_KEY in .env; private key into .secrets/vapid_private_key.txt.

  4. Start it:

    Terminal window
    docker compose up -d
    docker compose logs caldiy-app --follow

    Wait for ready on port 3000.

  5. Open https://<APP_TRAEFIK_HOST> from the VPN — the route ships access-vpn until 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 set APP_TRAEFIK_ACCESS=access-public in .env and docker compose up -d, if bookings are to be public. Public self-registration stays disabled — NEXT_PUBLIC_DISABLE_SIGNUP=true.

Terminal window
docker compose ps # three services, healthy

Then 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.

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:

Terminal window
dig +short cal.example.com # a Cloudflare address, not your origin

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:

Terminal window
docker compose up -d --force-recreate caldiy-app

The 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.

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.

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.

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 psql

encryption_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.

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.

  1. Read the fork’s release notes and re-resolve the digest for the new tag.

  2. Back up the database and the secret files together.

  3. Raise APP_TAG in .env — tag and digest — then:

    Terminal window
    docker compose pull && docker compose up -d
    docker compose logs caldiy-app --follow
  4. Log in and open one event type. A migration that touched the encrypted columns shows up there.

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.

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