---
title: Shared proxy
summary: "One proxy for a team on a private network, with a token and server-side keys."
canonical: https://docs.caveman.so/docs/proxy/shared
layer: proxy
license: BSL-1.1
capability: engine
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# Shared proxy

> One proxy for a team on a private network, with a token and server-side keys.
The same binary that runs on your laptop runs as one service for a team: bind it to an address the team can
reach, gate it with a shared token, and keep the provider keys on the server so nobody has to hold one. It
stays a single-operator proxy with a single set of credentials, which is the whole design.

Running it for yourself? [The proxy](/docs/proxy) is the loopback path and needs none of this.

The inbound token gate, the `cave_proxy_unauthorized_total` counter, and auth-before-routing ship from
binary release bin-v1.1.7. Run `caveman setup --install`, then check that `~/.caveman/bin/.bin-manifest.json`
reads `"release": "bin-v1.1.7"` or later before exposing a listener. `caveman version` reports the release the
CLI pins, not the one on disk. On an older binary a non-loopback bind is still refused, but a token on a
loopback listener is not enforced.

```bash
export CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)"
export CAVEMAN_LISTEN=0.0.0.0:8787
export ANTHROPIC_API_KEY=…
~/.caveman/bin/caveman-proxy serve
```

Clients then send that token on every request, in `x-cave-api-key` or as `Authorization: Bearer`:

```bash
curl http://proxy.internal:8787/v1/messages \
  -H "x-cave-api-key: $CAVEMAN_AUTH_TOKEN" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model":"claude-sonnet-4-5","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
```

## The token is what allows the bind

A listen address that is not loopback is refused while `CAVEMAN_AUTH_TOKEN` is empty. The proxy exits at
config load with an error naming the address, because binding wider with no inbound authentication would put
every configured provider credential on the network.

The token must be at least 16 bytes and carry no spaces or control characters, since it has to survive one
HTTP header value. It is compared in constant time. A request that presents the wrong token and a request
that presents none both get the same `401 cave_unauthorized`, so the port cannot be used to probe for the
value one byte at a time. The proxy logs the path and the remote host on each rejection and counts them in
`cave_proxy_unauthorized_total` on `/metrics`.

The token is yours, not the provider's, so the proxy deletes the header that carried it before the credential
resolver reads the request. It is never forwarded upstream.

**A token on a loopback listener gates local clients too**
Set `CAVEMAN_AUTH_TOKEN` and leave `listen` on `127.0.0.1`, and `caveman wrap` sessions start failing with
401 while `/health/live` stays green: the local wrap path sends no token. The proxy logs a warning at startup
when it sees that combination.

## Keys on the server

Each provider reads its key from the environment of the proxy process, so a client sends no provider
credential at all.

| Provider | Variable |
|---|---|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Gemini | `GEMINI_API_KEY` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` |
| Generic compatible mount | `OPENAI_COMPAT_API_KEY` |
| Named compatible mount | the variable named in `compat.<name>.api_key_env` |
| Bedrock | `AWS_BEARER_TOKEN_BEDROCK`, or `AWS_ACCESS_KEY_ID` with `AWS_SECRET_ACCESS_KEY` |

A credential that does arrive on the request still wins, which is what keeps a Claude Pro or Max login
working through a shared proxy. On Bedrock the environment is consulted after the inbound credential and
before the AWS default chain, so a task role, pod identity or instance profile works with no key in the
environment at all.

Per-provider upstreams are set the same way as on a laptop:

```yaml
# caveman.yaml
mode: record
providers:
  bedrock:
    region: eu-central-1
compat:
  house-router:
    base_url: https://router.internal/v1
    api_key_env: HOUSE_ROUTER_KEY
```

## The container

The Dockerfile is at the repository root; the compose file, the ECS task definition and the Kubernetes
manifest are in its deployment directory. Images are published to `ghcr.io/juliusbrussee/caveman-proxy`. The
image is distroless and runs as uid 65532, with `CAVEMAN_HOME` set to `/data` and `CAVEMAN_LISTEN` set to
`0.0.0.0:8787`.

```bash
docker run -d -p 8787:8787 -v caveman-data:/data \
  -e CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)" -e ANTHROPIC_API_KEY=… \
  ghcr.io/juliusbrussee/caveman-proxy:bin-v1.1.7
```

```yaml
services:
  caveman-proxy:
    image: ghcr.io/juliusbrussee/caveman-proxy:bin-v1.1.7
    ports:
      - "8787:8787"
    environment:
      CAVEMAN_AUTH_TOKEN: ${CAVEMAN_AUTH_TOKEN:?required}
    volumes:
      - caveman-data:/data
    restart: unless-stopped

volumes:
  caveman-data:
```

Trimmed from the compose file in
[JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman). `/data` holds the usage store, the
recovery store, the run state and `proxy.log`, so it wants a volume. A named volume inherits the image's
ownership; a bind mount does not, so chown the host directory to 65532 first.

The image has no shell, which is why it declares no `HEALTHCHECK`. Probe `GET /health/ready` on 8787 from the
orchestrator instead. It answers the JSON under [Start it](/docs/proxy#start-it), with `adapters` counting
the routes that listener mounted. Health and metrics sit outside the token gate, so an orchestrator can probe
them with no credential.

## One tenant

There is one token, one set of provider keys and one usage store, so every client that holds the token spends
the same credentials and writes rows to the same file. Splitting spend by caller is what the `/w/<slug>`
attribution path is for, and it is a label rather than a boundary.
