keelwave
Self-Hosting

Docker

Run keelwave with the single self-contained image or Docker Compose.

The fastest way to a running keelwave is the app Compose profile. It starts TimescaleDB, applies migrations, then starts the API and dashboard on http://localhost:8080.

# from core/
docker compose --profile app up --build

That brings up three services, in order:

ServiceImageRole
dbtimescale/timescaledb:latest-pg18Postgres + TimescaleDB, port 5432
migratemigrate/migrate:v4.19.1Applies cmd/migrate/migrations, then exits
appghcr.io/keelwave/keelwave:localAPI + dashboard, port 8080

migrate waits on the db healthcheck (pg_isready), and app waits for migrate to complete successfully. Database files persist in the keelwave_db volume.

Check that it is up:

curl http://localhost:8080/v1/health
# {"data":{"status":"ok","version":"...","env":"development"}}

Then open http://localhost:8080 for the dashboard and http://localhost:8080/v1/docs for the API reference.

What the image contains

The keelwave image is self-contained: one binary, one process, one port, serving both the Go API and the React dashboard.

The Dockerfile builds in three stages:

  1. web (node:22-bookworm-slim) — pnpm build compiles the dashboard as a static client-only SPA and copies the client bundle into internal/web/dist.
  2. build (golang:1.26-bookworm) — overlays that directory, then CGO_ENABLED=0 go build -trimpath produces /out/keelwave with the dashboard embedded via //go:embed. Cross-compiled using TARGETOS and TARGETARCH.
  3. runtime (gcr.io/distroless/static:nonroot) — non-root, no shell, EXPOSE 8080, ENTRYPOINT ["/usr/local/bin/keelwave"].

Routing inside the binary: chi matches /v1/* first (API, /v1/health, /v1/docs, /v1/openapi.json), and a catch-all mounted last serves the embedded dashboard. Unknown extensionless paths fall back to index.html so client-side routes deep-link; a missing asset returns 404.

Because the dashboard is same-origin with the API, it calls /v1/* over relative paths — no separate dashboard container, and no CORS setup needed for the dashboard itself.

The image does not run migrations on start. Apply them out-of-band — via the migrate service above, or make migrate-up — before rolling a new image.

Running the image directly

The binary needs a reachable Postgres/TimescaleDB. Minimum:

docker run --rm -p 8080:8080 \
  -e DB_ADDR="postgres://keelwave:keelwave@host:5432/keelwave?sslmode=disable" \
  ghcr.io/keelwave/keelwave:latest

To build the image locally instead of pulling it:

docker build --build-arg VERSION="$(git describe --tags --always)" -t keelwave:dev .

Released images are multi-arch (amd64 + arm64) and published to ghcr.io/keelwave/keelwave on a v* tag, with provenance and SBOM. Tags follow semver (X.Y.Z, X.Y, X), plus sha-<sha> and latest.

Database only

If you are running the Go binary from source, start just the database:

docker compose up -d db     # TimescaleDB (pg18) on :5432
make migrate-up             # apply all migrations
make run                    # API on :8080  (make dev for hot reload)

On a fresh checkout go build embeds only a placeholder dashboard. Run make web-build (or cd web && pnpm build) first if you want the real dashboard in a locally built binary. The Docker web stage does this automatically.

First API key

The server never stores plaintext API keys — only their SHA-256 hash — so a key is shown exactly once, at creation. There are two ways to get your first one.

Option 1 — seed script

make seed creates a user, an organization, a project, and one API key, then prints the plaintext to stdout:

make seed
project_id:   0199...
project_name: dev
api_key_id:   0199...
api_key:      kw_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
store the api_key now — server only keeps the SHA-256 hash

Copy the kw_… value — that is what the SDKs authenticate with. Project and key names default to dev and seed; override them with SEED_PROJECT_NAME and SEED_KEY_NAME.

The seeded user is created without a password and is not email-verified, so it cannot sign in to the dashboard. make seed exists to get an ingest key quickly in development; use the registration flow below for dashboard access.

Option 2 — register, then create a key

Register a user. POST /v1/auth/register creates the account and sends a verification email (needs RESEND_API_KEY and MAIL_FROM). Alternatively use Google or GitHub OAuth via GET /v1/auth/oauth/{provider}/start, which requires GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET or GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET.

Verify the email. The link points at DASHBOARD_URL + /verify-email/<token> and resolves through GET /v1/auth/verify-email/{token}. Admin and project query routes reject unverified users with 403.

Create an organization and a project with POST /v1/admin/orgs and POST /v1/admin/orgs/{orgID}/projects, or through the dashboard.

Create the key with POST /v1/admin/orgs/{orgID}/projects/{projectID}/keys and a {"name": "..."} body. The 201 response carries a key field holding the plaintext kw_… value. It is returned once and never again.

SDKs send the key as Authorization: Bearer kw_<token> on /v1/ingest/* requests.

Next

On this page