# Configuration

> **Related:** [Access Control](https://wavehouse.dev/access-control.md) · [API Reference](https://wavehouse.dev/api.md) · [Architecture](https://wavehouse.dev/architecture.md) · [Claude Code & AI agents](https://wavehouse.dev/claude-code.md) · [Deployment](https://wavehouse.dev/deployment.md) · [Development](https://wavehouse.dev/development.md) · [Durability & Storage](https://wavehouse.dev/durability.md) · [Getting Started](https://wavehouse.dev/getting-started.md) · [Ingest Pipeline](https://wavehouse.dev/ingest-pipeline.md) · [Named Pipes](https://wavehouse.dev/pipes.md) · [Behind a reverse proxy](https://wavehouse.dev/reverse-proxy.md) · [TypeScript SDK](https://wavehouse.dev/sdk.md) · [Settings Directory](https://wavehouse.dev/settings-directory.md) · [Why WaveHouse?](https://wavehouse.dev/why-wavehouse.md)
> **Also:** [HTML version](https://wavehouse.dev/configuration) · [Docs index](https://wavehouse.dev/llms.txt)

---

WaveHouse is configured via a YAML file with environment variable overrides. All environment variables use the `WH_` prefix.

## Loading Order

1. If a config file exists at the specified path (default: `config.yaml`), it is loaded first.
2. Environment variables override any values from the YAML file.
3. If no config file exists, all values are read from environment variables. Every key has a default except `settings.dir` (`WH_SETTINGS_DIR`), which must be set either way.
4. Both sources are **strict**. A YAML key this page doesn't list — a typo, or a tunable that has moved to the settings directory (`dlq.enabled`, `clickhouse.addr`, `stream.*`, a leftover `policy:` or `pipes:` block, …) — refuses to boot and names every offending key, so nothing is read, ignored, and believed. A `WH_*` environment variable that binds to no key on this page (`WH_DEDUPE_ENABLED`, `WH_CH_ADDR`, a misspelling) refuses to boot the same way. Two variables have no YAML key and are exempt because they are not config keys at all but process-level settings `main` reads directly: `WH_CONFIG` (below), which locates the file, and `WH_LOG_LEVEL`. Only the `WH_` prefix is checked, since the environment always carries names that aren't WaveHouse's. One outside source does share the prefix. Kubernetes injects `{SERVICE}_SERVICE_HOST`, `{SERVICE}_PORT`, and similar link variables into every pod in a Service's own namespace, for each Service with a cluster IP that existed before the pod started (a headless Service injects nothing, and a Service in another namespace is harmless). The name is uppercased with `-` mapped to `_`, so a Service named `wh` produces `WH_SERVICE_HOST` and `WH_PORT`, one named `wh-foo` produces `WH_FOO_SERVICE_HOST` and `WH_FOO_PORT`, and either way the pod refuses to boot on its next restart. Set `enableServiceLinks: false` on the pod spec, or name the Service something else. The error says so.
5. Before anything dials out, `data_dir` is probed, and boot refuses on any of these: the value is empty; the path exists but is not a directory; the path, or any component above it, is a dangling symlink (a mount that never came up); the directory exists but the process cannot write to it; the directory is absent and its nearest existing ancestor is not writable, so it could not be created. The probe runs before ClickHouse discovery, so the refusal lands at the top of the log, and a permission denial — on the write probe, or on reaching the path at all through a parent without search permission — carries the UID-65532 remediation, since a bind mount owned by root is the typical cause.

Boot is the validator for this half of configuration: there is no dry run, and a refused boot with the offending key, variable, or path named in the error is the loud signal. The hot-reloadable half has a dry run — `wavehouse validate` — because it is edited under a running server; boot config only ever takes effect through a restart, so the restart is where it is checked.

Set `WH_CONFIG` to change the config file path:

```bash
export WH_CONFIG=/etc/wavehouse/config.yaml
```

This page is boot config only — what the platform operator owns (wiring, lifecycle, secrets), read once at startup. Tenant-owned behavioral tunables, the access-control policy, and the named pipes are not here: they live in the hot-reloadable [Settings Directory](/settings-directory).

## Full Reference

### State

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `data_dir` | `WH_DATA_DIR` | `./data` | Root directory for embedded state. NATS JetStream lives at `<data_dir>/nats`; Pebble (when dedupe is enabled) at `<data_dir>/pebble`. Subdirectory names are conventions, not config — one knob, one mount. **In a container this MUST resolve to a host-backed volume**; the relative default is for local binary use. WaveHouse logs a startup `WARN` when the directory is missing or empty (no prior state). See [Persistent Storage](/deployment#persistent-storage-required-for-containers). |

### Server

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `server.port` | `WH_SERVER_PORT` | `8080` | HTTP server listen port. |
| `server.shutdown_timeout` | `WH_SERVER_SHUTDOWN_TIMEOUT` | `10` | Graceful shutdown timeout in seconds. |

The server speaks **plain HTTP** — there is no inbound-TLS setting. Terminate TLS at a [reverse proxy](/reverse-proxy#tls) for internet-facing deployments. (The settings directory's `clickhouse.http_scheme` is the *outbound* WaveHouse → ClickHouse hop, unrelated to your clients' TLS.)

### ClickHouse

Only the secret is boot config. The wiring — native address, HTTP port and scheme, database, username, query timeout — is the settings directory's [`clickhouse` block](/settings-directory#clickhouse), where a change re-dials without a restart.

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `clickhouse.password` | `WH_CH_PASSWORD` | *(empty)* | Authentication password, combined with the settings directory's `clickhouse.username` on every (re)connect. A secret, so it never lives in a tracked JSON file; rotating it is a restart. |

### Server-side resource limits

WaveHouse enforces a role's **per-role** resource caps (from the [access-control policy](/access-control#resource-limits)) by attaching them to each query as ClickHouse settings. **Server-wide** limits — the backstop that applies to *every* query regardless of role, including raw admin SQL — are configured in **ClickHouse itself**, via its [settings profiles](https://clickhouse.com/docs/operations/settings/settings-profiles) and [quotas](https://clickhouse.com/docs/operations/quotas). This keeps one authoritative place for global governance, has ClickHouse enforce it natively (defense-in-depth, even against a WaveHouse bug), and lets you use standard ClickHouse operations.

Set the backstop on the profile of the ClickHouse user WaveHouse connects as (the settings directory's `clickhouse.username`). For example, in `users.xml`:

```xml
<clickhouse>
  <profiles>
    <default>
      <!-- Per-query ceilings: a query that exceeds these is rejected. -->
      <max_memory_usage>4000000000</max_memory_usage>     <!-- ~4 GiB -->
      <max_execution_time>30</max_execution_time>         <!-- seconds -->
      <max_rows_to_read>1000000000</max_rows_to_read>

      <!-- Optionally make the memory ceiling a hard cap WaveHouse's per-role
           settings cannot raise above (but may still tighten). -->
      <constraints>
        <max_memory_usage><max>8000000000</max></max_memory_usage>
      </constraints>
    </default>
  </profiles>

  <!-- Quotas add time-windowed limits (queries/sec, rows read per hour, …)
       that per-query settings can't express. -->
  <quotas>
    <default>
      <interval>
        <duration>3600</duration>
        <read_rows>10000000000</read_rows>
      </interval>
    </default>
  </quotas>
</clickhouse>
```

:::caution[How the two layers compose]
WaveHouse's per-role caps are sent as per-query `SETTINGS` on its connection, so they **compose** with the ClickHouse profile — a per-role cap *tightens* within the profile's ceiling, and a `<constraints>` block bounds how far any setting can move. But if the profile marks a setting `readonly` (or `<constraints>` disallows changing it), ClickHouse will **reject** WaveHouse's per-query override and the query fails. So keep the settings WaveHouse manages (`max_memory_usage`, `max_execution_time`, `max_rows_to_read`, `max_result_rows`) **changeable** for its user — use a `<max>` constraint, not `readonly`, if you want a hard ceiling.
:::

### Message Queue (NATS)

The stream's disk budget, `mq.max_bytes_gb`, is a hot-reloadable key in the [Settings Directory](/settings-directory#message-queue) — there is no boot-config knob for it.

**Durability.** The embedded server runs with JetStream `SyncAlways`, so every event is `fsync`'d to disk before `POST /v1/ingest` returns `200`. This makes your storage's `fsync` latency your ingest latency floor — see [Durability & Storage](/durability) to check whether your substrate can sustain it. There is no knob to relax this today ([#139](https://github.com/Wave-RF/WaveHouse/issues/139) tracks a configurable group-commit interval).

### Cache

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `cache.l1_max_cost` | `WH_CACHE_L1_MAX_COST` | `67108864` | Maximum L1 cache size in bytes (~64 MB). The time-range bucket structured queries normalize to is `query.timestamp_bucket_seconds` in the [Settings Directory](/settings-directory#configjson-keys). |

### Authentication

Only the secrets are boot config. The verifier wiring — `jwks_url` and `role_claim` — is the settings directory's [`auth` block](/settings-directory#authentication), where a change rebuilds the verifier without a restart.

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `auth.jwt_secret` | `WH_AUTH_JWT_SECRET` | *(empty)* | HMAC secret for JWT validation. Set this (or the settings directory's `auth.jwks_url`) so presented tokens are verified; see [Access Control](/access-control). Ignored while `jwks_url` is set. |
| `auth.operator_key` | `WH_AUTH_OPERATOR_KEY` | *(empty)* | Non-JWT operator credential. A request presenting it — in an `Authorization: Operator <key>` header, or the `X-Operator-Key` alias — is authorized as a full-access platform operator (the whole data plane *and* the `/v1/ops/*` management surface), independent of the JWT verifier, and it is honored even when no policy is adopted (break-glass). Empty disables it. Treat it as an admin secret. See below and [Access Control](/access-control). |

WaveHouse accepts only the signing algorithms matching the active verifier — `HS256`/`HS384`/`HS512` for the HMAC secret, or the asymmetric family (`RS*`/`ES*`/`PS*`/`EdDSA`) for JWKS — and validates the token's `alg` before any key is used, so your IdP must sign with one of these and `alg: none` is always rejected.

**There is no auth on/off switch.** The JWT middleware always runs. A request with no token, or an invalid/expired one, falls back to the policy `default_role`; elevated access needs a valid token whose role is granted (or equals the policy `admin_role`). The privileged role and public access are **policy** settings, not config flags:

- **`admin_role`** (policy field, `"admin"` by default, exact case-sensitive match): the role granted full access and the `/v1/ops/*` gate. There is no separate `service` role, though the non-JWT `operator_key` (below) reaches the same surface without a token.
- **`default_role`** (policy field): set it to open public (no-token) access — roleless requests are evaluated as that role; remove it to close public access. Setting it equal to `admin_role` is allowed and makes every roleless request admin (including `/v1/ops/*`) — handy for local/dev, logged loudly on every node that loads such a policy, and not for production use. `/v1/ops/*` — raw SQL, pipe inspection, settings reload, schema, DLQ — is admin-only, and a pipe with no `allowed_roles` authorizes nobody but the admin role.

**Operator key (break-glass).** `auth.operator_key`, when set, is a non-JWT credential for the person running the deployment. A request presenting it — in the standard `Authorization: Operator <key>` header (forwarded verbatim by proxies, no collision with Bearer JWTs), or the `X-Operator-Key` alias — is authorized as a full-access platform operator without minting a JWT — the `/v1/ops/*` surface always, and the whole data plane while a policy is adopted (with none, data-plane requests evaluate the nil policy and are denied like any other). It is honored even when no policy is adopted, so an operator locked out by a wiped or broken `policies.json` can still trigger `POST /v1/ops/settings/reload` after fixing the file. It is matched with a constant-time comparison and is independent of `jwt_secret`/`jwks_url`; it takes precedence over any Bearer token on the same request. Because it is effectively an admin secret, load it from a secret store, serve it only over TLS, and leave it empty on deployments that do not need it.

See [API — Authentication](/api#authentication).

### Access Control Policy and Named Pipes

There are no boot-config keys for either. The policy is the settings directory's [`policies.json`](/settings-directory#policiesjson) (with its role registry in [`roles.json`](/settings-directory#rolesjson)) and the named pipes are its [`pipes.json`](/settings-directory#pipesjson); both are validated at boot and re-adopted on file change, `SIGHUP`, or `POST /v1/ops/settings/reload`. A leftover `policy:` or `pipes:` block in the YAML refuses boot by name.

### Settings Directory

The one boot-config key for the [Settings Directory](/settings-directory) is where it lives:

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `settings.dir` | `WH_SETTINGS_DIR` | *(required)* | Path to the settings directory. The server loads it at boot; the `wavehouse validate` and `wavehouse bootstrap` CLIs — like `wavehouse health` — resolve their input directly from the `[dir]` argument or `WH_SETTINGS_DIR` without loading this file, so a value set only in the YAML key validates on boot but not via the bare CLI. No default: a baked-in path would turn a missing mount into silent misconfiguration instead of an explicit operator choice. The container images preset it to `/app/settings` but ship no files there — bind-mount a directory you wrote with `bootstrap`. |

### OTel

The master switch is `otel.enabled`. When `true`, each signal (traces/metrics/logs) is then individually gated by its own `enabled` flag — you can run traces-only, logs-only, etc. Prometheus exposition is configured in its own top-level [`prometheus`](#prometheus) block; it operates independently and works in any combination with OTel (OTLP push only, Prometheus only, both, or neither). Stdout is always active for logs (the logger fans out to both stdout and the OTLP exporter), so logs never disappear regardless of collector state. gRPC exporters are lazy, so an unreachable collector does not block startup; transient export errors are surfaced via the OTel SDK's error handler. The `if err != nil` fallback in `main.go` only fires for genuine init errors (malformed options, resource construction failure).

**Sampling rates apply only to the OTLP push path.** Stdout always emits 100% of records — operators using a scraping-style pipeline (Promtail/Grafana Alloy → Loki, Vector, Fluent Bit, etc.) set the collection rate at the scraper, not the application. WaveHouse pushes telemetry to an OTel collector; the scraper world owns its own ingest policy. If you want to throttle OTLP volume for cost, lower the rates below. If you want to throttle Loki/Datadog Logs/etc., do it at that pipeline.

**Direct-to-cloud OTLP.** The OTLP destination is configured through the standard [`OTEL_EXPORTER_OTLP_*` environment variables](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) read by the OpenTelemetry SDK, not WaveHouse config: `OTEL_EXPORTER_OTLP_ENDPOINT` (always include a scheme — `https://` selects TLS via system root CAs, `http://` selects plaintext; a scheme-less `host:port` is **not** plaintext, it is mis-parsed and falls back to the default. With the endpoint unset that default is **TLS** to `localhost:4317`, so a plaintext local collector needs `http://localhost:4317` set explicitly or `OTEL_EXPORTER_OTLP_INSECURE=true`), `OTEL_EXPORTER_OTLP_HEADERS` for per-RPC auth, `OTEL_EXPORTER_OTLP_CERTIFICATE` to trust a custom/private CA, and `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_KEY` for mutual TLS. Custom/private CA and mutual TLS apply to the trace and metric signals only: the pinned gRPC logs exporter ignores the env TLS-cert vars (upstream bug [open-telemetry/opentelemetry-go#6661](https://github.com/open-telemetry/opentelemetry-go/issues/6661)), so the logs signal falls back to system roots — route logs through a local collector if your gateway uses a private CA. With these set, WaveHouse ships telemetry straight to a TLS-protected cloud gateway (Grafana Cloud's OTLP gateway, Honeycomb, etc.) — no sidecar required (a sidecar is still useful for egress queuing, batching, and tail-based sampling; it's just no longer mandatory). A malformed `OTEL_EXPORTER_OTLP_HEADERS` entry is logged and skipped by the OpenTelemetry SDK (fail-soft) rather than failing startup. Datadog has no public direct-to-cloud OTLP endpoint — use the local DDOT Collector path in the [deployment guide](/deployment#observability). See [Deployment → Observability](/deployment#observability) for worked Honeycomb / Grafana Cloud examples.

| YAML Key | Env Var | Default | Description |
| -------- | ------- | ------- | ----------- |
| `otel.enabled` | `WH_OTEL_ENABLED` | `false` | Master switch. When `false`, no signals are initialized regardless of the sub-toggles below. The OTLP endpoint, TLS, custom CA, mutual TLS, and auth headers are configured via the standard `OTEL_EXPORTER_OTLP_*` env vars (see the note above), not a WaveHouse key. |
| `otel.traces.enabled` | `WH_OTEL_TRACES_ENABLED` | `true` | Export traces via OTLP gRPC. |
| `otel.traces.sample_rate` | `WH_OTEL_TRACES_SAMPLE_RATE` | `1.0` | Head-based trace sampling rate in `[0.0, 1.0]`. `1.0` exports every trace; `0.0` exports none. Defaults to 100% (matches the OpenTelemetry SDK default); lower it for high-QPS production services where collector or backend cost is a concern. Best practice is "100% at the source, downsample at the collector" via tail-based sampling. Validated at config load. |
| `otel.metrics.enabled` | `WH_OTEL_METRICS_ENABLED` | `true` | Export metrics + Go runtime metrics via OTLP gRPC. Periodic reader interval is fixed at 15s. Metrics are pre-aggregated so there is no sampling knob. |
| `otel.logs.enabled` | `WH_OTEL_LOGS_ENABLED` | `true` | Export logs via OTLP gRPC. Disabling this leaves stdout logging untouched — the OTel logger provider is simply not registered. |
| `otel.logs.sample_rate` | `WH_OTEL_LOGS_SAMPLE_RATE` | `1.0` | OTLP export rate for `DEBUG`/`INFO` records, in `[0.0, 1.0]`. Validated at config load. `WARN` and `ERROR` records always export at 100% — dropping them silently during incidents is too dangerous to expose as a knob. **Stdout always receives 100% of records regardless of this rate** (see the scraper note above). |

### Prometheus

Prometheus exposition is its own top-level config block, independent of `otel.*`. Operators using a scrape-based pipeline (Grafana Alloy, Mimir, the standalone Prometheus server) can leave the entire `[otel]` block at its `enabled: false` default and turn on only `prometheus.enabled` — no OTLP collector required. Conversely, OTLP push and Prometheus can both be on at once (the underlying OTel MeterProvider drives both readers, same `Meter()` API). Disabled by default since enabling adds an unauthenticated endpoint, so opt-in is explicit.

| YAML Key | Env Var | Default | Description |
| --- | --- | ------- | ----------- |
| `prometheus.enabled` | `WH_PROMETHEUS_ENABLED` | `false` | Expose a Prometheus-format `/metrics` endpoint. Works standalone (no OTel push) or alongside `otel.metrics.enabled`. |
| `prometheus.path` | `WH_PROMETHEUS_PATH` | `/metrics` | URL path. Must start with `/`, and may not collide with a reserved probe path (`/livez`, `/readyz`, `/healthz`, `/health`, `/ready`). When `port` is `0` (mounted on the main server) it also may not be `/v1` or sit under `/v1/`, which would shadow the authenticated API. An invalid path fails validation at startup. |
| `prometheus.port` | `WH_PROMETHEUS_PORT` | `0` | Listener port. `0` mounts the endpoint on the main API server (`server.port`) — simplest, no extra port to expose. Non-zero spins up a dedicated HTTP listener, which lets you firewall metrics off the public API surface (common production posture). Must not equal `server.port` when non-zero. |

### Logging

| Env Var | Default | Description |
| ------- | ------- | ----------- |
| `WH_LOG_LEVEL` | `INFO` | Minimum log level. One of `DEBUG`, `INFO`, `WARN`, `ERROR` (case-insensitive). Applies to both stdout and (when OTel is enabled) the OTLP log exporter. See `otel.logs.sample_rate` above for the OTLP export rate. |

## Example Config File

Every key, with its default. Save the YAML as `config.yaml` next to the binary (or point `WH_CONFIG` at it), or supply the same settings as environment variables — e.g. an `env_file` in Compose:

<Tabs syncKey="cfg">
<TabItem label="YAML">

```yaml
data_dir: ./data         # nats → ./data/nats, pebble → ./data/pebble

server:
  port: 8080
  shutdown_timeout: 10

clickhouse:
  password: ""           # the only ClickHouse key here — addr, ports, database,
                         # username, query_timeout are settings (config.json)

cache:
  l1_max_cost: 67108864

auth:
  jwt_secret: change-me-in-production   # jwks_url and role_claim are settings (config.json)
  operator_key: "" # non-JWT full-access operator credential (Authorization: Operator <key>, or X-Operator-Key); empty disables

settings:
  dir: ./settings        # REQUIRED — no default: settings directory (roles/
                         # policies/pipes/config .json), validated at boot,
                         # reloaded on file change, SIGHUP, or
                         # POST /v1/ops/settings/reload; create one with
                         # `wavehouse bootstrap ./settings` (the access-control
                         # policy, the named pipes, and the dedupe, dlq, query,
                         # schema, stream, cors keys live there)

otel:
  enabled: false         # master switch — set true to export via OTLP gRPC
  # Endpoint, TLS, custom CA, mTLS, and auth headers come from the standard
  # OTEL_EXPORTER_OTLP_* env vars (OTEL_EXPORTER_OTLP_ENDPOINT=https://host:port,
  # OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=KEY), read by the OTel SDK.
  traces:
    enabled: true
    sample_rate: 1.0     # head-based, [0.0, 1.0]; tune down for high QPS
  metrics:
    enabled: true        # OTLP push for metrics
  logs:
    enabled: true
    sample_rate: 1.0     # DEBUG/INFO OTLP rate; WARN+ always 100%, stdout always 100%

prometheus:
  enabled: false         # independent of otel — works standalone for scrape
  path: /metrics
  port: 0                # 0 = mount on server.port; non-zero = sidecar listener
```

</TabItem>
<TabItem label="Environment">

```ini
WH_DATA_DIR=./data

WH_SERVER_PORT=8080
WH_SERVER_SHUTDOWN_TIMEOUT=10

WH_CH_PASSWORD=

WH_CACHE_L1_MAX_COST=67108864

WH_AUTH_JWT_SECRET=change-me-in-production
WH_AUTH_OPERATOR_KEY=

# Required, no default: `wavehouse bootstrap ./settings` writes a starter
# directory (edit clickhouse.addr in its config.json if ClickHouse is not on
# localhost:9000; its policies.json is empty, so add a policy before
# expecting any request through). The container images preset WH_SETTINGS_DIR=/app/settings
# and ship no files there; you mount a directory at that path instead.
# (Comments stay on their own line — an env_file does not strip inline ones.)
WH_SETTINGS_DIR=./settings

WH_OTEL_ENABLED=false
WH_OTEL_TRACES_ENABLED=true
WH_OTEL_TRACES_SAMPLE_RATE=1.0
WH_OTEL_METRICS_ENABLED=true
WH_OTEL_LOGS_ENABLED=true
WH_OTEL_LOGS_SAMPLE_RATE=1.0

WH_PROMETHEUS_ENABLED=false
WH_PROMETHEUS_PATH=/metrics
WH_PROMETHEUS_PORT=0
```

</TabItem>
</Tabs>