punctual:

Self-hosting

Zero to a working booking link in about 15 minutes, on Cloudflare's free tier, for $0.

Zero to a working booking link in about 15 minutes, on Cloudflare's free tier, for $0.

You need a Cloudflare account and Node 20+. You do not need a paid Cloudflare plan, a database server, Docker, or a credit card.

1. Get the code

git clone https://github.com/CCCrafts/punctual.git
cd punctual
npm install
npx wrangler login

2. Create the two resources

npx wrangler d1 create punctual
npx wrangler kv namespace create CACHE

Both commands print an id. Put them in wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "punctual"
database_id = "<the id from d1 create>"

[[kv_namespaces]]
binding = "CACHE"
id = "<the id from kv namespace create>"

D1 stores everything durable. KV caches only external calendars' busy times — never your bookings, which are always read from D1 so you see your own writes immediately.

3. Set two secrets

openssl rand -base64 32 | npx wrangler secret put ENCRYPTION_KEY_V1
openssl rand -base64 32 | npx wrangler secret put SIGNING_KEY

ENCRYPTION_KEY_V1 encrypts calendar refresh tokens at rest (AES-GCM). SIGNING_KEY signs the reschedule and cancel links in your emails.

Keep both. Losing ENCRYPTION_KEY_V1 means every host must reconnect their calendar. Rotating it later is supported — add ENCRYPTION_KEY_V2 and the engine decrypts with the old key while encrypting with the new one.

4. Create the schema and deploy

npm run migrate
npm run deploy

wrangler deploy prints your Worker's URL. Put it in wrangler.toml's BASE_URL (every link the engine writes into emails, OAuth callbacks and manage pages is built from it) and deploy once more. Until you do, the Worker refuses to serve rather than quietly generating dead links.

5. Connect a calendar

Punctual talks to Google Calendar and Microsoft 365 using your own OAuth application. That is more setup than a hosted service, and it is also why no one else can see your calendar data.

Google

  1. In Google Cloud Console, create a project and enable the Google Calendar API.
  2. Configure the OAuth consent screen. While it is unverified you can add up to 100 test users, which is plenty for a team.
  3. Create an OAuth client ID of type Web application with both redirect URIs below registered — sign-in and calendar connect are deliberately separate flows, so a leaked code for one can never be exchanged against the other's endpoint:
https://<your-worker-url>/auth/google/callback?purpose=identity
https://<your-worker-url>/auth/google/callback?purpose=calendar
  1. Set the credentials.
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET

Calendar scopes are classed as sensitive by Google. Verification takes weeks, so start it early if you plan to go past 100 users; until then the consent screen shows an "unverified app" warning, which is fine for an internal team.

Microsoft

  1. In Entra ID → App registrations, register an application. Under Supported account types, pick "Accounts in any organizational directory and personal Microsoft accounts" — the narrower single-tenant option locks out any guest or teammate on a different tenant or a personal Outlook.com account.
  2. Entra's registration screen only accepts one redirect URI and rejects one with a query string. Register with a bare placeholder URI first (.../auth/microsoft/callback), then on the Authentication blade add both real URIs and remove the placeholder:
https://<your-worker-url>/auth/microsoft/callback/identity
https://<your-worker-url>/auth/microsoft/callback/calendar

Also on that blade, enable ID tokens under "Implicit grant and hybrid flows" — the identity flow needs it.

  1. Grant delegated Graph permissions (API permissions → Add a permission → Microsoft Graph → Delegated): openid, email, profile, offline_access, Calendars.ReadWrite. No admin consent needed — these are per-user delegated grants.
  2. Set the credentials.
npx wrangler secret put MICROSOFT_CLIENT_ID
npx wrangler secret put MICROSOFT_CLIENT_SECRET

6. Email (optional, but you want it)

Without an email provider, Punctual logs emails instead of sending them. To send for real, set either provider's key (Resend is tried first if both are set):

npx wrangler secret put RESEND_API_KEY
# or
npx wrangler secret put BREVO_API_KEY

