Skip to main content
Photo of Anton ShubinAnton ShubinSenior Full-Stack Engineer & Tech Lead

Self-hosting

mig: a lightweight meeting scheduler because Calendly alternatives are overkill

One owner, one URL, one feature: book a time slot. A single Deno binary, JSON-file storage, SMTP confirmations with ICS attachments, cancellable links signed with SHA-256 HMAC. Built because I needed a static meeting link, not a database.

Anton Shubin7 min read

The tool: migOpen the booking pageCode: github.com/spy4x/mig (opens in a new tab)

TL;DR

  • mig is a meeting scheduler for one owner and one URL: a single Deno binary, bookings in a JSON file, confirmations over SMTP with an ICS attachment.
  • It leaves out accounts, calendar sync, payments, round-robin and a database on purpose; if you need those, use Cal.com.
  • Cancel links need no database: each link carries a random token and its HMAC signature.
  • Confirmation emails show the time in both the host's and the visitor's time zone, with Intl and no date library.

Most "Calendly alternatives" are heavyweight. Cal.com is Next.js + Postgres

  • Redis. CloudMeet is Cloudflare + D1 + OAuth. Both are the right answer for a team scheduler with payments, round-robin, and calendar sync.

Neither is the right answer if you are a solo operator with one meeting type, one URL, and zero interest in running a database to send an email.

mig is the lightweight scheduler I built for myself. It runs as a single Deno binary, stores bookings in a JSON file, sends confirmations over SMTP, and signs cancellable links with SHA-256 HMAC. No DB, no admin UI, no Tailwind cluster.

It runs my own booking page; how I run production shows how it fits in.

┌──────────┐    ┌──────────┐    ┌──────────┐
│ Visitor  │───>│   mig    │───>│  Owner   │
│ (anyone) │    │ (single) │    │  (you)   │
└──────────┘    └──────────┘    └──────────┘
                     │
                     ▼
              ┌─────────────┐
              │ JSON file + │
              │ SMTP + ICS  │
              └─────────────┘

The visitor lands on your URL, picks a slot, fills in their email and name. Mig emails both of them a confirmation with an ICS attachment. Both get a cancellable link signed with HMAC-SHA-256 of a random token. That's it.

Configuration is env vars. No admin UI to maintain.

docker run -d --name mig \
  -p 8080:8080 \
  -v ./data:/app/data \
  -e HOST_NAME="Anton Shubin" \
  -e HOST_EMAIL="anton@antonshubin.com" \
  -e HOST_TZ="Asia/Ho_Chi_Minh" \
  -e MEETING_URL="https://meet.antonshubin.com" \
  -e WEEKLY_AVAILABILITY="MON-FRI 09:00-17:00" \
  -e SLOT_DURATION_MIN=30 \
  -e MIN_NOTICE_HOURS=6 \
  -e SMTP_HOST=smtp.example.com \
  -e SMTP_USER=anton@example.com \
  -e SMTP_PASS=<REDACTED:SMTP_PASS> \
  -e CANCEL_SECRET=$(openssl rand -base64 32) \
  ghcr.io/spy4x/mig:latest

WEEKLY_AVAILABILITY supports ranges (MON-FRI 09:00-17:00), split shifts (MON-FRI 09:00-12:00,MON-FRI 14:00-18:00), and per-day overrides. There is a BLOCKED_DATES env var for holidays.

This is the design constraint that keeps mig lightweight:

  • No user accounts. The owner is whoever holds HOST_EMAIL. Visitors do not log in.
  • No calendar sync. Mig emits ICS attachments. The visitor's mail client handles calendar sync. The owner's mail client handles calendar sync. Google Calendar / iCal / Outlook all understand ICS.
  • No payments. If you need to charge for a meeting, use Stripe Checkout before the mig link.
  • No round-robin. One owner. One URL.
  • No database. Bookings live in data/bookings.json with atomic write (temp file + rename). Mutex-serialised in-memory.
  • No team admin UI. Configuration is env vars. Rotate SMTP_PASS by restarting the container.

If any of these constraints are wrong for your use case, you need a different tool. Use cal.com.

The interesting design decision was cancellable links. Most schedulers store a cancellation token in the database and look it up on cancel. Mig has no database. So the cancellation link is self-contained: it contains a SHA-256 HMAC of a random token, signed with CANCEL_SECRET.

// Sign: embed token + signature in the URL
const token = randomBase64(32);
const sig = hmac(CANCEL_SECRET, token);
const url = `${PUBLIC_URL}/cancel?t=${token}&s=${sig}`;

// Verify: recompute and compare
if (!timingSafeEqual(sig, hmac(CANCEL_SECRET, token))) return 403;

Stateless. The link itself is the record. Rotate CANCEL_SECRET once a year to invalidate all old links.

Mig's TZ handling is the part I am most pleased with. The owner sets HOST_TZ. The visitor's browser sends Intl.DateTimeFormat().resolvedOptions().timeZone in the request body. The confirmation email renders the time in both timezones — host and visitor — because the worst outcome for a scheduling tool is showing the wrong time.

When: Tuesday, 9 September 2026
        10:00 ICT (UTC+7)   — host
        03:00 BST (UTC+1)   — your time

No date library, no Luxon, no moment. Intl.DateTimeFormat does the heavy lifting and ships with the runtime.

Source: github.com/spy4x/mig. The Docker image is at ghcr.io/spy4x/mig:latest. The README has the full env var reference.

I use mig for my own booking link at meet.antonshubin.com. It also exposes an iframe-friendly page at /embed if you want to inline the scheduler on another site. If you want to talk through whether you need a custom build, book a free 30-minute call on /book.

Need this for your product?

Anton Shubin

Senior Full-Stack Engineer & Tech Lead

I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.

Get the next post by email

New posts on decisions for founders, AI and MCP, and self-hosting, about once a week at most, and only when there is one. Unsubscribe with one click.

Or follow the RSS feed