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 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.
export CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)"
export CAVEMAN_LISTEN=0.0.0.0:8787
export ANTHROPIC_API_KEY=…
~/.caveman/bin/caveman-proxy serveClients then send that token on every request, in x-cave-api-key or as Authorization: Bearer:
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.
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:
# caveman.yaml
mode: record
providers:
bedrock:
region: eu-central-1
compat:
house-router:
base_url: https://router.internal/v1
api_key_env: HOUSE_ROUTER_KEYThe 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.
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.7services:
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. /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, 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.