Skip to content

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 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#

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#

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

  3. 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.
  4. 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 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
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
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), 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_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
EMAIL_PROVIDER[vars]Explicitly selects console, Resend, Brevo, or Mailgun
MAILGUN_API_KEYsecretMailgun API key
MAILGUN_DOMAIN / MAILGUN_REGION[vars]Verified sending domain and us/eu API region
RESEND_API_KEYsecretResend API key
BREVO_API_KEYsecretBrevo 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.