open-leagues

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.

docker-compose.yml (abridged)
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.

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

HostClockSetting
Docker / any long-lived boxIn-process tickerOPENLEAGUES_SELF_TICK=1
Vercelvercel.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:

VariableDockerVercelPurpose
DATABASE_URLomitrequiredPostgres. Absent selects PGLite.
PGLITE_DATA_DIRpresetn/aWhere durable PGLite lives.
BETTER_AUTH_URLset itrequiredPublic origin, no trailing slash.
BETTER_AUTH_SECREToptionalrequiredSession signing. Self-generates on the volume.
CRON_SECRETadvisedadvisedGuards the tick endpoint.
OPENLEAGUES_MCP_AUTHoptionaloptionaltoken (default) or proxy: who /api/mcp trusts.
OPENLEAGUES_MODEoptionaloptionalunset = 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_SOURCEoptionaloptionaloff (default), or a list: actionnetwork (consensus, history from 2023), dknetwork (DraftKings' own, current slate), wiseguyteam (multi-book, current slate). Every pulled week is kept.
Agent tokens (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.

headless mint, no browser
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.