Self-hosting
Deploy the scheduling engine on infrastructure you control.
Choose Node.js with SQLite or Cloudflare Workers with D1. This guide covers the Cloudflare deployment; the Node.js deployment notes cover containers and persistent storage.
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/mymeet-ai-first-company/scheduler.git
cd scheduler
npm install
npx wrangler login2. Create the two resources#
npx wrangler d1 create punctual
npx wrangler kv namespace create CACHEBoth 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_KEYENCRYPTION_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 deploywrangler 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#
Mymeet.ai Calendar connects to Google Calendar, Yandex 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#
- 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/calendarAlso 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, Mymeet.ai Calendar logs a delivery: not_sent event without email contents or login links.
For production, select one provider explicitly. Mailgun requires a verified
sending domain and an explicit US or EU region:
npx wrangler secret put MAILGUN_API_KEY
# add under [vars] in wrangler.toml:
EMAIL_PROVIDER = "mailgun"
MAILGUN_DOMAIN = "mg.example.com"
MAILGUN_REGION = "eu"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. Resend and Brevo remain supported through their existing
API-key settings. In Mailgun, accepted means queued for
processing, not delivered; confirm delivered or inspect
failed in Mailgun Events.
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 the landing page links to it.
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 deployMigrations 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 |
DEFAULT_LOCALE | [vars] | Initial product language: ru or en. The dashboard Admin page can change the effective language later |
LEGAL_OPERATOR | [vars] | Operator named on /privacy and /terms; configure the actual legal entity for this deployment |
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), linked from the landing page |
GA_MEASUREMENT_ID | [vars] | Unset by default. A GA4 id (G-XXXXXXXXXX) loads Google Analytics on the marketing/docs pages ONLY — never on a booking page or the dashboard |
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 |
EMAIL_PROVIDER | [vars] | Explicitly selects console, Resend, Brevo, or Mailgun |
MAILGUN_API_KEY | secret | Mailgun API key |
MAILGUN_DOMAIN / MAILGUN_REGION | [vars] | Verified sending domain and us/eu API region |
RESEND_API_KEY | secret | Resend API key |
BREVO_API_KEY | secret | Brevo API key |
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 provider configured, or
with EMAIL_PROVIDER=console, only a delivery: not_sent event is logged.
Otherwise inspect the provider’s delivery events, then check
npx wrangler tail for adapter errors.
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.