Installation
Set up Lumio using Docker Compose.
Hosted Environments
Lumio runs three environments per app. Production lives on lumio.vision; non-production on zaflun.dev. Use these strings when configuring OAuth callbacks, CORS, env-var defaults and CI deploy targets.
| App | Production | Production Preview | Staging |
|---|---|---|---|
| Webapp | lumio.vision | lumio.web.prod.zaflun.dev | lumio.web.staging.zaflun.dev |
| ID App | id.lumio.vision | lumio.id.prod.zaflun.dev | lumio.id.staging.zaflun.dev |
| Admin App | admin.lumio.vision | lumio.admin.prod.zaflun.dev | lumio.admin.staging.zaflun.dev |
| API | api.lumio.vision | lumio.api.prod.zaflun.dev | lumio.api.staging.zaflun.dev |
| Docs | docs.lumio.vision | lumio.docs.prod.zaflun.dev | lumio.docs.staging.zaflun.dev |
| Overlay external URL | overlay.lumio.vision | (single per-deployment external host; configurable via NEXT_PUBLIC_OVERLAY_URL on the webapp) | (same) |
Branch mapping: next → staging · main → production preview · tag 20* → production.
The rest of this guide covers a local development setup using Docker Compose.
Prerequisites
- Docker and Docker Compose
- A PostgreSQL 18 instance (or use the provided compose file)
- Redis 8
Docker Setup
# Start the dev stack (PostgreSQL, TimescaleDB, Redis)
docker compose -f dev-stack/db.docker-compose.yml up -d
# Start the web app
docker compose -f dev-stack/web.docker-compose.yml up -d
Bazel (CI Only)
Bazel handles CI builds and container image creation. It is not required for local development -- Cargo covers that. Install Bazelisk, which automatically downloads the correct Bazel version:
# Install Bazelisk (Bazel version manager)
npm install -g @bazel/bazelisk
# Or on macOS:
brew install bazelisk
# Verify
bazel --version
See the Bazel Build System guide for details on the build architecture, vendored crates, and image targets.
Environment Configuration
Copy .env.example to .env and configure:
cp .env.example .env
Key variables:
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string |
REDIS_URL | Redis connection string |
AUTH_SECRET | NextAuth session secret (ID app) |
AUTH_URL | ID app base URL |
NEXT_PUBLIC_WEB_URL
Absolute URL of the apps/web app. Used by apps/id and apps/admin to build absolute links back to the canonical /legal/* pages in the cookie-consent banner footer.
- Default in code:
https://lumio.vision(production) - Staging:
https://lumio.web.staging.zaflun.dev(set via Cloudflare Pages env settings) - Production-preview:
https://lumio.web.prod.zaflun.dev - Local development: put
NEXT_PUBLIC_WEB_URL=http://localhost:4000inapps/id/.env.localandapps/admin/.env.localso banner links resolve to your local web app instead of production.
apps/web itself does NOT need this variable — it uses relative /legal/* paths because it hosts the pages.
Environment Variable Naming
Every TOML key in apps/api/config/*.toml is overridable via an environment variable using the format LUMIO__<SECTION>__<KEY> — double underscore between the prefix and the first segment, and double underscore between every nested segment. Examples:
| TOML path | ENV var |
|---|---|
database.url | LUMIO__DATABASE__URL |
auth.token_encryption_key | LUMIO__AUTH__TOKEN_ENCRYPTION_KEY |
webhooks.youtube_secret | LUMIO__WEBHOOKS__YOUTUBE_SECRET |
youtube.innertube_observer.api_key_override | LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDE |
The override priority is default.toml < {env}.toml < local.toml < ENV vars, so an env-var always wins.
YouTube Chat Transport
YouTube chat uses InnerTube as the primary transport (zero quota cost). gRPC and REST fallbacks are available but disabled by default:
| ENV var | Purpose | Default |
|---|---|---|
LUMIO__YOUTUBE__GRPC_FALLBACK_ENABLED | Enable gRPC streamList fallback when InnerTube fails | false |
LUMIO__YOUTUBE__REST_FALLBACK_ENABLED | Enable REST polling fallback when InnerTube and gRPC both fail | false |
YouTube InnerTube Settings
The InnerTube chat poller ships with sensible defaults — no configuration is required. For operational overrides, these ENV vars map onto the [youtube.innertube_observer] block in apps/api/config/default.toml:
The InnerTube API key and client version are resolved at runtime in this order: override (if set) → Redis cache → fresh youtube.com scrape → cold-boot constant. The scraped values are cached in Redis (keys lumio:yt:innertube_key / lumio:yt:innertube_version, 24 h TTL) and a shared leader-elected refresher re-scrapes every 6 h by default (REFRESH_INTERVAL_SECS), so YouTube's periodic rotations are picked up without a restart. The two *_OVERRIDE vars below are an emergency pin only — leave them empty so auto-rotation stays in charge; a non-empty value wins over every other source, so it must never carry the ordinary current value. If an override is set, the API logs a single WARN the first time the credential is resolved — innertube: rotation disabled by pin (client_version_override=…) - unset to resume auto-rotation — so a forgotten pin never silently disables rotation (the API key value is redacted in that log line).
The *_COLD_BOOT vars are different: they configure the last-resort value used only when override, cache, and scrape have all come up empty (Stage 4). Because they are consulted last they can never pin the credential or disable auto-rotation — they are a fallback seed, not an override. default.toml ships them set to the current cold-boot values so a fresh deployment starts from a known-good credential; the daily drift-check keeps them in lockstep with the compiled constants (DEFAULT_INNERTUBE_API_KEY / DEFAULT_CLIENT_VERSION), which remain the last-ditch fallback if a field is cleared. Clear one (or set an empty ENV var) to fall back to the compiled constant, or set one to a newer value to give a fresh deployment a better cold-start credential without recompiling.
Reaching Stage 4 (cold boot) means Redis was empty and the youtube.com scrape failed — a fault, not a normal state. Point COLD_BOOT_ALERT_WEBHOOK_URL at the same Discord channel the InnerTube health CI already posts to (do not create a second channel) and the refresher fires a single alert per incident. COLD_BOOT_ALERT_AFTER_FAILURES (default 1 = immediate) debounces it: raise it to require that many consecutive 6 h cycles at cold boot before alerting. Alerting is fail-open — an unset or unreachable webhook only produces a WARN log and never touches the chat path.
| ENV var | Purpose | Default |
|---|---|---|
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDE | Emergency pin for the InnerTube API key. Empty by default; set only if auto-detection fails after a Google rotation | (empty) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CLIENT_VERSION_OVERRIDE | Emergency pin for the InnerTube client version. Empty by default; set only if auto-detection fails | (empty) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_COLD_BOOT | Stage-4 cold-boot API key; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_INNERTUBE_API_KEY. Never a pin | (current key, shipped in default.toml) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CLIENT_VERSION_COLD_BOOT | Stage-4 cold-boot client version; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_CLIENT_VERSION. Never a pin | 2.20260731.00.00 |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CACHE_TTL_SECONDS | Member + tier-badge entry TTL in Redis | 1209600 (14 d) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__REFRESH_INTERVAL_SECS | How often the shared refresher re-scrapes youtube.com and warms Redis | 21600 (6 h) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__COLD_BOOT_ALERT_WEBHOOK_URL | Discord webhook for the InnerTube health channel; alert on a Stage-4 cold-boot fall-through. Empty → alerting disabled (still WARN-logged). Reuse the CI channel, not a new one | (empty) |
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__COLD_BOOT_ALERT_AFTER_FAILURES | Consecutive cold-boot refresh cycles before alerting; 1 = immediate. 0 treated as 1 | 1 |
api_key_override, api_key_cold_boot, and cold_boot_alert_webhook_url are all wrapped in a SensitiveString and never appear in logs or tracing output.
Chat Retention
plans.chat_retention_days is enforced by a background sweep that hard-deletes platform_chat_messages older than each account's plan window (see Chat → Retention). It is gated so dev/test/staging environments keep their history and are not purged too aggressively.
| Variable | Purpose | Default |
|---|---|---|
LUMIO__CHAT__RETENTION_ENFORCEMENT_ENABLED | Master on/off switch for the retention sweep. Off in default.toml/staging; on in production.toml | false (dev) · true (production) |
LUMIO__CHAT__RETENTION_SWEEP_INTERVAL_SECS | How often the sweep runs. Ignored when enforcement is off; a sweep also runs once on startup | 86400 (24 h) |
Frontend Environment & Logging
apps/web, apps/admin, and apps/id share two env-vars that drive the logger and the Sentry integration. Set both the server-side and the NEXT_PUBLIC_* mirror so the browser bundle picks them up too.
| Variable | Allowed values | Default | Purpose |
|---|---|---|---|
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENV | development | staging | production | development | Logger threshold default + Sentry environment tag |
LUMIO_LOG_LEVEL / NEXT_PUBLIC_LUMIO_LOG_LEVEL | debug | info | warn | error | per env (debug/info/warn) | Override the auto-threshold |
Sentry env-vars per app — leave blank locally to disable, fill them in CI / Cloudflare Pages env for staging + production:
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_SENTRY_DSN | Browser + server | Public DSN |
SENTRY_DSN | Server only | Optional override; falls back to NEXT_PUBLIC_SENTRY_DSN |
SENTRY_ORG | Build (CI) | Sentry org slug for source-map upload |
SENTRY_PROJECT | Build (CI) | Sentry project slug |
SENTRY_AUTH_TOKEN | Build (CI) | Auth token for source-map upload |
Each app also has a proxy.ts (Next.js 16's renamed middleware.ts) that prints one structured line per HTTP request to the SSR terminal — including the real client IP (x-real-ip / x-forwarded-for / cf-connecting-ip) with IPv4/IPv6 family classification. See Logging → Per-request access logging for details.
Verify Installation
Visit http://localhost:4000 to access the Lumio dashboard.