Installation
Set up a local Lumio development environment.
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 |
| Stats App | stats.lumio.vision | lumio.stats.prod.zaflun.dev | lumio.stats.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 |
| Developer Docs | developers.lumio.vision | lumio.developer-docs.prod.zaflun.dev | lumio.developer-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) |
| Widget external URL | widget.lumio.vision | (single per-deployment external host; configurable via LUMIO__WIDGET__PUBLIC_URL on the API) | (same) |
Branch mapping: next → staging · main → production preview · tag 20* → production. This mapping is enforced in CI for the Cloudflare-hosted docs family (docs, developer-docs, extension-apps). The core product containers (API, web, admin, id, workers, bots) are container images promoted by release channel instead — beta for a pre-release, latest for a full release.
The rest of this guide covers a local development setup.
Prerequisites
| Tool | Version / notes |
|---|---|
| Docker + Docker Compose | Runs the local database stack |
Rust toolchain (rustup) | Edition 2024. CI builds on stable; the Bazel toolchain pins 1.97.1 |
protoc | Required — lo-youtube-api's build script compiles the YouTube gRPC protos |
| Node.js | >= 18 |
| pnpm | 10.28.0 (see packageManager in the root package.json) |
just | Task runner used by every command in these docs |
Bazel is optional locally — it is the CI build system (see Bazel below).
Dev Stack
just stack-up starts the local database + broker stack, merging dev-stack/db.docker-compose.yml and dev-stack/broker.docker-compose.yml:
just stack-up
| Service | Image | Host port | Database |
|---|---|---|---|
lumio-postgres | postgres:18-alpine | 127.0.0.1:5432 | lumio (user/password lumio) |
lumio-tsdb | timescale/timescaledb-ha:pg18 | 127.0.0.1:5433 | lumio_tsdb (user/password lumio) |
lumio-extensions-postgres | postgres:18-alpine | 127.0.0.1:5434 | lumio_extensions (user/password lumio_ext) |
lumio-redis | redis:8-alpine | 127.0.0.1:6379 | password lumio |
lumio-rabbitmq | rabbitmq:4-management-alpine | 127.0.0.1:5672 (AMQP), 127.0.0.1:15672 (UI) | user/password lumio |
lumio-kafka | apache/kafka:3.9.0 | 127.0.0.1:9092 | KRaft mode (no ZooKeeper) |
The brokers back the ingest pipeline: RabbitMQ is the job/command bus and Kafka is the durable ingest firehose (Redis stays cache-only, TimescaleDB is the system of record). Dev and staging run single-node; only production is the multi-node cluster.
The stack runs in the foreground, so use a dedicated terminal. Related recipes:
| Recipe | What it does |
|---|---|
just stack-down | Stop the stack (volumes kept) |
just stack-reset | Stop the stack, drop the volumes, start again |
just stack-logs | Tail the stack logs |
just mail-up | MailDev on http://localhost:1080 |
just minio-up | MinIO console on http://localhost:9001 |
just stack-all | stack-up + mail-up + minio-up |
just psql / just tsdb-psql / just redis-cli | Open a CLI against the running containers |
just rabbitmq-ui | Print the RabbitMQ management UI URL (http://127.0.0.1:15672, lumio/lumio) |
just kafka-topics | List Kafka topics on the local KRaft broker |
Database Migrations
Migrations live in apps/api/migrations/ (main database) and apps/api/tsdb_migrations/ (TimescaleDB) and are run by the migrate binary:
just migrate # run all pending migrations on both databases
just migrate-status # show what has been applied
just migrate-main # main database only
just migrate-tsdb # TimescaleDB only
just db-fresh # drop, create, migrate
The API also runs pending migrations on startup.
sqlx::migrate!() embeds the .sql files into the binary when it is compiled. Adding a new migration file therefore requires a rebuild — cargo build -p lumio-api (or just build-api) — not just a restart. A restart alone will silently run the old migration set.
New migrations use the reversible .up.sql / .down.sql pair format. Legacy migrations from before 20260410 use the simple single-file format and are never renamed.
Running the Apps
Each app has its own recipe and its own port:
| Recipe | App | URL |
|---|---|---|
just run-api (or just dev-api for auto-reload, which needs cargo install cargo-watch) | Rust API | http://localhost:3000 |
just dev-web | Webapp / dashboard | http://localhost:4000 |
just dev-admin | Admin panel | http://localhost:4001 |
just dev-id | ID / auth app | http://localhost:4002 |
just dev-stats | Public stats app | http://localhost:4003 |
just dev-docs | This documentation site | Docusaurus default port — pass --port to avoid clashing with the API on 3000 |
just dev-developer-docs | Developer documentation | http://localhost:3004 |
just dev-supervisor | Extension supervisor (static) | http://localhost:3200 |
just dev-runtime | Extension runtime (static) | http://localhost:3201 |
The bots have matching recipes: just run-twitch-bot, just run-youtube-bot, just run-kick-bot, just run-trovo-bot, just run-discord-bot (and a dev-* variant of each).
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
Configuration is split by runtime. There is no repository-root .env — each app is configured on its own.
Rust services — layered TOML
The API and the bots read layered TOML from their own config/ directory:
config/default.toml → config/{env}.toml → config/local.toml → ENV vars
Later layers win, so an environment variable always overrides a file. config/default.toml is committed and holds every key with a documented default; config/local.toml is git-ignored and is where you put local secrets. The {env} layer is selected by APP_ENV for the API (development — the default — staging, or production), and by {SERVICE}_RUN_MODE for the bots (e.g. TWITCH_BOT_RUN_MODE).
Create apps/api/config/local.toml with at least your OAuth login credentials and a token encryption key (see OAuth Provider Setup). The database and Redis defaults in default.toml already match the dev stack:
| Config key | Default |
|---|---|
server.port | 3000 |
database.url | postgres://lumio:lumio@localhost:5432/lumio |
timescale.url | postgres://lumio:lumio@localhost:5433/lumio_tsdb |
extensions.database.url | postgres://lumio_ext:lumio_ext@localhost:5434/lumio_extensions |
redis.url | redis://:lumio@127.0.0.1:6379 |
web.public_url | http://localhost:4000 |
admin.public_url | http://localhost:4001 |
overlay.public_url | http://localhost:4000/overlay |
widget.public_url | http://localhost:4000/widget |
Required public URLs for hosted API deployments
The API uses [web] public_url and [admin] public_url to mint OAuth redirect URIs and to validate the admin return target for global bot OAuth. apps/api/config/staging.toml and apps/api/config/production.toml do not pin those sections, so hosted deployments must supply them through environment variables:
| Environment | LUMIO__WEB__PUBLIC_URL | LUMIO__ADMIN__PUBLIC_URL |
|---|---|---|
| Production | https://lumio.vision | https://admin.lumio.vision |
| Production preview | https://lumio.web.prod.zaflun.dev | https://lumio.admin.prod.zaflun.dev |
| Staging | https://lumio.web.staging.zaflun.dev | https://lumio.admin.staging.zaflun.dev |
If LUMIO__WEB__PUBLIC_URL is missing outside local development, system-sourced channel and bot OAuth flows build provider redirect URIs with the default http://localhost:4000 host. Twitch, Kick, Trovo and YouTube reject that redirect URI in hosted environments. If LUMIO__ADMIN__PUBLIC_URL is missing, the admin global-bot flow stores http://localhost:4001/providers as its return target; the unified callback allowlist then cannot return the browser to the hosted admin app.
DATABASE_URL is not an API settingThe API reads its connection strings from the TOML layers (or LUMIO__DATABASE__URL). A bare DATABASE_URL is only used by the apps/api integration-test harness, which needs a PostgreSQL server it can CREATE DATABASE on:
DATABASE_URL=postgres://lumio:lumio@127.0.0.1:5432/lumio cargo test -p lumio-api.
Next.js apps — .env.local
apps/web, apps/admin and apps/id each ship a .env.example. Copy it next to the app and fill it in:
cp apps/web/.env.example apps/web/.env.local
cp apps/admin/.env.example apps/admin/.env.local
cp apps/id/.env.example apps/id/.env.local
The variables you will actually touch first:
| Variable | App | Description |
|---|---|---|
LUMIO_API_URL | web, admin, id | Server-side API base — http://localhost:3000/v1 |
LUMIO_INTERNAL_TOKEN | web, admin, id | Shared secret for SSR → API calls; must match rate_limiting.internal_token in the API config (dev-internal-token locally) |
AUTH_SECRET | id | NextAuth session secret |
AUTH_URL | id | ID app base URL — http://localhost:4002 |
AUTH_{PROVIDER}_ID / AUTH_{PROVIDER}_SECRET | id | OAuth login credentials per provider (see OAuth Provider Setup) |
NEXT_PUBLIC_APP_URL | web, admin | Public URL of the app itself |
NEXT_PUBLIC_ID_URL | web | Public URL of the ID app |
NEXT_PUBLIC_OVERLAY_URL | web | Public overlay host — http://localhost:4000/overlay locally |
NEXT_PUBLIC_SUPERVISOR_URL / NEXT_PUBLIC_EXTENSION_RUNTIME_URL | web | Extension sandbox hosts — http://localhost:3200 / http://localhost:3201 locally |
COOKIE_DOMAIN | web, admin, id | Only needed for cross-subdomain deployments; leave unset locally. Must be identical across the three apps |
LUMIO_WEB_URL / NEXT_PUBLIC_WEB_URL
Absolute URL of the apps/web app, read by apps/id. It is what the ID app redirects to after a successful login (/dashboard, /dashboard/connections, /account/subscription). LUMIO_WEB_URL takes precedence; NEXT_PUBLIC_WEB_URL is the client-side mirror.
- Default in code:
http://localhost:4000 - Staging:
https://lumio.web.staging.zaflun.dev - Production-preview:
https://lumio.web.prod.zaflun.dev - Production:
https://lumio.vision
apps/web itself does NOT need this variable — it uses relative paths because it hosts those 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.
Platform Credential Sources
Channel and bot OAuth flows choose their OAuth app source from the API config. The flat [platform_credentials] table applies to channel connections. The nested [platform_credentials.bot] table applies to bot connections.
# Channel-credential source. The flat form is channel.
[platform_credentials]
twitch = "system"
kick = "system"
trovo = "system"
youtube = "account"
spotify = "account"
# Bot-credential source. OAuth bots are system-first; Discord uses a static token.
[platform_credentials.bot]
twitch = "system"
kick = "system"
trovo = "system"
youtube = "system"
Allowed values are "system" and "account". system means the OAuth app pair comes from the global config keys in [auth]; account means the pair comes from the encrypted per-account app_credentials row. If a system platform has no matching global key pair configured, new connects fail safe to the account path and keep local/operator-managed installs usable.
ENV overrides use the same nested naming convention:
LUMIO__PLATFORM_CREDENTIALS__TWITCH=system
LUMIO__PLATFORM_CREDENTIALS__YOUTUBE=account
LUMIO__PLATFORM_CREDENTIALS__BOT__TWITCH=system
LUMIO__PLATFORM_CREDENTIALS__BOT__YOUTUBE=system
The global system OAuth app pairs live in [auth]:
| TOML path | ENV var | Notes |
|---|---|---|
auth.twitch_channel_client_id | LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID | Twitch channel/bot system app; falls back to auth.twitch_client_id when empty |
auth.twitch_channel_client_secret | LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_SECRET | Twitch channel/bot system app secret |
auth.kick_channel_client_id | LUMIO__AUTH__KICK_CHANNEL_CLIENT_ID | Kick channel/bot system app; no login fallback |
auth.kick_channel_client_secret | LUMIO__AUTH__KICK_CHANNEL_CLIENT_SECRET | Kick channel/bot system app secret |
auth.trovo_channel_client_id | LUMIO__AUTH__TROVO_CHANNEL_CLIENT_ID | Trovo channel/bot system app; no login fallback |
auth.trovo_channel_client_secret | LUMIO__AUTH__TROVO_CHANNEL_CLIENT_SECRET | Trovo channel/bot system app secret |
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 (.github/workflows/innertube-health.yml) 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) |
Automation Worker
The API dispatches extension automation-node handlers to the Automation Worker over HTTP. Configured under [automation_worker] in apps/api/config/default.toml.
| Variable | Purpose | Default |
|---|---|---|
LUMIO__AUTOMATION_WORKER__URL | Base URL of the Automation Worker (apps/automation-worker, no trailing slash) | http://automation-worker:8091 |
LUMIO__AUTOMATION_WORKER__SYSTEM_KEY | System key presented as Authorization: SystemKey <key>; must match the worker's worker.system_key. Empty (the default) disables extension-node dispatch and fails closed | (empty) |
Handler-execution telemetry (workers)
Both handler-execution workers (apps/bot-module-worker, apps/automation-worker) optionally record per-execution telemetry into TimescaleDB — one extension_errors row on failure and one extension_install_logs row on every run (see Developer Extension Endpoints). The writer is fail-open: it never fails or delays a handler request, and is simply disabled when no TimescaleDB pool is configured. Env vars use each worker's own prefix — BOT_MODULE_WORKER__… and AUTOMATION_WORKER__….
| Variable | Purpose | Default |
|---|---|---|
…__TIMESCALE__URL | TimescaleDB connection URL. Omit the whole [timescale] section to disable the telemetry writer | (unset → writer off) |
…__WORKER__TELEMETRY_SALT | Per-deployment secret salt used to HMAC-anonymize account/install IDs in extension_errors. Set a stable value in production; an empty salt still hashes but is weaker | (empty) |
Worker registry sync
Each worker (apps/bot-module-worker, apps/automation-worker) populates its in-memory InstallRegistry from the API's internal handler-bundle feed on startup and re-syncs on a poll — see Bot Modules → Worker sync-loop. Configured under [sync], with the env prefix BOT_MODULE_WORKER__SYNC__* and AUTOMATION_WORKER__SYNC__* respectively. When the section is not fully configured the worker starts with an empty registry (every request short-circuits) rather than failing.
| Variable (per worker prefix) | Purpose | Default |
|---|---|---|
…__SYNC__ENABLED | Run the registry sync loop | true |
…__SYNC__API_BASE_URL | API origin the feed is served from (no trailing slash), e.g. http://api:8080. Empty disables the loop | (empty) |
…__SYNC__SYSTEM_KEY | System key presented to the feed as Authorization: Bearer <key>. Empty disables the loop | (empty) |
…__SYNC__POLL_INTERVAL_SECS | Full-snapshot re-sync interval | 30 |
GeoIP
The optional GeoIP service resolves client IP addresses to a country/city using the free MaxMind GeoLite2 City database. It powers the Location column of the Audit Log. It is disabled by default — no MaxMind account is needed to run Lumio; leave enabled = false and the location fields simply stay empty.
When enabled with a license key, the API downloads the .mmdb database on startup if it is missing and, when auto_update is on, refreshes it every update_interval_days. All GeoIP state lives under [geoip] in apps/api/config/default.toml; every key is overridable via the LUMIO__GEOIP__* env vars below.
| Variable | Purpose | Default |
|---|---|---|
LUMIO__GEOIP__ENABLED | Master on/off switch. When false, no database is loaded and location fields stay empty | false |
LUMIO__GEOIP__DATABASE_PATH | Path to the GeoLite2 City .mmdb file. The default lives under the container WORKDIR (/app); the runtime-base image pre-creates /app/data/geoip owned by uid 1001, so the file is downloadable and refreshable by the non-root user. Mount a persistent volume there to survive restarts | /app/data/geoip/GeoLite2-City.mmdb |
LUMIO__GEOIP__LICENSE_KEY | MaxMind license key used to download/update the database. Get a free key. Without it, an existing database is still read but cannot be auto-downloaded | (empty) |
LUMIO__GEOIP__ACCOUNT_ID | MaxMind numeric account ID. Required (with the license key) for the Privacy Exclusions API | (empty) |
LUMIO__GEOIP__AUTO_UPDATE | Re-download the database on a schedule when it is older than update_interval_days | false |
LUMIO__GEOIP__UPDATE_INTERVAL_DAYS | Minimum database age before a re-download. MaxMind publishes GeoLite2 weekly | 7 |
LUMIO__GEOIP__PRIVACY_EXCLUSIONS_ENABLED | Honour MaxMind Privacy Exclusions (user geolocation opt-outs). Requires license_key + account_id | false |
LUMIO__GEOIP__PRIVACY_EXCLUSIONS_REFRESH_HOURS | How often the cached exclusions list is refreshed | 24 |
LUMIO__GEOIP__PRIVACY_EXCLUSIONS_CACHE_PATH | Where the exclusions list is cached for persistence across restarts. Lives on the same writable /app/data/geoip directory as the database | /app/data/geoip/geoip-privacy-exclusions.json |
For a one-off database refresh outside the server, run the geoip-update maintenance binary: cargo run -p lumio-api --bin geoip-update -- --help.
Privacy. GeoIP derives only a coarse country/city from the IP — no precise coordinates are stored. Enabling
privacy_exclusions_enabledmakes Lumio respect MaxMind's Privacy Exclusions opt-out list so IPs of users who opted out are not resolved.
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 the CI build / deployment environment 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
just stack-up— all six containers report healthy.just migrate-status— every migration shows as applied.just run-api— the API answers onhttp://localhost:3000/v1.GET /v1/graphiqlserves the GraphiQL IDE andGET /v1/schemathe GraphQL SDL (both are on by default indevelopmentand gated by[graphql].playground/[graphql].schema_sdl). The REST surface is documented at/v1/swagger-ui/with the raw spec at/v1/openapi.json.just dev-web— visithttp://localhost:4000and log in through the ID app onhttp://localhost:4002.
Before you push
| Recipe | What it runs |
|---|---|
just verify-all | cargo fmt --check + clippy (-D warnings) + cargo check + ESLint + tsc — the fast inner loop |
just lint-all | clippy + ESLint only |
just test-all | cargo test (workspace) + the V8 extension tests + Vitest |
just verify-release | verify-all plus regenerating the OpenAPI/GraphQL schemas and the Bazel vendor tree, failing on drift. Run this after a version bump or a Cargo.lock change — verify-all does not detect that drift |