# 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 legacy `docker-compose` 1.29.x fails on Docker 28 with `KeyError: '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 `A` record 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 ```bash 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): ```bash sudo bash scripts/migrate-host-nginx.sh ``` ### Configuration ```bash vi .env DOMAIN=proxy.bildme.ru ACME_EMAIL=you@example.com PROXY_SECRET= ALLOWED_CIDR=10.0.0.0/8,172.16.0.0/12 docker compose up -d ``` `DOMAIN` and `ACME_EMAIL` are mandatory — the entrypoint refuses to start without them. ### Verify ```bash # 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`: ```caddyfile { email {$ACME_EMAIL} acme_ca https://acme-staging-v02.api.letsencrypt.org/directory } ``` Check the certificate state: ```bash 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`: ```caddyfile import api anthropic api.anthropic.com "" ``` Arguments: ` `. 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: ```bash docker compose up -d --build ``` --- ## K8s Side: How to Call the Proxy ### Environment Variables ```yaml env: - name: PROXY_BASE_URL value: "https://proxy.bildme.ru" # or http://:8082 inside the private network - name: PROXY_SECRET valueFrom: secretKeyRef: name: api-proxy key: token ``` ### TypeScript Examples ```typescript // 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_SECRET` generated via `openssl rand -hex 32` - [ ] `ALLOWED_CIDR` restricted 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_data` volume backed up (contains the ACME account key) - [ ] Logs rotate (Docker logging driver configured: 50m x 5 files) --- ## Monitoring ```bash # 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 1. A k8s pod sends a request to `https://proxy.bildme.ru/elevenlabs/v1/text-to-speech/...` (or `http://:8082/...`) with the `X-Proxy-Token` header and the original API key. 2. Caddy checks the source IP against `ALLOWED_CIDR` and validates `X-Proxy-Token` — rejecting with 403 if either fails. `/health` is answered before both guards. 3. `handle_path` strips the prefix (`/elevenlabs/`, `/openai/`, `/telegram/`, `/openrouter/`, `/deepseek/`) and the request is forwarded over HTTPS with the upstream host as both `Host` header and TLS SNI. 4. `X-Proxy-Token` 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. 5. Responses stream back unbuffered (`flush_interval -1`) — critical for binary audio, SSE token streams and Telegram long-polling. 6. Access control fragments (`matchers.caddy`, `guard.caddy`) are generated from environment variables at container start by `docker-entrypoint.sh`.