Self-hosted helper for Google Calendar: when you are busy on one calendar, CalSync mirrors Busy blocks onto the others in your sync group. OAuth refresh tokens and preferences are stored per user in Supabase (Postgres). The app is multi-user—each Google sign-in gets an isolated account unless that identity was already linked (including via “Add another Google account”).
Version: see package.json. Release history: CHANGELOG.md.
- Node.js 20 or newer (LTS recommended)
- A Google Cloud project (Calendar API + OAuth web client)
- A Supabase project for the database
git clone https://github.com/srizon/CalSync.git calsync
cd calsyncUse your fork or mirror URL if different; the last argument is the folder name.
npm install-
In Google Cloud Console, select or create a project.
-
APIs & Services → Library — enable Google Calendar API.
-
APIs & Services → OAuth consent screen — configure the app (External is fine for personal use; add test users while in testing).
-
APIs & Services → Credentials → Create credentials → OAuth client ID — type Web application.
-
Authorized redirect URIs — add your callback URLs (same path for every origin):
- Local:
http://localhost:3000/api/auth/callback(adjust host/port if you change the dev server; setCALSYNC_PUBLIC_URLto match.) - Production:
https://your-domain.com/api/auth/callback
- Local:
-
Copy the Client ID and Client secret.
- In the Supabase SQL Editor, run
supabase/migrations/20260409120000_calsync_multiuser.sql. This createscalsync_users,calsync_identities,calsync_stores, andcalsync_watch_channelswith RLS and no public policies—the app uses the service role on the server only. - Under Project Settings → API, copy the Project URL and the service_role key (keep it secret).
cp .env.example .env.localEdit .env.local using the reference table below and the comments in .env.example. Optional settings (webhook token, auto-sync interval, cron secret) are documented there.
Legacy import: If you are upgrading from a build that used .data/store.json, leave that file in place on first start. When the Supabase database has no users yet, the app imports it for a single user and renames the file to store.json.migrated.
| Variable | When | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
Always | OAuth client ID from Google Cloud |
GOOGLE_CLIENT_SECRET |
Always | OAuth client secret |
SUPABASE_URL |
Always | Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY |
Always | Service role key (server only; never expose to the browser) |
CALSYNC_PUBLIC_URL |
Always | Public base URL with no trailing slash (http://localhost:3000 locally; HTTPS origin in production). Must match what users and OAuth use. |
CALSYNC_SESSION_SECRET |
Production | Long random string; signs the dashboard session cookie |
CALSYNC_ALLOWED_EMAILS |
Optional | Comma-separated Google emails allowed to sign in (useful on the public internet) |
CALSYNC_WEBHOOK_TOKEN |
Optional | If set, Google push requests must send the same value in X-Goog-Channel-Token |
CALSYNC_CRON_SECRET |
Optional | Protects GET /api/cron/renew-watches with Authorization: Bearer <secret> |
CALSYNC_AUTO_SYNC_INTERVAL_SEC |
Optional override | Poll sync every N seconds while the process runs. Defaults to 30 if unset; set 0 to disable polling. |
CALSYNC_SYNC_COALESCE_TIMEOUT_SEC |
Optional | Max seconds a coalesced auto-sync can hold the in-memory per-user lock before stale-lock recovery (default 300) |
Google Calendar push needs a public HTTPS URL. Without it, push-related behavior is skipped (the cron route may report no_https_public_url).
npm run devOpen http://localhost:3000, sign in with Google, then configure calendars on the dashboard.
Default port is 3000. For another port:
npx next dev -p 3001Set CALSYNC_PUBLIC_URL accordingly (e.g. http://localhost:3001).
npm run build
npm run startDefault listen port is 3000; override with PORT (e.g. PORT=8080 npm run start). Ensure CALSYNC_PUBLIC_URL matches the URL users and Google OAuth use (HTTPS in production).
For a VPS, homelab host, or similar always-on deployment:
Compute: Node.js 20 LTS or newer. CalSync is mostly I/O to Google; a small VM (about 1 vCPU, 512 MB–1 GB RAM) is often enough.
Data: Supabase holds tokens, sync selection, and push metadata—use backups and the same SUPABASE_* values in production. After a successful legacy migration, .data/ is optional; .data/store.json is only read once then renamed.
HTTPS and reverse proxy: Terminate TLS at Caddy, nginx, Traefik, or your platform LB; proxy to http://127.0.0.1:<PORT> (match PORT for npm run start). Forward Host, X-Forwarded-Proto, and X-Forwarded-For so redirects align with CALSYNC_PUBLIC_URL.
Cron (push renewal): Channels expire about weekly. Schedule a daily HTTPS request:
curl -fsS -H "Authorization: Bearer YOUR_CALSYNC_CRON_SECRET" \
"https://your-domain.com/api/cron/renew-watches"Set CALSYNC_CRON_SECRET to match. Environment variables are summarized in Environment variables.
Process supervision: Run npm run start under systemd, PM2, or equivalent. Example systemd unit (adjust paths and user):
[Unit]
Description=CalSync Next.js
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/calsync
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/opt/calsync/.env.local
ExecStart=/usr/bin/npm run start
Restart=on-failure
[Install]
WantedBy=multi-user.targetEnsure node/npm are on PATH for the service user, or use full paths in ExecStart.
- Upcoming events — Next 7 days, This month (now through the end of the current calendar month), or Next month (full following calendar month), using your browser’s local timezone, for calendars in your saved sync group. Shows schedule, free transparency, optional Google Meet/Zoom/FaceTime links, and a link to Google Calendar. RSVP — when Google returns attendee data for an event, you can accept, tentatively accept, or decline from the row (updates sync to Calendar). Overlapping busy intervals show a Conflict badge (self-declined RSVPs excluded). Declined events toggles invitations you declined. The list refreshes in the background about every minute while visible, and after sync or clear-mirrors.
- Sync setup — Connected Google accounts (add/remove, disconnect all). Calendars in sync group — choose at least two writable calendars; grouped by account with the primary calendar first. Add calendar creates a new calendar or adds by ID. Save selection, then Run sync now, or rely on push plus built-in polling (defaults to 30s; configurable via
CALSYNC_AUTO_SYNC_INTERVAL_SEC). Last sync shows mirror counts and skip reasons.
Tokens and preferences live in Supabase; secure and back up that database.
API (session-authenticated): GET /api/events?timeMin=<ISO>&timeMax=<ISO> (both required together; max span about 40 days), or legacy GET /api/events?days=30 (1–30 rolling window from server time). Response includes timeMin / timeMax echo. POST /api/sync, POST /api/calendars/clear-mirrors with { "calendarId": "<id>" }, POST /api/events/rsvp with { "calendarId": "<id>", "eventId": "<id>", "responseStatus": "accepted" | "tentative" | "declined" }. Event objects can include declinedBySelf and selfResponseStatus.
| Command | Purpose |
|---|---|
npm run dev |
Development server |
npm run dev:webpack |
Development server (Webpack) |
npm run build |
Production build |
npm run build:webpack |
Production build (Webpack) |
npm run start |
Production server |
npm run lint |
ESLint |
Next.js 16 (App Router), React 19, Tailwind CSS 4, Supabase (Postgres), and the Google Calendar API via @googleapis/calendar and google-auth-library.