Ditch the group chat chaos. A scheduling tool for tabletop gamers, hosted for the community or self-hosted on your own server.
Live on tabletoptime.us right now:
TabletopTime helps your gaming group find the best time to meet. A host proposes time slots, everyone votes without making an account, and the host finalizes a slot. The hosted version runs at tabletoptime.us. The same code ships as a Docker image for home servers (Synology, Unraid, Raspberry Pi) and integrates with Telegram and Discord for real-time coordination.
- Host: Create, edit, and delete time slots dynamically. Manage quorum rules (min players) and capacity limits (max players). Remove accepted participants to trigger waitlist auto-promotion.
- Vote & Suggest: No login required. Simple "Yes", "If Needed", or "No" voting. Attendees can also suggest new time slots if none work.
- Waitlist: Automatic waitlist management with First-Come-First-Serve promotion when spots open up. "If Needed" players are seated automatically only when the table is below its minimum, "Yes" voters whenever a seat is open; a waitlisted player can switch to "Yes" to claim an open seat.
- Finalize: Select a host/location and generate calendar invites (.ics / Google Calendar). Once finalized, slot management is locked.
- Telegram / Discord Bot:
- Pins a live-updating dashboard in your group chat or channel.
- Posts slot changes, availability updates, the finalized result, and cancellations.
- Sends voting and session reminders when the organizer turns them on.
- DMs the organizer a login link if they lose the manage page, and DMs players about waitlist promotions and final results (each person can turn bot DMs off).
- My Events & Cross-Device Sync: No account, but sign in through the Telegram bot or with Discord and your voting history follows you anywhere. The profile page shows a per-event sync badge (linked, or "This Device Only"), and you can link or unlink any event you've voted on straight from that badge.
- Privacy & Security:
- Zero Tracking: We do not use Google Analytics, Facebook Pixels, or any third-party trackers.
- Hashed admin tokens: Event admin tokens are hashed (SHA-256) before storage. A token from an older release that is still stored in plaintext is rewritten as its hash the first time it is used.
- Signed identity cookies: Platform identities (Telegram, Discord) are stored in signed cookies, and a bare platform ID is never trusted as a login.
- Self-Hostable: Your data stays on your server (SQLite). We capture as little information as possible.
- Persistence: We use cookies only to restore your session, never to track you.
The easiest way to run TabletopTime is the pre-built Docker image.
mkdir -p data && sudo chown 1000:1000 data
docker run -d \
-p 3000:3000 \
-v ./data:/app/data \
-e DATABASE_URL="file:/app/data/scheduler.db" \
--name tabletop-time \
ghcr.io/mels0n/tabletop_scheduler:latestOpen http://localhost:3000 to start creating events.
Upgrading is automatic. Run docker pull ghcr.io/mels0n/tabletop_scheduler:latest and restart the container; on start it brings your database up to date from whatever release you were on, keeping your data.
Check these settings when you move to a new release with a bot connected:
- Set
NEXT_PUBLIC_BASE_URL. Every link a Telegram or Discord bot sends (logins, events, reminders) is built from it. Without it the app still starts, but it logs an error and bot links fail until you set it. Use an address your players can open; a LAN address works behind NAT. - Polling is the self-host default. With a Telegram token and no
TELEGRAM_MODE, a self-hosted install polls Telegram, which needs no public URL. - Set
TELEGRAM_MODE=webhookonly with a public URL. If your install used webhook mode before, keep it by settingTELEGRAM_MODE=webhook;NEXT_PUBLIC_BASE_URLmust then be a public HTTPS address Telegram can reach. If the log says another consumer owns the bot, a webhook is still registered: setTELEGRAM_MODE=webhookor delete the webhook.
Bind mounts and UID 1000. The container runs as the node user (UID 1000). A bind-mounted ./data directory must be writable by UID 1000, or the database cannot be created and the container exits at startup. A named Docker volume needs no extra step.
Secrets. If SESSION_SECRET or CRON_SECRET is unset, the container generates a random value on first start and keeps it in /app/data (.session-secret, .cron-secret), so they survive restarts as long as the data volume does. Set them explicitly if you run more than one instance against the same data.
How the schema is applied (db push contract). The Docker image uses SQLite and applies the schema on every start with prisma db push. There is no migration history for self-hosters: the schema in the image is the schema you get. Releases only ever add to the schema, and CI checks that every past release upgrades without losing data. If startup ever stops with a data-loss warning (a database edited by hand, for example), it has deleted nothing: back up /app/data/scheduler.db, then start once with PRISMA_ACCEPT_DATA_LOSS=1 to accept the change, and remove the variable afterwards.
All configuration is through environment variables, validated once at boot. A missing or invalid value stops the server with a message that lists every problem. See EnvVariables.md for the full reference.
The variables most installs touch:
| Variable | When you need it |
|---|---|
DATABASE_URL |
Always. SQLite path for Docker (file:/app/data/scheduler.db), pooled Postgres URL when hosted. |
DIRECT_URL |
Hosted only. Direct Postgres URL for migrations. |
NEXT_PUBLIC_BASE_URL |
Whenever a bot token is set. Every link the bots send is built from it. |
SESSION_SECRET |
Production. Docker generates one if unset. |
CRON_SECRET |
Hosted or Vercel production. Docker generates one if unset. |
TELEGRAM_BOT_TOKEN |
To enable the Telegram bot. |
TELEGRAM_MODE |
Optional. polling, webhook or off; derived when unset. |
DISCORD_BOT_TOKEN, DISCORD_APP_ID, DISCORD_CLIENT_SECRET |
To enable the Discord bot. |
LOG_LEVEL |
Optional. debug, info (default), warn or error. |
Retention windows, vote announcement cooldown, private webhook sources, Ko-fi, recovery flags and every default are in EnvVariables.md.
- Telegram Setup Guide: How to create a Telegram bot and connect an event.
- Discord Setup Guide: How to create a Discord bot and enable OAuth.
- Understanding Magic Links: How passwordless recovery and login works.
- Environment Variables: Detailed configuration options.
- Privacy & Architecture: How the hosted and self-hosted builds differ.
- API Reference: Every HTTP route, its auth, and its request and response shapes.
- Architecture Decisions: Why the stack and the dual database target look the way they do.
- User Guide: Comprehensive usage guide.
- Contributing: Guide for developers wanting to help out.
- Reverse Proxy Example: Nginx configuration for exposing to the web.
Requirements: Node 22 and npm.
git clone https://github.com/mels0n/tabletop_scheduler.git
cd tabletop_scheduler
npm ci
cp .env.example .env # DATABASE_URL="file:./dev.db" is enough to start
npx prisma db push # creates prisma/dev.db from prisma/schema.prisma
npm run devPosts live in content/blog/*.md. A build publishes only the posts that are live on the build date: draft: true never publishes, and a post dated after the build date waits for a later build. npm run dev, npm run build, npm run typecheck, npm run lint, npm test and npm run depcruise first run scripts/generate-published-posts.mjs, which writes shared/data/published-posts.generated.ts (gitignored). The site reads posts only from that module, through shared/lib/blog.ts.
SHOW_SCHEDULED=1 npm run dev # also show future-dated posts
SHOW_DRAFTS=1 SHOW_SCHEDULED=1 npm run dev # also show drafts
BUILD_DATE=2026-12-01 npm run build # build as if it were that date (UTC, YYYY-MM-DD)The preview flags apply only to npm run dev; production builds, CI and any run with NODE_ENV=production ignore them. Restart the dev server after editing a post or changing a flag, because the module is generated once at startup. npm run blog:queue lists live, scheduled and draft posts.
Checks to run before opening a pull request:
npm run typecheck
npm run lint
npm test
npm run depcruise
npm run buildThe app has two Prisma targets from one codebase: prisma/schema.prisma (SQLite, self-host and local development) and prisma/hosted/schema.prisma (Postgres, hosted). A schema change edits both files and adds a migration under prisma/hosted/migrations/. See CONTRIBUTING.md and HostedMaintenance.md.
The hosted version runs on Vercel with a Supabase Postgres database.
- Build and migrations.
vercel.jsonpoints the build atscripts/vercel-build.sh, which generates the Prisma client from the hosted schema, runsprisma migrate deployand pending data migrations on production builds only, then builds the app. A failed migration fails the deploy. Preview builds never migrate. - CI gates. Pull requests and pushes run typecheck, lint, tests, a production build, the dependency-direction check, the self-host upgrade check, and a check that the hosted migration history matches
prisma/hosted/schema.prisma. The Docker image is published only after those pass.mainis protected, so every change lands through a pull request that passes these checks. - Fortnightly rebuild. A GitHub Actions workflow calls a Vercel deploy hook every other Thursday so scheduled blog posts go live. It pushes no commits.
- Required settings.
DATABASE_URL(pooled),DIRECT_URL,NEXT_PUBLIC_IS_HOSTED=true,NEXT_PUBLIC_BASE_URL,SESSION_SECRET, andCRON_SECRET. - Scheduled jobs. Event cleanup runs daily from Vercel Cron. Reminders and the outbound webhook queue run from Supabase
pg_cron, with a GitHub Actions reminder run every two hours as a backstop; the one-time setup is in HostedMaintenance.md.
The hosted version is free to use. If TabletopTime saves your group some chaos, you can help with the hosting bill on Ko-fi.
CC-BY-NC-SA 4.0 (see LICENSE).