# Behind a reverse proxy

> **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) · [Configuration](https://wavehouse.dev/configuration.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) · [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/reverse-proxy) · [Docs index](https://wavehouse.dev/llms.txt)

---

WaveHouse serves plain HTTP on `:8080` and is meant to run **behind a reverse proxy, CDN, or tunnel** for any internet-facing deployment. It deliberately does *not* terminate TLS or manage a server certificate, rate-limit by IP, or mitigate slow-connection attacks — those are the proxy's job, and pushing them out keeps WaveHouse from growing a second, redundant set of knobs you'd have to tune in two places. This page covers everything that behaves differently behind a proxy: TLS, request-body limits, Server-Sent Events, header forwarding, and health probes.

## Division of responsibility

WaveHouse and your proxy are layers of one system, not substitutes. The proxy owns the network edge; WaveHouse owns auth and ships fixed safety backstops so a missing or loose proxy setting can't take it down.

| Concern | WaveHouse | Your reverse proxy |
| --- | --- | --- |
| TLS termination / certificates | ✗ (plain HTTP on `:8080`) | ✓ |
| Per-IP rate limiting, connection caps | ✗ | ✓ |
| Slow-loris / slow-body mitigation | ✗ | ✓ |
| Request-body size limit | Fixed internal backstop (1 MiB parameter/AST bodies, 16 MiB bulk payload bodies) | Tunable outer limit |
| Authentication (JWT) | ✓ (validates / resolves role) | Pass through |
| CORS | ✓ (`cors.allowed_origins`, [settings directory](/settings-directory)) | Pass through (don't double it) |
| Health probes | ✓ (serves `/livez`, `/readyz`, `/v1/health`) | Route / expose appropriately |

:::note[Defense in depth, not either/or]
The body-size caps below exist in **both** layers on purpose. Your proxy limit is the tunable first line you size to your deployment; WaveHouse's in-code cap is the backstop that bounds what a **single request** can allocate even if the proxy limit is missing (Caddy and Cloudflare don't cap by default), too loose, or bypassed by something reaching `:8080` directly. It bounds exposure, not outcomes: the handler buffers the whole body and `bytes.Buffer` grows by doubling, so one request can allocate past the cap before it trips, and a deployment whose memory limit sits below that peak can still be pushed over by a single request sized just under the limit. Bounding the *total* across concurrent requests is still the proxy's job — see the note under [Request-body size limits](#request-body-size-limits).
:::

## TLS

WaveHouse serves plain HTTP and terminates no TLS of its own: it manages no server certificate and no inbound key. Terminate TLS at the proxy and forward cleartext to `http://<wavehouse-host>:8080`.

:::caution[`clickhouse.http_scheme` and `clickhouse.tls` are different settings]
The `clickhouse.http_scheme` and `clickhouse.tls` keys in the [settings directory](/settings-directory#clickhouse) control TLS on the **WaveHouse → ClickHouse** hops, not the inbound edge. They have nothing to do with terminating TLS for your clients — that's the proxy's job.
:::

## Path prefixes

WaveHouse always serves its routes at the root of whatever reaches it — `/v1/query`, `/v1/stream`, `/livez`. There is no configurable base path, and there won't be one: mounting a service under a prefix is the proxy's job, and the server having its own opinion about it is a second place for the two to disagree.

So you can expose WaveHouse under a prefix — `https://app.example.com/api/wavehouse/…`, one origin fronting several services, or a backend-for-frontend (BFF) adding auth ahead of the data API — as long as the proxy **strips the prefix** before forwarding:

<Tabs>
<TabItem label="nginx">

```nginx
location /api/wavehouse/v1/stream {
    proxy_pass         http://127.0.0.1:8080/v1/stream;
    proxy_http_version 1.1;
    proxy_set_header   Connection "";
    proxy_buffering    off;
}

# A proxy_pass carrying a URI part — even a bare `/` — replaces the matched
# location prefix, which is what strips /api/wavehouse/ here. Omit the URI
# and nginx forwards /api/wavehouse/v1/query verbatim, which WaveHouse 404s.
location /api/wavehouse/ {
    proxy_pass       http://127.0.0.1:8080/;
    proxy_set_header Host $host;
}
```

</TabItem>
<TabItem label="Caddy">

```text
# handle_path (not handle) strips the matched prefix.
handle_path /api/wavehouse/* {
    reverse_proxy 127.0.0.1:8080 {
        flush_interval -1   # SSE: no response buffering
    }
}
```

</TabItem>
</Tabs>

:::note[Kubernetes `ingress-nginx` does not strip by default]
The ingress controller forwards the full path unless you ask it to rewrite. Pair a capture-group path (`/api/wavehouse(/|$)(.*)` with `pathType: ImplementationSpecific`) with the `nginx.ingress.kubernetes.io/rewrite-target: /$2` annotation, or the prefix arrives at WaveHouse unstripped.
:::

Point the SDK at the prefixed URL and it does the rest — `createClient({ baseURL: 'https://app.example.com/api/wavehouse' })` sends both REST calls and SSE streams under the prefix ([SDK → Serving under a path prefix](/sdk#serving-under-a-path-prefix)).

## Request-body size limits

WaveHouse caps the inbound request body it will decode, as a memory-safety backstop:

| Endpoints | Cap | Over-cap response |
| --- | --- | --- |
| `POST /v1/query`, `GET/POST /v1/pipes/{name}` (parameter / AST bodies) | **1 MiB** | `413 {"error":"request body exceeded 1048576 bytes"}` |
| `POST /v1/ingest`, `POST /v1/ops/query` (bulk payload bodies) | **16 MiB** | `413 {"error":"request body exceeded 16777216 bytes"}` |

These caps are **fixed and not configurable** — they aren't a tuning knob, they're an invariant. A JSON request body amplifies roughly an order of magnitude when decoded into memory (a large array or object of small values explodes into Go's in-memory representation), so an *uncapped* decoder on a public endpoint is a single-request out-of-memory vector. A query or pipe-parameter body is bounded by nature — a real one is far under 1 MiB even with a large `in`-list — so the 1 MiB cap is generous headroom that never binds legitimate use.

Set your own **outer** limit at the proxy, sized to your real needs:

- **nginx** — `client_max_body_size 16m;`
- **Caddy** — `request_body { max_size 16MB }` (Caddy has **no** body limit by default)
- **Cloudflare** — a plan-based upload limit applies (100 MB on Free/Pro); it can't be lowered below the plan on those tiers.

The effective limit is the smaller of the proxy's and WaveHouse's. For ingest, WaveHouse's 16 MiB is the ceiling — raising the proxy above it won't help, because the server rejects first. The cap applies to **every** body shape, NDJSON included: WaveHouse reads the whole body before parsing it, so a line-framed batch is bounded by the same 16 MiB cap as a JSON array. Split an upload larger than the cap across several requests. See [Batch Ingest](/api#batch-ingest).

:::note[Size the container for concurrency, not just for one request]
Ingest reads the whole body into memory before parsing it, so peak memory per in-flight request tracks the **body**, not one record — and it is a multiple of the body. `bytes.Buffer` grows by doubling to the next power of two and then once more to probe for EOF, so any body above ~16 MiB − 512 B lands in a **32 MiB** allocation (a 9 MiB body already costs 16 MiB), and that final doubling *copies*: the old 16 MiB array and the new 32 MiB one are both live while it runs, a transient of roughly **48 MiB — 3× the body — for a single request**. Hitting the cap does not avoid it, since a rejected over-cap body allocates the same 32 MiB before the `413`. On top of that sits the decoded Go value of the record in flight; ingest still decodes record by record, so for a batch of small rows that term is minor. A single-object body **is** one record, and the decode runs *before* schema validation: the body is materialized as a `map[string]any` carrying whatever keys it arrived with, and only then is an unknown column rejected. So a flat object of many tiny keys is fully in memory before anything bounds it — a 16 MiB body of ~1.4M one-byte values decodes to well over 100 MiB, which *is* the order-of-magnitude amplification quoted above. (A single oversized array element or NDJSON line costs the same, bounded by 16 MiB and 10 MiB respectively.) The 3× figure covers the buffering term alone. Nothing bounds the *total* across concurrent uploads either, so size for concurrent uploads × **at least 10×** the largest body you accept — an array-valued key amplifies harder still, around 18× measured, and nesting goes higher — or lower the proxy's body cap for `/v1/ingest` below 16 MiB. Size the container memory limit and the proxy's body cap together, and cap concurrency at the proxy if you accept large batches. A server-side bound on total in-flight bytes is tracked in [#544](https://github.com/Wave-RF/WaveHouse/issues/544).
:::

## Server-Sent Events (SSE)

`GET /v1/stream` is a long-lived `text/event-stream` connection (see the [API reference](/api#get-v1stream--server-sent-events-stream)). Proxies classically break SSE two ways — response buffering, which you must disable, and idle timeouts, which WaveHouse's keepalive comment now handles for you.

### 1. Disable response buffering

A proxy that buffers the upstream response will hold events until a buffer fills or the connection closes, so clients receive nothing in real time — the whole point of the stream is lost. WaveHouse flushes after every event, but the proxy has to forward immediately too.

- **nginx** — WaveHouse sets `X-Accel-Buffering: no` on the stream response, so nginx won't buffer it out of the box (nginx strips the header before it reaches the client). Adding `proxy_buffering off;` on the stream location is an equivalent belt-and-braces if you'd rather set it explicitly.
- **Caddy** — `flush_interval -1` on the `reverse_proxy` for the stream route.
- **Cloudflare** — passes `text/event-stream` through without buffering.

### 2. Keepalive and idle timeouts

WaveHouse sends a minimal SSE keepalive comment — a bare `:` line — on every stream (alongside the `: connected` comment on open), so even an otherwise-quiet connection keeps producing bytes between events and an intermediary's idle timeout never trips on a slow table. By default each connection is nudged at least every **30 seconds** ([`stream.keepalive_interval`](/settings-directory#streaming), a hot-reloadable settings-directory key), comfortably under the common 55–60s proxy and load-balancer idle windows (nginx's default `proxy_read_timeout` is 60s) and Cloudflare's ~120s edge. A single ticker per tenant drives the keepalives of every one of its connections; the comment is invisible to clients — `EventSource` and any spec-compliant parser ignore SSE comment lines, and `curl` simply prints them.

This resolved [#226](https://github.com/Wave-RF/WaveHouse/issues/226): before it, a quiet stream sent nothing after `: connected`, so an intermediary's idle timeout reset the connection — Cloudflare's edge dropped quiet streams about every two minutes in dogfooding. Browsers masked it (`EventSource` auto-reconnects and resumes via `Last-Event-ID` gap-fill); `curl` and backend consumers saw repeated drops, each re-running gap-fill.

**Why a keepalive works without raising any timeout:** nearly every proxy idle timeout is a *read* timeout measured as time-since-the-last-byte from the upstream, and **any** byte — including a keepalive comment — resets it. So WaveHouse only has to write more often than the tightest such timeout in the path; it does not need to be anywhere near as frequent as the events themselves. The exceptions are absolute, total-duration caps that traffic does *not* reset (some serverless and managed-LB request limits) — a keepalive can't save a stream from those, so they need an operator config change or a client reconnect. Both kinds are tabulated in [Idle timeouts by provider](#idle-timeouts-by-provider) below. Pick a [`keepalive_interval`](/settings-directory#streaming) safely under the tightest idle timeout your traffic crosses; the 30s default already clears the common self-hosted and reverse-proxy cases (a handful of CDNs and gateways are tighter — see the table).

Raising the proxy's read/idle timeout for `/v1/stream` is therefore no longer required, though it's harmless if you'd rather not lean on the keepalive's margin:

- **nginx** — optionally `proxy_read_timeout 3600s;` on the `/v1/stream` location.
- **Caddy** — `reverse_proxy` has no response timeout by default, so nothing is needed.
- **Cloudflare** — the edge idle timeout isn't configurable on standard plans; the keepalive is what keeps a quiet stream alive.
- **Envoy / Istio** — the default **15s route timeout is a total cap, not an idle timeout**, so a keepalive can't help: set the route `timeout: 0` for `/v1/stream` regardless of the keepalive.

### 3. Forward the credential, `since`, and `Last-Event-ID` — and don't redirect

The SDK streams over `fetch` and sends the JWT as an `Authorization` header, the same as on every other request. A client that can't set headers — a hand-rolled browser `EventSource`, say — can still pass it as `GET /v1/stream?token=<jwt>` (it's stripped after extraction, whichever credential wins, so it stays out of WaveHouse's own logs; an `Authorization` header always takes precedence). Make sure your proxy:

- forwards the `Authorization` request header — check this first if streams authenticate from `curl` but not through the proxy;
- **allows `Authorization` (and `Last-Event-ID`) on `OPTIONS /v1/stream`, if the proxy answers CORS itself.** `Authorization` is not CORS-safelisted, so a credentialed cross-origin browser stream now preflights *before it connects* — where a bare `EventSource` never preflighted at all, its request not being a `fetch()`. An **uncredentialed** stream preflights from the first reconnect *after it has received an event* — the resumption header only exists once there is an id to resume from — because `Last-Event-ID` isn't safelisted either. Reject the preflight and a credentialed stream never opens; reject it on the resumption path and the stream is dead from its first drop onward — the id is never cleared, so every attempt re-sends `Last-Event-ID`, fails the same preflight before the request goes out, and re-dials forever without getting back on ([#471](https://github.com/Wave-RF/WaveHouse/issues/471)). WaveHouse's own CORS already allow-lists both;
- forwards the query string (`since`, and `token` if any client still uses it) — most do by default;
- forwards the `Last-Event-ID` request header so reconnects resume from the right point (it's already allow-listed in WaveHouse's CORS preflight);
- does not redirect `/v1/stream` for authenticated clients. The SDK's streaming transport refuses a redirect on any stream carrying a credential (an `auth` token or `options.headers`), because a cross-origin hop strips `Authorization` — and this endpoint answers an unauthenticated caller with a reduced view rather than an error — while forwarding your other headers to wherever it points. An uncredentialed stream follows redirects normally;
- does not log the full URL, for any client still sending `token` in it.

## Idle timeouts by provider

A reference for picking [`stream.keepalive_interval`](/settings-directory#streaming). These defaults change over time — each row links its source so you can re-check. The **30s default clears every "reset-by-traffic" row below except the 30s-and-tighter rows** (Azure Application Gateway 20s, Azure Front Door 30s, CloudFront 30s, Fastly ~10s); if your edge is one of those, lower the interval. (Absolute caps like API Gateway's 29s aren't fixable by any keepalive — see the second table.) Values are defaults for a streaming/`text/event-stream` response and may differ on enterprise tiers.

### Reset-by-traffic idle timeouts — a keepalive handles these

The timer resets on every byte from the upstream, so a keepalive fired more often than the value keeps a quiet stream alive. Set `keepalive_interval` under the tightest one your traffic crosses.

| Layer | Default | How to change it | Source |
| --- | --- | --- | --- |
| **nginx** `proxy_read_timeout` | **60s** | `proxy_read_timeout 3600s;` on the stream location | [nginx docs](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout) |
| **ingress-nginx** (Kubernetes) | **60s** | `nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"` annotation | [ingress-nginx ConfigMap](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#proxy-read-timeout) |
| **Apache** `mod_proxy` `ProxyTimeout` | **60s** (inherits core `Timeout`) | `ProxyTimeout 3600` | [Apache core `Timeout`](https://httpd.apache.org/docs/current/mod/core.html#timeout) |
| **HAProxy** `timeout server` / `timeout client` | no built-in default (unset = infinite + startup warning); examples ship **~50s** | set both, e.g. `timeout server 1h` (`timeout tunnel` is WebSocket-only, not SSE) | [HAProxy docs](https://docs.haproxy.org/2.8/configuration.html#4.2-timeout%20server) |
| **Caddy** `reverse_proxy` | none (streaming auto-flushed) | only buffering matters: `flush_interval -1` | [Caddy reverse_proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy) |
| **Traefik** | no response-write timeout (`writeTimeout` 0); streams flushed immediately | nothing needed for SSE | [Traefik entrypoints](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/) |
| **Envoy / Istio** `stream_idle_timeout` | **5m** (300s) | the *idle* timeout a keepalive resets — but also set the route `timeout: 0`, see note below | [Envoy timeouts](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts) |
| **AWS ALB / Classic ELB** idle | **60s** (range 1–4000s) | `idle_timeout.timeout_seconds` | [ALB idle timeout](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html#connection-idle-timeout) |
| **AWS NLB** idle | **350s** (TCP; range 60–6000s) | configurable per listener | [NLB idle timeout](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#connection-idle-timeout) |
| **AWS CloudFront** origin read | **30s** (max **120s**) | Origin "Response timeout" 1–120s | [CloudFront origin settings](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesOrigin.html#DownloadDistValuesOriginResponseTimeout) |
| **Azure Application Gateway** request timeout | **20s** (range 1–86400s) | backend HTTP setting | [App Gateway backend settings](https://learn.microsoft.com/en-us/azure/application-gateway/configuration-http-settings#request-timeout) |
| **Azure Front Door** origin response | **30s** (range 16–240s) | Standard/Premium only | [Front Door timeout](https://learn.microsoft.com/en-us/troubleshoot/azure/front-door/troubleshoot-issues) |
| **Azure Load Balancer** (L4) | **240s** (4 min; range 4–100 min) | rule idle-timeout setting | [Azure LB idle](https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-tcp-idle-timeout) |
| **Heroku** router | **55s** rolling window | not configurable — keep `keepalive_interval` < 55s | [Heroku request timeout](https://devcenter.heroku.com/articles/request-timeout) |
| **Cloudflare** (proxied) | **~120s** (524 error) | Enterprise only; otherwise rely on the keepalive | [Cloudflare connection limits](https://developers.cloudflare.com/fundamentals/reference/connection-limits/) |
| **Cloudflare Tunnel** (`cloudflared`) | **~120s** edge idle reset (same mechanism as a 524; the ~125s we observed) | Enterprise only — the origin `keepAliveTimeout` (90s) is a *different*, idle-pool setting that won't keep a stream open | [Cloudflare connection limits](https://developers.cloudflare.com/fundamentals/reference/connection-limits/) |
| **Fastly** `first_byte_timeout` / `between_bytes_timeout` | **15s** / **~10s** | raise on the backend | [Fastly first-byte timeout](https://www.fastly.com/documentation/reference/vcl/variables/backend-connection/bereq-first-byte-timeout/) |

:::note[OS TCP keepalive doesn't substitute for this]
Linux's [`tcp_keepalive_time`](https://man7.org/linux/man-pages/man7/tcp.7.html) defaults to **7200s (2 hours)**, is off per-socket unless the app opts in, and its probes don't reliably reset L7 proxy read timers. The application-level SSE comment is the right tool — that's what `keepalive_interval` controls.
:::

### Absolute / total-duration caps — a keepalive does **not** help

These limit the *whole* request regardless of traffic, so no keepalive extends them. You must raise the cap or have the client reconnect — the SDK's stream transport and browser `EventSource` both do this automatically, resuming via `Last-Event-ID`.

| Layer | Cap | What to do | Source |
| --- | --- | --- | --- |
| **Envoy / Istio** route `timeout` | **15s** (total) | set the route `timeout: 0` for `/v1/stream` | [Envoy timeouts](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts) |
| **GCP** external ALB backend service | **30s** (complete-response) | raise the backend service timeout (recommended max 86400s) | [GCP backend service](https://docs.cloud.google.com/load-balancing/docs/backend-service#timeout-setting) |
| **GCP Cloud Run** request timeout | **300s** (max 3600s) | raise the service request timeout, then reconnect | [Cloud Run timeout](https://docs.cloud.google.com/run/docs/configuring/request-timeout) |
| **AWS API Gateway** (REST) | **29s** default max (raisable for Regional/private since 2024; hard for edge-optimized) | response-streaming mode, raise the timeout, or a different front door | [API Gateway quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-execution-service-limits-table.html) |
| **Vercel** functions | edge: 25s to first byte, ~300s stream; serverless up to 800s | rely on client reconnect; not an idle timeout | [Vercel limits](https://vercel.com/docs/functions/limitations) |
| **Railway** | 5m idle (reset) + **15m hard cap** | keepalive handles the 5m idle; reconnect past 15m | [Railway SSE](https://docs.railway.com/guides/sse-vs-websockets) |

## Header and auth forwarding

- **`Authorization`** — forward verbatim. WaveHouse validates a `Bearer` JWT (resolving the role from it) or an `Operator <key>` [operator credential](/access-control#operator-key). Most proxies forward `Authorization` unchanged, so it's the transport to prefer for the operator key — the exception to check for is an auth-terminating layer that consumes or rewrites the header (e.g. a gateway doing its own auth).
- **`X-Operator-Key`** (optional) — only relevant if you present the [operator key](/access-control#operator-key) via this alias header instead of `Authorization: Operator <key>`. Custom request headers are forwarded by default, but confirm your proxy doesn't strip it — and note nginx silently drops header names containing **underscores** (this one uses hyphens, so it's fine as named). The `Authorization` form needs none of this.
- **`X-Tenant-ID`** — **strip it at the edge unless you are deliberately using it.** It selects the [tenant](/deployment#multi-tenant-deployments) (absent means tenant `0`), and a settings directory that isn't nested defines no tenant other than `0`, so a gateway or mesh that stamps `X-Tenant-ID` with any other value turns every `/v1` route outside `/v1/ops/*` into a `404` (a `400` when the value is not a tenant id at all, a dotted hostname, say) — the SDK's `/v1/health` reachability ping included. It is resolved before authentication and never derived from the token, so a proxy that owns tenant selection must **replace** a client-supplied value, not add to it: two `X-Tenant-ID` lines are refused with `400`. That is a stricter version of the append-vs-replace hazard of `Content-Type` below — here even two agreeing lines are refused. Over a [nested settings directory](/deployment#the-nested-settings-directory) the proxy owns the tenant: derive it from the host or path and set it on **every** request, the CORS `OPTIONS` preflight included — the browser sends no custom header there, and WaveHouse reads the preflight's tenant the same way ([per-tenant CORS](/deployment#multi-tenant-deployments)). The name is hyphenated, so nginx's underscore rule does not apply.
- **`X-Forwarded-For` / `X-Forwarded-Proto` / `Host`** — set these for your own logs and any upstream that reads them. WaveHouse does not currently derive a client IP from `X-Forwarded-For` (see the caution below); forwarding it is good hygiene and is what the trusted-proxy client-IP work ([#333](https://github.com/Wave-RF/WaveHouse/issues/333)) will consume.

:::caution[Don't expose `:8080` directly]
WaveHouse does **not** derive a client IP from forwarded headers — it does no per-IP logic (rate limiting and IP allow/deny are the proxy's job) and does not trust `X-Forwarded-For` / `X-Real-IP` / `True-Client-IP` to rewrite the connection's source address. So a forged forwarded header has no effect on WaveHouse, and `r.RemoteAddr` (what OpenTelemetry records as the peer) is the honest immediate peer — your proxy, when one is in front. Still, don't expose `:8080` to untrusted clients: bind WaveHouse to a private interface or firewall the port so the proxy is the only path in. Capturing the real client IP in WaveHouse's own traces and logs — trusted-proxy-aware, so it can't be spoofed — is tracked in [#333](https://github.com/Wave-RF/WaveHouse/issues/333).
:::

- **`Content-Type`** — forward it **verbatim**; ingest reads the format from it and refuses anything it cannot read ([details](/api#post-v1ingesttabletable--ingest-data)). Appending rather than replacing is safe *only* if the proxy sends a second header **line** — those are resolved together and accepted when they agree. A proxy that **merges** duplicates into one comma-joined value (Envoy's `append: true`, and some WAF rewrite rules) produces `application/json, application/json`, which is a `415` on every ingest **even though both halves agree**: a comma-joined value is refused unless the value as a whole still parses as one media type. Replacing it is worse than merging, because it fails silently: an NDJSON batch declared `application/json` is read as the single object it starts with and the remaining lines are dropped behind a `200` ([#561](https://github.com/Wave-RF/WaveHouse/issues/561)) — no error to alert on. If ingest starts returning `415` fleet-wide after a proxy change, look here first.

- **CORS** — WaveHouse applies its own CORS from the settings directory's `cors.allowed_origins` — over a nested directory, [the list of the tenant the request names](/deployment#multi-tenant-deployments), the preflight following the `X-Tenant-ID` the proxy stamps on `OPTIONS`. Let one layer own CORS: either pass it through the proxy untouched (recommended), or strip it from WaveHouse and do it at the proxy — not both, or browsers see duplicate `Access-Control-Allow-Origin` headers and reject the response.

- **Response caching** — don't let a shared cache (a CDN or a caching proxy) store responses from the authenticated routes. What a read returns depends on the caller's role and claims, and a pipe read — a `GET`, so cacheable by default — sends no `Cache-Control` and no `Vary: Authorization` to say so. A cache that follows RFC 9111 already refuses to store the response to a request carrying `Authorization`; a credential sent as `?token=` or `X-Operator-Key` is not covered by that rule, so exclude those routes explicitly. The `Vary: X-Tenant-ID` on a [tenant-resolved](/deployment#multi-tenant-deployments) response keys it on the tenant only and says nothing about the caller.

## Fencing the admin surface

Every admin-gated endpoint — raw SQL, pipe inspection, settings reload, schema discovery, DLQ stats — lives under the single `/v1/ops/` prefix, so one proxy rule covers the entire management surface: an nginx `location /v1/ops/ { deny all; }` on the public vhost (serving it only on an internal listener), or an IP allowlist on that prefix. If you deny the prefix outright, keep an internal listener that still serves it — `POST /v1/ops/settings/reload` with the [operator key](/access-control#operator-key) is the one HTTP path that re-adopts a fixed `policies.json` without a restart, and a blanket fence with no internal route removes that break-glass too. Under a [path prefix](#path-prefixes), fence `<prefix>/v1/ops/` instead. This is belt-and-braces, not a requirement — the server's `RequireAdmin` gate remains the authorization boundary and denies non-admin callers on its own — but keeping the management surface off the public vhost removes it from unauthenticated reach entirely.

## Health probes

WaveHouse serves Kubernetes-convention probes on `:8080` (full behavior in [Deployment → Health Checks](/deployment#health-checks)):

- **`/livez`** — liveness; sticky-200 after first successful boot. Does not touch ClickHouse.
- **`/readyz`** — readiness; pings every open ClickHouse pool on **every** call. Point your load balancer's (internal) health check here so it routes around an instance whose ClickHouse is unreachable.
- **`/healthz`** — permanent alias of `/livez`.
- **`/v1/health`** — the SDK's content-free liveness ping; mirrors `/livez` (200 once booted) and never touches ClickHouse.

:::caution[Recommended: keep the bare probe paths off the public vhost]
Expose only **`/v1/health`** (and your API) to the internet — route `/livez`, `/readyz`, and `/healthz` on a private/internal listener, not the public proxy. Your orchestrator still reaches them the internal way regardless: kubelet probes the container directly on `:8080`, and a load balancer health-checks the backend — neither goes through the public proxy. Two reasons to keep them internal: `/readyz` pings every open ClickHouse pool on every call, so a public `/readyz` lets an unauthenticated flood turn into per-request backend pings; and the probe bodies disclose boot and readiness state — each unanswering pool's ClickHouse address, database and user, and over a nested directory the failing tenant's id. `/v1/health` is the safe public liveness endpoint because it answers the same "is this server up" question without touching ClickHouse — it's what the SDK's `wh.sys.health()` calls.
:::

## Timeouts and slow links

A slow *legitimate* client (think an IoT device on a satellite link) and a slow-loris *attacker* are indistinguishable at the byte level, so no read/body timeout at any layer cleanly separates them. WaveHouse intentionally sets no aggressive inbound read timeout so it can durably accept slow uploads. If you add a `client_body_timeout` (or similar) at the proxy to shed abusive connections, know that too tight a value also kills legitimate slow uploads. The durable way to keep slow-link abuse out is **authentication plus per-source quotas**, not duration limits.

## Example configurations

Minimal, SSE-aware configs. Adjust hostnames, certificate paths, and the upstream address for your setup.

<Tabs>
<TabItem label="nginx">

```nginx
server {
    listen 443 ssl;
    server_name wavehouse.example.com;

    # ssl_certificate     /etc/ssl/wavehouse.crt;
    # ssl_certificate_key /etc/ssl/wavehouse.key;

    # Outer body limit. WaveHouse's own backstop: 1 MiB parameter/AST bodies,
    # 16 MiB bulk payload bodies; this can only make the effective limit tighter.
    client_max_body_size 16m;

    # SSE: stream events immediately (buffering off). The long read timeout is
    # optional — WaveHouse's keepalive comment (~30s default, #226) keeps quiet streams
    # alive on its own — but harmless.
    location /v1/stream {
        proxy_pass            http://127.0.0.1:8080;
        proxy_http_version    1.1;
        proxy_set_header      Connection "";
        proxy_buffering       off;
        proxy_read_timeout    3600s;
        proxy_set_header      Host $host;
        proxy_set_header      X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header      X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass       http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

</TabItem>
<TabItem label="Caddy">

```text
# Caddyfile
wavehouse.example.com {
    # TLS is automatic.

    # Outer body limit (WaveHouse backstop: 1 MiB parameter/AST, 16 MiB bulk payload).
    request_body {
        max_size 16MB
    }

    # SSE: disable buffering so events flush immediately. Caddy has no default
    # response timeout, so quiet streams already survive.
    reverse_proxy /v1/stream* 127.0.0.1:8080 {
        flush_interval -1
    }

    reverse_proxy 127.0.0.1:8080
}
```

</TabItem>
<TabItem label="Cloudflare Tunnel">

```yaml
# cloudflared config.yml — no inbound ports opened; the tunnel dials out.
tunnel: <TUNNEL-ID>
credentials-file: /etc/cloudflared/<TUNNEL-ID>.json

ingress:
  - hostname: wavehouse.example.com
    service: http://localhost:8080
  - service: http_status:404
```

Cloudflare terminates TLS at its edge and forwards to the tunnel. Two caveats:

- A plan-based upload size limit applies (100 MB on Free/Pro) — it can't be lowered below the plan on those tiers, and WaveHouse's own caps still apply underneath.
- The edge resets idle connections after ~120s, but WaveHouse's keepalive comment (~30s by default, [#226](https://github.com/Wave-RF/WaveHouse/issues/226)) keeps even a quiet SSE stream alive, so they're no longer dropped — no reconnect loop needed just to survive a slow table.

</TabItem>
</Tabs>