Skip to content
vectorfrogPublic

About

Set-it-and-forget-it scheduling page. Your own Calendly alternative on Cloudflare's free tier.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Edge-Cal

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.

Why Edge-Cal?

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.

When you should use Cal.com instead

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.

What You Get

  • 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

How It Works

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

Deploy Your Own (15 minutes)

Prerequisites

You need three things:

  1. Node.js 18+ - nodejs.org
  2. Cloudflare account - Sign up free
  3. Google Cloud project - console.cloud.google.com

Step 1: Clone and Install

git clone https://github.com/vectorfrog/edge-cal
cd edge-cal
npm install

Step 2: Set Up Google OAuth

You need OAuth credentials so Edge-Cal can read your calendar and create events.

2a. Create a Google Cloud Project

  1. Go to Google Cloud Console
  2. Click the project dropdown at the top and select New Project
  3. Name it something like "Edge-Cal" and click Create
  4. Make sure your new project is selected in the dropdown

2b. Enable the APIs

  1. Go to APIs & Services > Library
  2. Search for and enable the Google Calendar API (the only API Edge-Cal needs)

2c. Configure the OAuth Consent Screen

  1. Go to APIs & Services > OAuth consent screen
  2. Select External user type, click Create
  3. Fill in the required fields:
    • App name: Edge-Cal (or whatever you want)
    • User support email: your email
    • Developer contact: your email
  4. Click Save and Continue
  5. On the Scopes page, click Add or Remove Scopes and add:
    • openid
    • email
    • https://www.googleapis.com/auth/calendar.readonly
    • https://www.googleapis.com/auth/calendar.events
  6. 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.

2d. Create OAuth Credentials

  1. Go to APIs & Services > Credentials

  2. Click Create Credentials > OAuth 2.0 Client ID

  3. Select Web application

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

  5. Click Create

  6. Copy the Client ID and Client Secret

2e. Save Your Credentials Locally

Create a .dev.vars file in the project root:

cp .dev.vars.example .dev.vars

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

Step 3: Create Cloudflare Resources

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 login

Create the KV namespace and D1 database:

npx wrangler kv namespace create KV
npx wrangler d1 create gcal-scheduler

Each 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_id is intentionally absent: plain wrangler dev runs against local storage and doesn't need it. If you ever want wrangler 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.

Step 4: Customize Your Profile

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.

Keeping your personal config out of git

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

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

Step 5: Run Locally

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 dev

Open http://localhost:8787 and:

  1. 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 the role = 'owner' user you seeded in migrations/0002_seed_owner.sql.
  2. Visit http://localhost:8787/book/yourname/meeting to see your booking page

Step 6: Deploy to Production

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

Set 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 deploy

Wrangler will output your worker URL, something like:

https://edge-cal.your-subdomain.workers.dev

Post-deployment checklist

  1. 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
    
  2. Log in to the admin dashboard (this also connects your Google Calendar in production):

    https://edge-cal.your-subdomain.workers.dev/admin
    

    Log in with the Google account whose email matches your seeded role = 'owner' user.

  3. Test your booking page:

    https://edge-cal.your-subdomain.workers.dev/book/yourname/meeting
    

Admin Dashboard

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.

What You Can Do

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.

Embedding on Your Website

iframe

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>

Link

Just link to your booking page:

<a href="https://edge-cal.your-subdomain.workers.dev/book/yourname/meeting">
  Book a meeting with me
</a>

Custom Domain

  1. In Cloudflare dashboard, go to Workers & Pages > your worker > Settings > Domains & Routes
  2. Add your custom domain (e.g., cal.yourdomain.com)
  3. Your booking page becomes: https://cal.yourdomain.com/book/yourname/meeting

Project Structure

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

Architecture

Storage

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.

How Booking Works

  1. Guest loads /book/yourname/meeting
  2. Server fetches availability rules from D1
  3. Server fetches busy times from Google Calendar API (using cached access token from KV)
  4. Calculates open slots, applies slot limiting if configured
  5. Returns rendered HTML with calendar UI and available times
  6. Guest submits booking form (name, email, optional notes)
  7. Server creates a Google Calendar event with the guest as an attendee
  8. Google sends the guest a calendar invitation automatically
  9. Booking is saved to D1

Performance

Operation Typical Latency
D1 query <10ms
Google Calendar API ~110ms
Full page load <200ms

Database Schema

Six tables, all using text primary keys and millisecond Unix timestamps.

users

Calendar owners who receive bookings.

Column Type Notes
id TEXT PK e.g., user_owner_001
username TEXT UNIQUE URL slug
email TEXT Must match Google account
full_name TEXT Display name
timezone TEXT IANA format, e.g., America/New_York
role TEXT owner or member

event_types

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

availability_rules

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

bookings

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

calendar_connections

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

invitations

For future multi-user onboarding.


Useful Commands

Local Development

npm run dev                    # Start dev server on localhost:8787
npm run build                  # Type-check without emitting

Running Tests

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

Database (Local)

# 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

Database (Production)

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

Deployment

npm run deploy                 # Deploy worker to Cloudflare
npx wrangler secret put KEY    # Set a production secret
npx wrangler tail              # Stream live logs from production

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


Troubleshooting

"User not found" on booking page

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" --remote

OAuth redirects to wrong URL

The redirect URI must match exactly between your Google Cloud Console and your worker URL. Common mistakes:

  • Trailing slash mismatch (/admin/auth/callback vs /admin/auth/callback/)
  • HTTP vs HTTPS
  • Wrong worker subdomain

"Calendar not connected" when booking

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

Admin login fails

The admin dashboard validates your email against the users table. Make sure:

  1. Your Google account email matches the email field in the users table
  2. Your user has role = 'owner'

Slots not showing up

Check in order:

  1. Do you have availability rules? Check the admin dashboard or query: SELECT * FROM availability_rules
  2. Is the event type active? SELECT slug, is_active FROM event_types
  3. Is your Google Calendar connected? Log in at /admin again
  4. Are all your slots actually busy in Google Calendar?

Local database is empty after restart

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

Cloudflare Free Tier Limits

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


Roadmap

  • 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

License

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.

About

Set-it-and-forget-it scheduling page. Your own Calendly alternative on Cloudflare's free tier.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages