Skip to main content

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.

AppProductionProduction PreviewStaging
Webapplumio.visionlumio.web.prod.zaflun.devlumio.web.staging.zaflun.dev
ID Appid.lumio.visionlumio.id.prod.zaflun.devlumio.id.staging.zaflun.dev
Admin Appadmin.lumio.visionlumio.admin.prod.zaflun.devlumio.admin.staging.zaflun.dev
APIapi.lumio.visionlumio.api.prod.zaflun.devlumio.api.staging.zaflun.dev
Docsdocs.lumio.visionlumio.docs.prod.zaflun.devlumio.docs.staging.zaflun.dev
Overlay external URLoverlay.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:

VariableDescription
DATABASE_URLPostgreSQL connection string
REDIS_URLRedis connection string
AUTH_SECRETNextAuth session secret (ID app)
AUTH_URLID 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:4000 in apps/id/.env.local and apps/admin/.env.local so 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 pathENV var
database.urlLUMIO__DATABASE__URL
auth.token_encryption_keyLUMIO__AUTH__TOKEN_ENCRYPTION_KEY
webhooks.youtube_secretLUMIO__WEBHOOKS__YOUTUBE_SECRET
youtube.innertube_observer.api_key_overrideLUMIO__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 varPurposeDefault
LUMIO__YOUTUBE__GRPC_FALLBACK_ENABLEDEnable gRPC streamList fallback when InnerTube failsfalse
LUMIO__YOUTUBE__REST_FALLBACK_ENABLEDEnable REST polling fallback when InnerTube and gRPC both failfalse

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 varPurposeDefault
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDEEmergency 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_OVERRIDEEmergency pin for the InnerTube client version. Empty by default; set only if auto-detection fails(empty)
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_COLD_BOOTStage-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_BOOTStage-4 cold-boot client version; used only after override/cache/scrape all miss. Absent/empty → compiled DEFAULT_CLIENT_VERSION. Never a pin2.20260731.00.00
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CACHE_TTL_SECONDSMember + tier-badge entry TTL in Redis1209600 (14 d)
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__REFRESH_INTERVAL_SECSHow often the shared refresher re-scrapes youtube.com and warms Redis21600 (6 h)
LUMIO__YOUTUBE__INNERTUBE_OBSERVER__COLD_BOOT_ALERT_WEBHOOK_URLDiscord 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_FAILURESConsecutive cold-boot refresh cycles before alerting; 1 = immediate. 0 treated as 11

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.

VariablePurposeDefault
LUMIO__CHAT__RETENTION_ENFORCEMENT_ENABLEDMaster on/off switch for the retention sweep. Off in default.toml/staging; on in production.tomlfalse (dev) · true (production)
LUMIO__CHAT__RETENTION_SWEEP_INTERVAL_SECSHow often the sweep runs. Ignored when enforcement is off; a sweep also runs once on startup86400 (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.

VariableAllowed valuesDefaultPurpose
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENVdevelopment | staging | productiondevelopmentLogger threshold default + Sentry environment tag
LUMIO_LOG_LEVEL / NEXT_PUBLIC_LUMIO_LOG_LEVELdebug | info | warn | errorper 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:

VariableWherePurpose
NEXT_PUBLIC_SENTRY_DSNBrowser + serverPublic DSN
SENTRY_DSNServer onlyOptional override; falls back to NEXT_PUBLIC_SENTRY_DSN
SENTRY_ORGBuild (CI)Sentry org slug for source-map upload
SENTRY_PROJECTBuild (CI)Sentry project slug
SENTRY_AUTH_TOKENBuild (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.