This guide covers local development for the full Maple stack: app services, Postgres, ClickHouse, and the OTel collector pipeline.
For a single-binary experience without Docker, see docs/local-mode.md
(maple start).
Install the pinned toolchain (recommended via mise):
| Tool | Used for |
|---|---|
| Bun | JS/TS apps, scripts, tests |
| Node | Some scripts (e.g. mobile) |
| Rust | apps/ingest (OTLP gateway) |
| Docker | Postgres, ClickHouse, OTel collector |
First-time bootstrap:
curl https://mise.run | sh
echo 'eval "$(mise activate zsh)"' >> ~/.zshrc # or bash/fish equivalent
mise trust
mise run setup # installs tools, bun deps, copies .env.example → .env.local, portless CAWithout mise, the same steps manually:
bun install
cp .env.example .env.local
npx portless trust # only needed if you use `bun dev` / portless HTTPS URLsBrowser → web (3471) → api (3472) → ClickHouse (8123)
↓
OTLP clients → ingest (3473/3474) → OTel collector (4318) → ClickHouse
↓
Postgres (5499) app state (issues, keys, dashboards, …)
The development Docker stack (docker-compose.development.yml) runs the data plane.
Application processes run on the host (see Running application services).
All services read secrets and overrides from .env.local at the repo root (gitignored).
Start from the template:
cp .env.example .env.localSeveral services share these keys. Generate them once and paste into .env.local:
# AES-256-GCM key for encrypting private ingest keys at rest (must be base64 of exactly 32 bytes)
openssl rand -base64 32
# HMAC key for ingest-key lookup hashes (any non-empty secret; hex is convenient)
openssl rand -hex 32Set the outputs as:
MAPLE_INGEST_KEY_ENCRYPTION_KEY=<output of first command>
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY=<output of second command>These values align with docker-compose.development.yml and self-hosted auth:
# Warehouse — route API queries to local ClickHouse instead of Tinybird Cloud
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_PROVIDER=clickhouse
CLICKHOUSE_USER=maple
CLICKHOUSE_PASSWORD=maple
CLICKHOUSE_DATABASE=default
# App database (`alchemy dev` binds MAPLE_DB to the docker Postgres on 5499)
# MAPLE_DB_URL is intentionally blank for the Workers; see db:migrate:local below.
# Auth — self-hosted mode (no Clerk account required)
MAPLE_AUTH_MODE=self_hosted
MAPLE_ROOT_PASSWORD=change-me
MAPLE_DEFAULT_ORG_ID=default
# Ingest — forward OTLP to the local collector (which writes ClickHouse)
INGEST_WRITE_MODE=forward
INGEST_FORWARD_OTLP_ENDPOINT=http://127.0.0.1:4318
INGEST_PORT=3474
# Single-tenant ingest keys (no Postgres key store required)
MAPLE_SELF_HOSTED_MODE=single_tenant
MAPLE_ORG_ID_OVERRIDE=default
# Internal service auth (must match across api and scraper)
INTERNAL_SERVICE_TOKEN=dev-internal-service-token
SD_INTERNAL_TOKEN=dev-internal-service-token
# Web → API/ingest URLs when running raw ports (not portless)
VITE_API_BASE_URL=http://localhost:3472
VITE_MAPLE_AUTH_MODE=self_hostedTINYBIRD_HOST and TINYBIRD_TOKEN remain in .env.example because the API validates
them at startup even when CLICKHOUSE_URL is set. Placeholder values from the template are
fine for this stack.
Clerk mode: set
MAPLE_AUTH_MODE=clerkand provideCLERK_SECRET_KEY,CLERK_PUBLISHABLE_KEY, plus the matchingVITE_*overrides. Test credentials for the hosted dev org are documented in CLAUDE.md.
From the repo root:
docker compose -f docker-compose.development.yml up -dThis starts:
| Service | Ports | Purpose |
|---|---|---|
postgres |
5499 → 5432 |
App DB for alchemy dev (Hyperdrive origin) |
clickhouse |
8123, 9000 |
Telemetry warehouse |
otel-collector |
4317, 4318, 13133 |
OTLP ingest → ClickHouse via mapleexporter |
The collector reads .env.local and uses MAPLE_CLICKHOUSE_PASSWORD=maple from compose
overrides. Ensure ClickHouse credentials in .env.local match (maple / maple).
Wait for health checks, then apply schema and migrations:
# Postgres (Drizzle migrations for app state)
bun run db:migrate:local
# ClickHouse (telemetry tables + materialized views)
bun run --cwd packages/clickhouse-cli start apply \
--url=http://localhost:8123 \
--user=maple \
--password=maple \
--database=defaultStop infrastructure:
docker compose -f docker-compose.development.yml downPostgres data persists in the postgres-data volume; ClickHouse in clickhouse-data.
Remove volumes with down -v for a clean slate.
bun dev runs every app below as one alchemy dev stack behind https://<app>.localhost,
and bun dev api web runs a subset — see All at once. The per-app sections
describe what each one needs and how to run it alone on its raw port.
bun dev api alerting electric-syncThe three Cloudflare Workers are served by alchemy's local runtime from the same
alchemy.run.ts that deploys them, reachable at https://api.localhost,
https://alerting.localhost and https://electric-sync.localhost through the portless
proxy (branch-prefixed in a linked worktree). Ports are sticky per app and never need
writing down; bun dev prints them.
The variables below are the API's:
| Variable | Required | Notes |
|---|---|---|
MAPLE_INGEST_KEY_ENCRYPTION_KEY |
yes | openssl rand -base64 32 |
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY |
yes | openssl rand -hex 32 |
MAPLE_AUTH_MODE |
yes | self_hosted or clerk |
MAPLE_ROOT_PASSWORD |
yes* | *Required when MAPLE_AUTH_MODE=self_hosted |
MAPLE_DEFAULT_ORG_ID |
yes | Default default; must match ingest org override |
TINYBIRD_HOST |
yes | Placeholder OK when using CLICKHOUSE_URL |
TINYBIRD_TOKEN |
yes | Placeholder OK when using CLICKHOUSE_URL |
TINYBIRD_SIGNING_KEY / TINYBIRD_WORKSPACE_ID |
with Tinybird raw SQL | Explicit JWT signing configuration; never derived from TINYBIRD_TOKEN |
TINYBIRD_RAW_SQL_JWT_RPS_LIMIT |
optional | Positive integer; Tinybird-enforced raw-SQL ceiling with a separate bucket per org |
CLICKHOUSE_URL |
recommended | http://localhost:8123 for local ClickHouse stack |
CLICKHOUSE_PROVIDER |
optional | tinybird (default) or clickhouse; set clickhouse for env-level vanilla/self-managed ClickHouse |
CLICKHOUSE_USER / CLICKHOUSE_PASSWORD / CLICKHOUSE_DATABASE |
with CH | Match docker-compose (maple / maple / default) |
INTERNAL_SERVICE_TOKEN |
recommended | Internal MCP tool calls; shared with the scraper |
SD_INTERNAL_TOKEN |
optional | Prometheus scraper internal API auth |
CLERK_* |
clerk mode | See .env.example |
Loads env via alchemy dev --env-file .env.local; a changed variable needs a restart.
Requires docker Postgres running (bun run db:migrate:local).
bun dev web # in the stack: https://web.localhost
bun --filter=@maple/web dev # alone, raw portDefault raw URL: http://localhost:3471
| Variable | Required | Notes |
|---|---|---|
VITE_API_BASE_URL |
yes | http://localhost:3472 (or portless https://api.localhost) |
VITE_MAPLE_AUTH_MODE |
yes | Mirror MAPLE_AUTH_MODE |
VITE_INGEST_URL |
optional | Defaults derived; set if ingest port differs |
VITE_CLERK_* |
clerk mode | Publishable key + sign-in URLs |
VITE_MAPLE_INGEST_KEY |
optional | Browser self-telemetry via ingest |
Sign in (self-hosted): use the org/user you create with MAPLE_ROOT_PASSWORD.
Clerk dev login: see CLAUDE.md.
bun dev ingest # in the stack: https://ingest.localhost
bun --filter=@maple/ingest dev # alone, raw portDefault port: 3473 (INGEST_PORT / PORT override). .env.example uses 3474; pick one
port and keep VITE_* / MAPLE_INGEST_PUBLIC_URL consistent.
| Variable | Required | Notes |
|---|---|---|
INGEST_FORWARD_OTLP_ENDPOINT |
yes | http://127.0.0.1:4318 for local collector |
INGEST_WRITE_MODE |
recommended | forward for ClickHouse stack (default tinybird) |
MAPLE_INTERNAL_ORG_ID |
yes | Org the gateway files its OWN telemetry under; no default |
MAPLE_INGEST_KEY_ENCRYPTION_KEY |
yes* | *Required for postgres key store / ClickHouse direct path |
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY |
yes | Same value as API |
MAPLE_SELF_HOSTED_MODE |
recommended | single_tenant → static key store (no DB) |
MAPLE_ORG_ID_OVERRIDE |
with static | Must match MAPLE_DEFAULT_ORG_ID |
MAPLE_PG_URL |
postgres store | postgres://maple:maple@localhost:5499/maple if not using static store |
TINYBIRD_HOST / TINYBIRD_TOKEN |
tinybird mode | When INGEST_WRITE_MODE=tinybird or dual |
INGEST_PORT |
optional | Default from port / env |
INGEST_REQUIRE_TLS |
optional | false locally |
Sources ../../.env.local automatically. Requires Rust toolchain.
Optional — polls the API for Prometheus targets and forwards metrics through ingest.
cd apps/scraper && bun devDefault health port: 3475
| Variable | Required | Notes |
|---|---|---|
MAPLE_API_URL |
optional | Default http://127.0.0.1:3472 |
MAPLE_INGEST_URL |
optional | Default http://127.0.0.1:3474 |
SD_INTERNAL_TOKEN |
optional | Default maple-sd-dev-token; match API's SD_INTERNAL_TOKEN |
SCRAPER_CONCURRENCY |
optional | Default 10 |
PORT |
optional | Health endpoint, default 3475 |
Loads ../../.env.local via bun --env-file.
/chat and /investigations/* are served by apps/api itself: the ChatSession Durable
Object owns each transcript and the agent turn runs in-process on @opencode-ai/ai against the
Workers AI AI binding. There is no second worker to start — but the chat routes only work
when apps/api is running under alchemy dev (bun dev api), because a plain
bun process has neither the Durable Object namespace nor the AI binding.
The AI binding needs a Cloudflare account with Workers AI, through an alchemy profile
(bunx alchemy login). Confirm the default model id in apps/api/src/lib/Llm.ts is still in
the catalog — cd apps/api && bunx wrangler ai models list; a retired id returns 410.
Override it with MAPLE_TRIAGE_MODEL.
MAPLE_ORG_ID_OVERRIDEbreaks the investigation chat. It pins every API request to one org, but the browser addresses the session as<clerk org>:<tab>. With the override set to anything other than your Clerk organization's id, the page watches a session the API never seeded, and the transcript stays empty forever. Unset it, or set it to your Clerk org id, when working on chat.
When an autonomous investigation fails to start, the investigations.error column names it:
error value |
Cause |
|---|---|
agent_unavailable: … |
apps/api isn't running under alchemy dev, so there is no CHAT_SESSION binding |
start_failed: a turn is already running for this investigation |
The session is busy; retry |
diagnosis_timeout: … |
The turn ran but never called submit_diagnosis within 15 minutes |
bun dev # everything
bun dev api web # a subsetOne alchemy dev stack: the Workers (api, alerting, electric-sync) on alchemy's local
runtime, everything else (web, landing, local-ui, ingest, scraper) as child processes of the
same stack, each behind https://<app>.localhost (branch-prefixed in a linked worktree).
Ctrl-C stops all of it. Logs share the one terminal. See docs/infra.md for how the ports
and routes are handed out.
- Open
http://localhost:3471(orhttps://web.localhostwith portless). - Sign in (self-hosted root password or Clerk test user).
- Send test OTLP to
http://localhost:3474/v1/traces(or your ingest port) with a well-formed ingest key, or rely on static-key mode (anymaple_pk_*key resolves toMAPLE_ORG_ID_OVERRIDE). - Confirm data in the traces/logs UI after the collector batch flushes (~seconds).
Health checks:
- OTel collector:
http://localhost:13133 - Scraper:
http://localhost:3475/health - API:
http://localhost:3472/health
bun test
bun typecheck
bun run format # oxfmt + oxlint --fixVitest uses embedded PGlite — no Docker Postgres required for unit tests.
| Symptom | Check |
|---|---|
| API fails on startup | Missing MAPLE_INGEST_KEY_*, MAPLE_ROOT_PASSWORD, or TINYBIRD_* |
| Ingest 401 on OTLP | Static key store: use maple_pk_… prefix; postgres store: seed keys via API |
| Empty traces UI | ClickHouse schema applied? Collector running? INGEST_WRITE_MODE=forward? |
| API DB errors | docker compose … up -d postgres + bun run db:migrate:local |
| Ingest/API HMAC mismatch | MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY must be identical; compare startup fingerprints in logs |
| Chat MCP tools fail | INTERNAL_SERVICE_TOKEN mismatch or API not running |
- docs/persistence.md — Postgres migrations
- docs/self-hosted-clickhouse.md — ClickHouse BYO + schema
- docs/local-mode.md — single-binary local mode
- .env.example — full env reference with optional integrations (GitHub App, Hazel, Autumn, email)