keelwave
Self-Hosting

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:latest

Unparseable 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

VariablePurposeDefault
ADDRListen address for the HTTP server:8080
ENVEnvironment label. Reported by /v1/health; the value production also turns on the Secure flag for session cookiesdevelopment
PUBLIC_URLPublic base URL of this API. Used to build OAuth callback URLshttp://localhost:8080
DASHBOARD_URLBase URL the dashboard is served from. Used for post-OAuth redirects and email verification linkshttp://localhost:3000
SHUTDOWN_TIMEOUTTotal budget for graceful shutdown: HTTP drain, batch flush, alert scheduler and worker stop10s

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

VariablePurposeDefault
DB_ADDRPostgres/TimescaleDB connection string (pgx pool DSN)postgres://keelwave:keelwave@localhost:5432/keelwave?sslmode=disable
DB_MAX_CONNSMaximum connections in the pgx pool30

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

VariablePurposeDefault
CORS_ALLOWED_ORIGINSComma-separated list of allowed originshttp://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.

VariablePurposeDefault
RATE_LIMIT_INGEST_IP_PER_MINUTERequests per window, per client IP100
RATE_LIMIT_INGEST_KEY_PER_MINUTERequests per window, per API key1000
RATE_LIMIT_INGEST_WINDOWWindow length for both limiters1m

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.

VariablePurposeDefault
BATCH_FLUSH_INTERVALHow often a partial buffer is flushed500ms
BATCH_MAX_ROWSMaximum rows per COPY batch500
BATCH_QUEUE_DEPTHPer-table in-memory queue capacity10000

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

VariablePurposeDefault
SESSION_TTLDashboard session lifetime168h (7 days)
SESSION_COOKIE_NAMESession cookie namekeelwave_session
GOOGLE_CLIENT_IDGoogle OAuth client ID. Provider is enabled only when both ID and secret are setunset
GOOGLE_CLIENT_SECRETGoogle OAuth client secretunset
GITHUB_CLIENT_IDGitHub OAuth client ID. Provider is enabled only when both ID and secret are setunset
GITHUB_CLIENT_SECRETGitHub OAuth client secretunset

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

Email delivery goes through Resend, and is used for account verification and for the email alert channel.

VariablePurposeDefault
RESEND_API_KEYResend API keyunset
MAIL_FROMFrom address on outgoing mailexample@example.com

Without a working RESEND_API_KEY and a real MAIL_FROM, registration verification emails and email alerts will not be delivered.

Alerting

VariablePurposeDefault
ALERT_EVAL_INTERVALHow often the scheduler evaluates alert rules30s

Seed script

make seed (cmd/migrate/seed) reads DB_ADDR plus two of its own variables:

VariablePurposeDefault
SEED_PROJECT_NAMEName of the project it createsdev
SEED_KEY_NAMEName of the API key it createsseed

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_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

The 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": "..."}; the 201 response contains the plaintext under key.

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.

On this page