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.
- In Google Cloud Console, create a project and enable the Google Calendar API.
- Configure the OAuth consent screen. While it is unverified you can add up to 100 test users, which is plenty for a team.
- 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
- 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
- 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.
- 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.
- 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. - 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
| Variable | Where | Purpose |
|---|---|---|
| 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 |
| SIGNUPS | secret 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_V1 | secret | AES-GCM key for calendar tokens |
| SIGNING_KEY | secret | HMAC key for guest manage links |
| GOOGLE_CLIENT_ID / _SECRET | secret | Your Google OAuth app |
| MICROSOFT_CLIENT_ID / _SECRET | secret | Your Microsoft app |
| RESEND_API_KEY | secret | Omit to log emails instead of sending |
| BREVO_API_KEY | secret | Alternative 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.