Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8.4 KiB
API Proxy — HTTPS Reverse Proxy
Production-ready Caddy reverse proxy in Docker for forwarding requests from an internal Kubernetes cluster to external APIs (ElevenLabs, OpenAI, Telegram Bot API, OpenRouter, DeepSeek).
TLS certificates are issued and renewed automatically by Let's Encrypt — no certbot, no manual cert files.
Quick Start
Requires Docker Compose v2 (
docker compose). The legacydocker-compose1.29.x fails on Docker 28 withKeyError: 'ContainerConfig'.
apt-get install -y docker-compose-v2
Prerequisites
- Ubuntu 24.04 VM
- Ports 80 and 443 free and reachable from the internet (required for the ACME HTTP-01 challenge)
- A DNS
Arecord pointing your domain at the VM (e.g.proxy.bildme.ru → 62.84.98.250) - Port 8082 (plain HTTP) open only to the k8s cluster CIDR
Installation
sudo bash scripts/install.sh
If the VM already runs a host nginx on 80/443, free those ports first (backs up the config, then disables the service):
sudo bash scripts/migrate-host-nginx.sh
Configuration
vi .env
DOMAIN=proxy.bildme.ru
ACME_EMAIL=you@example.com
PROXY_SECRET=<openssl rand -hex 32>
ALLOWED_CIDR=10.0.0.0/8,172.16.0.0/12
AUTH_HEADER_NAME=X-Api-Key # optional: access from any IP by header
AUTH_HEADER_VALUE=<openssl rand -hex 32>
docker compose up -d
DOMAIN and ACME_EMAIL are mandatory — the entrypoint refuses to start without them.
Verify
# Health check (no auth):
curl http://localhost:8082/health
curl https://proxy.bildme.ru/health
# Test with auth:
curl -H "X-Proxy-Token: YOUR_SECRET" https://proxy.bildme.ru/elevenlabs/v1/voices
Available Endpoints
| Prefix | Upstream |
|---|---|
| /elevenlabs/ | https://api.elevenlabs.io |
| /openai/ | https://api.openai.com |
| /telegram/ | https://api.telegram.org |
| /openrouter/ | https://openrouter.ai/api/v1 |
| /deepseek/ | https://api.deepseek.com |
| /health | local health check (no auth) |
Listeners
| Host port | Container port | Purpose |
|---|---|---|
| 443 (tcp+udp) | 443 | Public HTTPS endpoint for DOMAIN, HTTP/3 enabled |
| 80 | 80 | ACME HTTP-01 challenge + redirect to HTTPS |
| 8082 | 8080 | Plain HTTP for legacy in-cluster clients |
Both listeners serve the same routes and the same access control.
TLS / HTTPS
Caddy obtains a certificate for DOMAIN on first start and renews it automatically (~30 days before expiry).
Certificates and the ACME account key live in the caddy_data volume mounted at /data. Never drop this volume — recreating it re-issues certificates and burns the Let's Encrypt rate limit (5 failures/hour, 50 certs/week per domain).
To rehearse against the staging CA, add to the global block of Caddyfile:
{
email {$ACME_EMAIL}
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Check the certificate state:
docker exec api-proxy ls /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/
echo | openssl s_client -connect proxy.bildme.ru:443 -servername proxy.bildme.ru 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
How to Add a New Upstream
Example: adding api.anthropic.com → /anthropic/*
Add one line to the (routes) snippet in Caddyfile:
import api anthropic api.anthropic.com ""
Arguments: <url-prefix> <upstream-host> <upstream-path-prefix>. The third argument is prepended to the proxied path — "" for a plain passthrough, /api/v1 for OpenRouter-style bases.
Update the catch-all hint in the same snippet, then rebuild:
docker compose up -d --build
K8s Side: How to Call the Proxy
Environment Variables
env:
- name: PROXY_BASE_URL
value: "https://proxy.bildme.ru" # or http://<vm-ip>:8082 inside the private network
- name: PROXY_SECRET
valueFrom:
secretKeyRef:
name: api-proxy
key: token
TypeScript Examples
// ElevenLabs TTS
const response = await fetch(
`${process.env.PROXY_BASE_URL}/elevenlabs/v1/text-to-speech/${voiceId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'xi-api-key': process.env.ELEVENLABS_API_KEY,
'X-Proxy-Token': process.env.PROXY_SECRET,
},
body: JSON.stringify(payload),
}
);
const buffer = Buffer.from(await response.arrayBuffer());
// DeepSeek (OpenAI-compatible)
const ds = await fetch(`${process.env.PROXY_BASE_URL}/deepseek/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'X-Proxy-Token': process.env.PROXY_SECRET,
},
body: JSON.stringify({ model: 'deepseek-chat', messages }),
});
For OpenAI-compatible SDKs set baseURL to https://proxy.bildme.ru/deepseek and pass X-Proxy-Token as a default header.
Security Checklist
PROXY_SECRETgenerated viaopenssl rand -hex 32ALLOWED_CIDRrestricted to cluster CIDR only- Port 8082 closed to the public (only k8s CIDR in firewall/NSG) — it carries API keys in cleartext
- Clients migrated to
https://DOMAIN, then port 8082 retired caddy_datavolume backed up (contains the ACME account key)- Logs rotate (Docker logging driver configured: 50m x 5 files)
Troubleshooting
403 {"error":"invalid_token"} on every path
The proxy answered, not the upstream — X-Proxy-Token is missing or wrong. The body is the proxy's own format; a real upstream error looks different (DeepSeek: {"error":{"message":...,"type":...}}).
The guard applies to every prefix. Before v0.2.0 /openrouter/ was the one route without a token check — that exception is gone, so clients that relied on it must send the header too.
curl -H "X-Proxy-Token: $PROXY_SECRET" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
https://proxy.bildme.ru/deepseek/user/balance
For OpenAI-compatible SDKs put it in the default headers, next to baseURL:
option.WithBaseURL("https://proxy.bildme.ru/deepseek"),
option.WithHeader("X-Proxy-Token", os.Getenv("PROXY_SECRET")),
403 {"error":"ip_not_allowed"}
The caller's source IP is not in ALLOWED_CIDR and the request has no valid AUTH_HEADER_NAME: AUTH_HEADER_VALUE header. Check the effective list:
docker exec api-proxy cat /etc/caddy/matchers.caddy
Upstream errors pass through unchanged
401, 404 or 5xx with an upstream-shaped JSON body means the proxy did its job and the upstream rejected the request — check the API key, not the proxy.
Monitoring
# Tail logs
docker compose logs -f proxy | jq .
# Requests per upstream per minute
docker compose logs --since=1m proxy --no-log-prefix \
| jq -r 'select(.logger=="http.log.access") | .request.uri' \
| cut -d'/' -f2 \
| sort | uniq -c | sort -rn
# Container health
docker inspect --format='{{.State.Health.Status}}' api-proxy
How It Works
- A k8s pod sends a request to
https://proxy.bildme.ru/elevenlabs/v1/text-to-speech/...(orhttp://<vm-ip>:8082/...) with theX-Proxy-Tokenheader and the original API key. - Caddy lets the request through if the source IP is in
ALLOWED_CIDRor it carries theAUTH_HEADER_NAME: AUTH_HEADER_VALUEheader, then validatesX-Proxy-Token(ifPROXY_SECRETis set; a valid key header satisfies this check too) — rejecting with 403 on failure./healthis answered before both guards. handle_pathstrips the prefix (/elevenlabs/,/openai/,/telegram/,/openrouter/,/deepseek/) and the request is forwarded over HTTPS with the upstream host as bothHostheader and TLS SNI.X-Proxy-Token, theAUTH_HEADER_NAMEheader and all IP-leaking headers (X-Forwarded-*,X-Real-IP,True-Client-IP,CF-Connecting-IP,X-Client-IP,Forwarded,Via) are removed before forwarding; API keys pass through unchanged.- Responses stream back unbuffered (
flush_interval -1) — critical for binary audio, SSE token streams and Telegram long-polling. - Access control fragments (
matchers.caddy,guard.caddy) are generated from environment variables at container start bydocker-entrypoint.sh.