Self-host
One container with a volume, or a Vercel deploy with managed Postgres. The difference is who drives the league clock.
Docker
The compose file publishes 8080 and keeps PGLite on a named volume. Do not set DATABASE_URL here; the volume is the database.
services:
app:
build: .
ports: ["8080:8080"]
volumes: [open-leagues-data:/data]
environment:
PGLITE_DATA_DIR: /data/pglite
OPENLEAGUES_SELF_TICK: "1"
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:8080}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
CRON_SECRET: ${CRON_SECRET:-}Left blank, BETTER_AUTH_SECRET is generated at /data/better-auth-secret on the volume. Set it for more than one replica.
Set BETTER_AUTH_URL to your public origin, no trailing slash, once the box sits behind a proxy or domain.
Local
PGLite migrates itself on first database access.
bun install cp .env.example .env # optional bun run dev # 0.0.0.0:8080
bun run db:migrate is a no-op without DATABASE_URL: it prints “skipping” and exits.The clock
Scoring advances on a tick. One process should drive it.
| Host | Clock | Setting |
|---|---|---|
| Docker / any long-lived box | In-process ticker | OPENLEAGUES_SELF_TICK=1 |
| Vercel | vercel.json cron, 15 * * * * | leave it unset |
OPENLEAGUES_SELF_TICK on Vercel gives you two clocks. Without CRON_SECRET, /api/league/tick accepts unauthenticated requests and the box logs a warning at boot.Environment
The full list is in .env.example. These decide whether a box boots:
| Variable | Docker | Vercel | Purpose |
|---|---|---|---|
| DATABASE_URL | omit | required | Postgres. Absent selects PGLite. |
| PGLITE_DATA_DIR | preset | n/a | Where durable PGLite lives. |
| BETTER_AUTH_URL | set it | required | Public origin, no trailing slash. |
| BETTER_AUTH_SECRET | optional | required | Session signing. Self-generates on the volume. |
| CRON_SECRET | advised | advised | Guards the tick endpoint. |
| OPENLEAGUES_MCP_AUTH | optional | optional | token (default) or proxy: who /api/mcp trusts. |
| OPENLEAGUES_MODE | optional | optional | unset = substrate (receipts, open data, the lab, public MCP; no accounts or leagues). league = the whole product. Compose and bun run dev set league. |
| OPENLEAGUES_SPLITS_SOURCE | optional | optional | off (default), or a list: actionnetwork (consensus, history from 2023), dknetwork (DraftKings' own, current slate), wiseguyteam (multi-book, current slate). Every pulled week is kept. |
ol_…) are minted in-app and never belong in an env file.Who your box trusts
By default /api/mcp issues and checks its own bearer tokens against its own database. A token minted here works here only.
bun scripts/ledger.mjs mintToken --write --user usr_… --name codex
If you authenticate people at the edge, set OPENLEAGUES_MCP_AUTH=proxy and pass the user id on a header. See bring your own identity.
Build and migrations
bun run build is vite build then db:migrate. The ordering is for Vercel, where migrations run against DATABASE_URL on every deploy.
The Docker image runs vite build at image build time and migrations at container start. With no DATABASE_URL, they skip and PGLite migrates itself.