Then set FROM_EMAIL and FROM_NAME in wrangler.toml's [vars] to an address on a domain you have verified with your provider, with SPF, DKIM and DMARC configured — booking confirmations that land in spam are worse than no email at all.

7. Make it yours

Sign in first — you're the admin. The first account created on a fresh deployment gets the admin role: an Admin page appears in the dashboard with the user list (grant or remove admin; the last admin can never be demoted) and the sign-up policy — open, closed, or an allowlist of emails and @domains. When everyone who should have an account has one, close sign-ups there. Existing users keep signing in.

Prefer configuration as code? Setting the SIGNUPS variable (same values) pins the policy — the Admin page then shows it read-only. Upgrading an existing deployment where nobody is admin yet? Promote yourself once:

npx wrangler d1 execute punctual --remote \
  --command "UPDATE users SET role='admin' WHERE email='you@acme.com'"

Fill in your profile at Dashboard → Settings: photo, name, position, company, and a company link. They render on your booking pages and in guest confirmation emails, and your company anchors the booking page's footer.

Name the operator. BRAND_NAME is the product name in the footer and emails; LEGAL_OPERATOR is the legal entity named on /privacy and /terms.

Put a live demo on your landing page. Once you have a real event type, set DEMO_BOOKING_PATH (e.g. /jo/30min) and your home page embeds that booking page live.

Every booking form also asks one built-in optional question — “What would you like to discuss?” — whose answer flows to the calendar event and both confirmation emails. To reword it or make it required, add your own Agenda | textarea | required line to the event type's questions; your version replaces the built-in one.

Upgrading

git pull
npm run migrate
npm run deploy

Migrations are forward-only and additive, so skipping several versions is fine.

What you get on the free tier

A team of ten scheduling normally sits far inside Cloudflare's free limits: 100,000 Worker requests a day, 5 GB of D1 storage, 5 million D1 row reads a day.

Two features need a paid plan, and both degrade gracefully:

  • Queues — emails and webhooks are delivered inline instead, on the request path, with no automatic retries. Everything still works; a failed send is simply not retried.
  • Read replication — without it, D1 reads go to your database's home region. Fine for a team in one place; enable it later with one API call, no code change.

Configuration reference

VariableWherePurpose
BASE_URL[vars]Public origin; used in links and emails
BRAND_NAME[vars]Shown in the footer and emails
LEGAL_OPERATOR[vars]Data controller named on /privacy and /terms; defaults to BRAND_NAME
FROM_EMAIL / FROM_NAME[vars]Sender identity
SUPPORT_EMAIL[vars]Reply-to on outbound mail
TELEMETRY_ENABLED[vars]0 by default — see below
SIGNUPSsecret or [vars]Pins the sign-up policy: open, closed, or a comma list of emails and @domains. Unset (the default), admins manage it from the dashboard's Admin page — existing users always sign in either way
DEMO_BOOKING_PATH[vars]A live booking page on this deployment (e.g. /jo/30min), embedded on the landing page
ENCRYPTION_KEY_V1secretAES-GCM key for calendar tokens
SIGNING_KEYsecretHMAC key for guest manage links
GOOGLE_CLIENT_ID / _SECRETsecretYour Google OAuth app
MICROSOFT_CLIENT_ID / _SECRETsecretYour Microsoft app
RESEND_API_KEYsecretOmit to log emails instead of sending
BREVO_API_KEYsecretAlternative to Resend; Resend wins if both are set

Telemetry

Off unless you set TELEMETRY_ENABLED=1. When on, it sends one ping a day: a random instance id, the version, and counts of users, event types and bookings — no names, emails, slugs, URLs or calendar content.

Troubleshooting

"unverified app" on Google sign-in. Expected until Google finishes verification. Add yourself as a test user on the consent screen.

Emails are not arriving. With no RESEND_API_KEY they are logged, not sent. Check npx wrangler tail.

Times look wrong by an hour. Almost always a host timezone set incorrectly rather than a DST bug — the engine computes in UTC and converts at the edges.

Full guide on GitHub: docs/self-hosting.md.