Skip to content
Cavemandocs
01Proxy/BSL-1.1

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.

terminal
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:

terminal
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.

ProviderVariable
AnthropicANTHROPIC_API_KEY
OpenAIOPENAI_API_KEY
GeminiGEMINI_API_KEY
Azure OpenAIAZURE_OPENAI_API_KEY
Generic compatible mountOPENAI_COMPAT_API_KEY
Named compatible mountthe variable named in compat.<name>.api_key_env
BedrockAWS_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.

terminal
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. /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.