The set-it-and-forget-it scheduling page. Your own Calendly alternative on Cloudflare's free tier — deploy it in 15 minutes, then never think about it again.
No server updates. No database backups. No bill. One Worker with real-time Google Calendar sync, an admin dashboard, and sub-200ms page loads worldwide — running in your own Cloudflare account, under your own Google OAuth, with your bookings stored in your account — not ours.
Most self-hostable tools quietly assume you enjoy running servers. Edge-Cal keeps everything people actually want from running their own instance — your data, your accounts, no SaaS subscription — and drops the part they hate: there is no server. The entire app is a single Cloudflare Worker serving your booking page from 300+ edge locations, with managed storage (D1 + KV) on the free tier. Deploy it once and it just keeps working.
| Calendly Free | Cal.com (self-hosted) | Edge-Cal | |
|---|---|---|---|
| Cost | $0 (limited) | $5-20/mo VPS | $0 |
| Event types | 1 | Unlimited | Unlimited |
| Infrastructure | Their servers | Docker + Postgres on your server | Cloudflare edge (300+ cities) |
| Data ownership | Theirs | Yours | Yours |
| Page load | ~1-2s | ~1-3s (single region) | <200ms (worldwide) |
| Setup time | 5 min | 30-60 min | 15 min |
| Maintenance | None | Server updates, DB backups | None |
| Codebase | Closed | Large monorepo (Next.js, Prisma, tRPC) | A handful of files you can read in an afternoon |
That last row matters more than it looks: the entire frontend lives in template literals with no build step, so customizing your booking page means editing a string, not learning a monorepo.
Honest answer: Cal.com is a scheduling platform, and it's excellent. If you need team scheduling, round-robin routing, payments on bookings, video integrations, or an app ecosystem, use Cal.com — Edge-Cal doesn't do those things and doesn't try to. Edge-Cal is for the individual who wants a fast personal booking page with zero infrastructure and zero cost, full stop.
- Public booking page at
your-worker.workers.dev/book/yourname/meeting - Admin dashboard to manage event types, availability, and slot limiting
- Google Calendar sync - shows only times you're actually free
- Calendar invites - guests automatically receive a Google Calendar invitation
- Embeddable widget - drop an iframe on your website
- Slot limiting - show fewer time slots for a premium, high-demand feel
Visitor loads booking page
|
Edge-Cal checks your availability rules (D1 database)
|
Fetches your real-time busy times (Google Calendar API)
|
Shows available slots (with optional slot limiting)
|
Guest books a slot
|
Creates Google Calendar event (guest added as attendee)
|
Guest receives calendar invite via Google
You need three things:
- Node.js 18+ - nodejs.org
- Cloudflare account - Sign up free
- Google Cloud project - console.cloud.google.com
git clone https://github.com/vectorfrog/edge-cal
cd edge-cal
npm installYou need OAuth credentials so Edge-Cal can read your calendar and create events.
- Go to Google Cloud Console
- Click the project dropdown at the top and select New Project
- Name it something like "Edge-Cal" and click Create
- Make sure your new project is selected in the dropdown
- Go to APIs & Services > Library
- Search for and enable the Google Calendar API (the only API Edge-Cal needs)
- Go to APIs & Services > OAuth consent screen
- Select External user type, click Create
- Fill in the required fields:
- App name: Edge-Cal (or whatever you want)
- User support email: your email
- Developer contact: your email
- Click Save and Continue
- On the Scopes page, click Add or Remove Scopes and add:
openidemailhttps://www.googleapis.com/auth/calendar.readonlyhttps://www.googleapis.com/auth/calendar.events
- Click Save and Continue through the remaining steps
Note: While your app is in "Testing" mode, only test users you explicitly add can use OAuth. Add your own Google account under Test users. You can publish the app later to remove this restriction.
-
Go to APIs & Services > Credentials
-
Click Create Credentials > OAuth 2.0 Client ID
-
Select Web application
-
Under Authorized redirect URIs, add both:
http://localhost:8787/admin/auth/callback https://YOUR-WORKER-NAME.YOUR-SUBDOMAIN.workers.dev/admin/auth/callback(You'll get the production URL after deploying in Step 6. You can add it then.)
-
Click Create
-
Copy the Client ID and Client Secret
Create a .dev.vars file in the project root:
cp .dev.vars.example .dev.varsEdit .dev.vars with your credentials:
GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
This file is gitignored and never committed.
Wrangler v4 is pinned as a dev dependency, so npx wrangler ... works with no global install.
Log in to Cloudflare via Wrangler (if you haven't already):
npx wrangler loginCreate the KV namespace and D1 database:
npx wrangler kv namespace create KV
npx wrangler d1 create gcal-schedulerEach command outputs an ID. Update wrangler.toml with your actual IDs:
[[kv_namespaces]]
binding = "KV"
id = "paste-your-kv-id-here"
[[d1_databases]]
binding = "DB"
database_name = "gcal-scheduler"
database_id = "paste-your-d1-id-here"
preview_idis intentionally absent: plainwrangler devruns against local storage and doesn't need it. If you ever wantwrangler dev --remote, create a separate preview namespace first (npx wrangler kv namespace create KV --preview) so remote dev can't touch your live tokens and sessions.
Edit migrations/0002_seed_owner.sql with your information:
INSERT INTO users (id, username, email, full_name, timezone, role)
VALUES (
'user_owner_001',
'yourname', -- Your URL slug: /book/yourname/meeting
'you@example.com', -- Must match your Google account email
'Your Name', -- Display name on booking page
'America/New_York', -- Your IANA timezone
'owner'
);Important: The email must match the Google account you'll use for OAuth. This is how the admin dashboard validates your identity.
The seed file also creates a default 30-minute meeting type and Mon-Fri 9am-5pm availability. Edit these too if you want different defaults. You can always change them later from the admin dashboard.
After personalizing wrangler.toml (your real KV/D1 ids) and migrations/0002_seed_owner.sql, contributors who plan to push commits can tell git to stop tracking their local edits:
git update-index --skip-worktree wrangler.toml migrations/0002_seed_owner.sqlThose files will no longer show as modified, so your personal values never end up in a commit. Undo with git update-index --no-skip-worktree <file>. The committed versions must keep placeholder values.
Initialize the local database and start the dev server:
npx wrangler d1 execute gcal-scheduler --file=./migrations/0001_initial_schema.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0002_seed_owner.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0003_add_slot_limiting.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0004_booking_conflict_index.sql --local
npm run devOpen http://localhost:8787 and:
- Log in at
http://localhost:8787/admin(or click Start OAuth Flow on the home page). This single Google login both authenticates you as admin and connects your calendar. Your Google account email must match therole = 'owner'user you seeded inmigrations/0002_seed_owner.sql. - Visit
http://localhost:8787/book/yourname/meetingto see your booking page
Run migrations on the production database:
npx wrangler d1 execute gcal-scheduler --file=./migrations/0001_initial_schema.sql --remote
npx wrangler d1 execute gcal-scheduler --file=./migrations/0002_seed_owner.sql --remote
npx wrangler d1 execute gcal-scheduler --file=./migrations/0003_add_slot_limiting.sql --remote
npx wrangler d1 execute gcal-scheduler --file=./migrations/0004_booking_conflict_index.sql --remoteSet your Google OAuth secrets in production:
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET(Wrangler will prompt you to paste each value.)
Deploy the worker:
npm run deployWrangler will output your worker URL, something like:
https://edge-cal.your-subdomain.workers.dev
-
Add the production redirect URI to your Google OAuth credentials (Step 2d) if you haven't already:
https://edge-cal.your-subdomain.workers.dev/admin/auth/callback -
Log in to the admin dashboard (this also connects your Google Calendar in production):
https://edge-cal.your-subdomain.workers.dev/adminLog in with the Google account whose email matches your seeded
role = 'owner'user. -
Test your booking page:
https://edge-cal.your-subdomain.workers.dev/book/yourname/meeting
The admin dashboard lets you manage everything without touching SQL.
Access it at: /admin (you'll be sent through Google login if you don't have a session)
Authentication works through Google OAuth. Only a Google account whose email matches a user with the owner role in the database can log in. Sessions last 24 hours. Logging in is also what connects your Google Calendar — there is no separate connect step.
Event Types (/admin/event-types)
- Create, edit, and delete event types
- Set name, URL slug, duration, and description
- Activate/deactivate event types
- Configure slot limiting per event type
Availability (/admin/availability)
- Set your weekly schedule (which days and hours you're available)
- Different hours per day (e.g., Mon-Thu 9-5, Fri 9-12)
- Changes take effect immediately
Slot Limiting (per event type)
- Days forward limit (1-90): How far ahead guests can book. Default 14 days.
- Max days per week (1-7): Limit which days show available slots. Prioritizes days that already have bookings to cluster meetings.
- Max slots per day (1-20): Cap the number of time slots shown per day. Randomly distributed across the day.
The simplest approach. Paste this wherever you want the booking widget:
<iframe
src="https://edge-cal.your-subdomain.workers.dev/book/yourname/meeting"
width="100%"
height="800px"
frameborder="0"
style="border: none; border-radius: 8px;"
></iframe>Just link to your booking page:
<a href="https://edge-cal.your-subdomain.workers.dev/book/yourname/meeting">
Book a meeting with me
</a>- In Cloudflare dashboard, go to Workers & Pages > your worker > Settings > Domains & Routes
- Add your custom domain (e.g.,
cal.yourdomain.com) - Your booking page becomes:
https://cal.yourdomain.com/book/yourname/meeting
edge-cal/
├── src/
│ ├── index.ts # App wiring: mounts route modules, error/404 handlers
│ ├── routes/ # Route modules (booking, admin auth, admin CRUD, debug)
│ ├── templates/ # HTML templates (booking page, admin dashboard, forms)
│ ├── oauth.ts # Google OAuth helpers + token management
│ ├── calendar.ts # Google Calendar API (busy times, event creation)
│ ├── availability.ts # Time slot calculation algorithm
│ ├── slot-limiting.ts # Slot limiting / premium positioning
│ ├── db.ts # D1 database queries (type-safe)
│ ├── admin-middleware.ts # Session auth for admin routes
│ ├── session.ts # KV-based session management
│ ├── html.ts # HTML escaping helper
│ └── types.ts # TypeScript interfaces
├── migrations/
│ ├── 0001_initial_schema.sql # Tables: users, event_types, availability_rules, bookings, etc.
│ ├── 0002_seed_owner.sql # Your profile + defaults (customize this)
│ ├── 0003_add_slot_limiting.sql
│ └── 0004_booking_conflict_index.sql
├── .github/workflows/ci.yml # CI: typecheck + tests on every push/PR
├── wrangler.toml # Cloudflare Workers config
├── package.json
└── tsconfig.json
Edge-Cal uses two Cloudflare storage systems:
| Storage | What's in it | Why |
|---|---|---|
| D1 (SQLite) | Users, event types, availability rules, bookings, OAuth refresh tokens | Durable, relational, free |
| KV | OAuth access tokens (1hr TTL), admin sessions (24hr TTL) | Fast reads from edge, auto-expiry |
Refresh tokens go in D1 because they're long-lived and critical. Access tokens go in KV because they're short-lived and need fast global reads.
- Guest loads
/book/yourname/meeting - Server fetches availability rules from D1
- Server fetches busy times from Google Calendar API (using cached access token from KV)
- Calculates open slots, applies slot limiting if configured
- Returns rendered HTML with calendar UI and available times
- Guest submits booking form (name, email, optional notes)
- Server creates a Google Calendar event with the guest as an attendee
- Google sends the guest a calendar invitation automatically
- Booking is saved to D1
| Operation | Typical Latency |
|---|---|
| D1 query | <10ms |
| Google Calendar API | ~110ms |
| Full page load | <200ms |
Six tables, all using text primary keys and millisecond Unix timestamps.
Calendar owners who receive bookings.
| Column | Type | Notes |
|---|---|---|
| id | TEXT PK | e.g., user_owner_001 |
| username | TEXT UNIQUE | URL slug |
| TEXT | Must match Google account | |
| full_name | TEXT | Display name |
| timezone | TEXT | IANA format, e.g., America/New_York |
| role | TEXT | owner or member |
Bookable meeting types.
| Column | Type | Notes |
|---|---|---|
| id | TEXT PK | |
| user_id | TEXT FK | References users |
| slug | TEXT | URL path, unique per user |
| name | TEXT | Display name |
| description | TEXT | Optional |
| duration_minutes | INTEGER | Default 30 |
| is_active | INTEGER | 0 or 1 |
| days_forward_limit | INTEGER | 1-90, default 14 |
| max_days_per_week | INTEGER | 1-7, nullable |
| max_slots_per_day | INTEGER | 1-20, nullable |
Weekly schedule (when you're available).
| Column | Type | Notes |
|---|---|---|
| id | TEXT PK | |
| user_id | TEXT FK | References users |
| day_of_week | INTEGER | 0=Sunday through 6=Saturday |
| start_time | TEXT | HH:MM format, e.g., 09:00 |
| end_time | TEXT | HH:MM format, e.g., 17:00 |
Confirmed appointments.
| Column | Type | Notes |
|---|---|---|
| id | TEXT PK | UUID |
| user_id | TEXT FK | Calendar owner |
| event_type_id | TEXT FK | Which event type |
| guest_name | TEXT | |
| guest_email | TEXT | |
| guest_notes | TEXT | Optional |
| start_time | INTEGER | Unix ms |
| end_time | INTEGER | Unix ms |
| timezone | TEXT | |
| status | TEXT | confirmed, cancelled, completed |
| google_event_id | TEXT | Links to Google Calendar event |
OAuth refresh tokens for Google Calendar.
| Column | Type | Notes |
|---|---|---|
| user_id | TEXT UNIQUE | One connection per user |
| provider | TEXT | Always google |
| refresh_token | TEXT | Encrypted by Google |
| calendar_id | TEXT | Default primary |
For future multi-user onboarding.
npm run dev # Start dev server on localhost:8787
npm run build # Type-check without emittingnpm test # Run the vitest suite (88 tests)
npm run typecheck # TypeScript type-check (tsc --noEmit)CI (.github/workflows/ci.yml) runs both on every push and pull request.
# Run a migration
npx wrangler d1 execute gcal-scheduler --file=./migrations/FILENAME.sql --local
# Run a query
npx wrangler d1 execute gcal-scheduler --command="SELECT * FROM users" --local# Run a migration
npx wrangler d1 execute gcal-scheduler --file=./migrations/FILENAME.sql --remote
# Check bookings
npx wrangler d1 execute gcal-scheduler --command="SELECT id, guest_name, guest_email, datetime(start_time/1000, 'unixepoch') as starts, status FROM bookings ORDER BY start_time DESC LIMIT 10" --remote
# Check event types
npx wrangler d1 execute gcal-scheduler --command="SELECT slug, name, duration_minutes, is_active, max_slots_per_day, days_forward_limit FROM event_types" --remotenpm run deploy # Deploy worker to Cloudflare
npx wrangler secret put KEY # Set a production secret
npx wrangler tail # Stream live logs from productionWorkers Logs are enabled via [observability] in wrangler.toml, so npx wrangler tail (and the Cloudflare dashboard) show the worker's structured JSON logs — the first place to look when troubleshooting production.
The username in the URL doesn't match any user in the database. Check what's there:
npx wrangler d1 execute gcal-scheduler --command="SELECT username, email FROM users" --remoteThe redirect URI must match exactly between your Google Cloud Console and your worker URL. Common mistakes:
- Trailing slash mismatch (
/admin/auth/callbackvs/admin/auth/callback/) - HTTP vs HTTPS
- Wrong worker subdomain
Your calendar is connected by logging in to the admin dashboard, and you need to do that in each environment separately (local and production):
- Local:
http://localhost:8787/admin - Production:
https://your-worker.workers.dev/admin
The admin dashboard validates your email against the users table. Make sure:
- Your Google account email matches the
emailfield in theuserstable - Your user has
role = 'owner'
Check in order:
- Do you have availability rules? Check the admin dashboard or query:
SELECT * FROM availability_rules - Is the event type active?
SELECT slug, is_active FROM event_types - Is your Google Calendar connected? Log in at
/adminagain - Are all your slots actually busy in Google Calendar?
Wrangler stores local D1 data in .wrangler/. If you deleted that directory, re-run migrations:
npx wrangler d1 execute gcal-scheduler --file=./migrations/0001_initial_schema.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0002_seed_owner.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0003_add_slot_limiting.sql --local
npx wrangler d1 execute gcal-scheduler --file=./migrations/0004_booking_conflict_index.sql --localEdge-Cal fits comfortably within the free tier:
| Resource | Free Limit | Edge-Cal Usage (est.) |
|---|---|---|
| Worker requests | 100,000/day | ~100-500/day typical |
| KV reads | 100,000/day | ~1 per page load |
| KV writes | 1,000/day | ~1 per OAuth refresh |
| D1 storage | 5 GB | <10 MB typical |
| D1 reads | Unlimited | ~3-5 per page load |
| D1 writes | 100,000/day | ~1 per booking |
You'd need thousands of bookings per day to approach any limit.
- Public booking page with calendar UI
- Google Calendar busy time sync
- Google Calendar event creation with attendee invites
- Admin dashboard (event types, availability, slot limiting)
- Session-based admin authentication
- Email confirmations via Gmail API
- Booking cancellation flow
- Custom branding / themes
- SMS reminders
- Analytics dashboard
AGPL-3.0. Free to self-host, fork, and modify. If you run a modified version as a network service, you must make your modified source available to its users under the same license.