Configuration
Environment variables, database settings, and API keys for a keelwave deployment.
keelwave is configured entirely through environment variables. There is no
config file. Every variable is optional except in practice DB_ADDR, which you
almost always need to change from its localhost default.
docker run --rm -p 8080:8080 \
-e DB_ADDR="postgres://keelwave:secret@db:5432/keelwave?sslmode=require" \
-e ENV="production" \
-e PUBLIC_URL="https://keelwave.example.com" \
-e DASHBOARD_URL="https://keelwave.example.com" \
-e CORS_ALLOWED_ORIGINS="https://keelwave.example.com" \
ghcr.io/keelwave/keelwave:latestUnparseable values fall back to the default rather than failing startup:
integer variables fall back if strconv.Atoi fails, and duration variables fall
back if time.ParseDuration fails. Durations use Go syntax — 500ms, 30s,
168h.
Server
| Variable | Purpose | Default |
|---|---|---|
ADDR | Listen address for the HTTP server | :8080 |
ENV | Environment label. Reported by /v1/health; the value production also turns on the Secure flag for session cookies | development |
PUBLIC_URL | Public base URL of this API. Used to build OAuth callback URLs | http://localhost:8080 |
DASHBOARD_URL | Base URL the dashboard is served from. Used for post-OAuth redirects and email verification links | http://localhost:3000 |
SHUTDOWN_TIMEOUT | Total budget for graceful shutdown: HTTP drain, batch flush, alert scheduler and worker stop | 10s |
In the single-image deployment, the dashboard is served same-origin by the API,
so PUBLIC_URL and DASHBOARD_URL should both point at the app's own origin.
The Compose app service sets both to http://localhost:8080.
On SIGINT or SIGTERM the server stops accepting connections, drains
in-flight requests, flushes the batch buffers, stops the alerting scheduler and
worker, and closes the database pool — all inside the SHUTDOWN_TIMEOUT
budget.
Database
| Variable | Purpose | Default |
|---|---|---|
DB_ADDR | Postgres/TimescaleDB connection string (pgx pool DSN) | postgres://keelwave:keelwave@localhost:5432/keelwave?sslmode=disable |
DB_MAX_CONNS | Maximum connections in the pgx pool | 30 |
keelwave requires TimescaleDB, not plain Postgres. The schema declares
hypertables for ai_traces, api_events, infra_metrics, agent_runs, and
agent_steps, and migration 000010 creates a continuous aggregate
(agent_runs_5m) with a refresh policy that depends on TimescaleDB background
workers. Compose uses timescale/timescaledb:latest-pg18.
On startup the pool is created and pinged with a 5-second timeout; if the ping
fails the process exits. Set sslmode=require (or stronger) in DB_ADDR when
the database is not on the same host.
Migrations are never applied automatically — see Production.
CORS
| Variable | Purpose | Default |
|---|---|---|
CORS_ALLOWED_ORIGINS | Comma-separated list of allowed origins | http://localhost:3000 |
Values are split on commas and trimmed; empty entries are dropped. Requests
carry credentials, so * is rejected — if any entry is * the server logs a
fatal error and refuses to start. List explicit origins instead.
Allowed methods are GET, POST, PUT, PATCH, DELETE, OPTIONS; allowed
headers are Accept, Authorization, Content-Type, X-API-Key,
X-Project-ID, X-Org-ID; preflight responses are cached for 300 seconds.
CORS only matters for cross-origin browser callers. Server-side SDKs and the same-origin dashboard are unaffected.
Rate limiting
Two httprate layers guard /v1/ingest/*: a per-IP limiter before API key
authentication, and a per-API-key limiter after it. Both return 429 with a
Retry-After header.
| Variable | Purpose | Default |
|---|---|---|
RATE_LIMIT_INGEST_IP_PER_MINUTE | Requests per window, per client IP | 100 |
RATE_LIMIT_INGEST_KEY_PER_MINUTE | Requests per window, per API key | 1000 |
RATE_LIMIT_INGEST_WINDOW | Window length for both limiters | 1m |
Raise both limits together if a busy agent fleet shares one egress IP — otherwise the per-IP limit binds first.
Batch buffer
Hot-path ingest rows (ai_traces, api_events, infra_metrics,
agent_steps) are queued in memory and written in bulk with COPY.
| Variable | Purpose | Default |
|---|---|---|
BATCH_FLUSH_INTERVAL | How often a partial buffer is flushed | 500ms |
BATCH_MAX_ROWS | Maximum rows per COPY batch | 500 |
BATCH_QUEUE_DEPTH | Per-table in-memory queue capacity | 10000 |
Enqueue is non-blocking. When a queue is full the handler responds 503 with
Retry-After: 1 instead of stalling — SDKs are expected to retry. Sustained
503s mean the database cannot keep up; increase BATCH_MAX_ROWS or
BATCH_QUEUE_DEPTH, or give the database more resources.
Note that a larger queue means more rows held only in memory, so more data can be lost if the process is killed without a graceful shutdown.
Authentication and sessions
| Variable | Purpose | Default |
|---|---|---|
SESSION_TTL | Dashboard session lifetime | 168h (7 days) |
SESSION_COOKIE_NAME | Session cookie name | keelwave_session |
GOOGLE_CLIENT_ID | Google OAuth client ID. Provider is enabled only when both ID and secret are set | unset |
GOOGLE_CLIENT_SECRET | Google OAuth client secret | unset |
GITHUB_CLIENT_ID | GitHub OAuth client ID. Provider is enabled only when both ID and secret are set | unset |
GITHUB_CLIENT_SECRET | GitHub OAuth client secret | unset |
The session cookie's Secure flag is set when ENV equals production. Behind
TLS, set ENV=production so browsers do not send session cookies over plain
HTTP.
Email delivery goes through Resend, and is used for account verification and
for the email alert channel.
| Variable | Purpose | Default |
|---|---|---|
RESEND_API_KEY | Resend API key | unset |
MAIL_FROM | From address on outgoing mail | example@example.com |
Without a working RESEND_API_KEY and a real MAIL_FROM, registration
verification emails and email alerts will not be delivered.
Alerting
| Variable | Purpose | Default |
|---|---|---|
ALERT_EVAL_INTERVAL | How often the scheduler evaluates alert rules | 30s |
Seed script
make seed (cmd/migrate/seed) reads DB_ADDR plus two of its own variables:
| Variable | Purpose | Default |
|---|---|---|
SEED_PROJECT_NAME | Name of the project it creates | dev |
SEED_KEY_NAME | Name of the API key it creates | seed |
API keys
An API key is kw_ followed by base32 (no padding) of 32 random bytes. Only
the SHA-256 hash is stored — the plaintext is returned once at creation and
cannot be recovered.
SDKs and any direct ingest client authenticate with a bearer header:
Authorization: Bearer kw_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXThe key determines the project_id for every ingested row; it is resolved in
middleware and never read from the request body.
Two ways to create one:
make seed— creates a project and prints one plaintext key. Development convenience; the seeded user cannot sign in to the dashboard.POST /v1/admin/orgs/{orgID}/projects/{projectID}/keys— requires a verified, signed-in user with the admin role in that organization. Body is{"name": "..."}; the201response contains the plaintext underkey.
List keys with GET and revoke one with
DELETE /v1/admin/orgs/{orgID}/projects/{projectID}/keys/{keyID}. Rotate by
creating the replacement, deploying it, then deleting the old key.
Keep API keys and OAuth secrets out of version control. If a key leaks, delete it and issue a new one — there is no other way to invalidate it.