vasyanskandClaude Opus 5 d71001a2bf
Build Admin / Build image (push) Successful in 13s
refactor: switch nginx to Caddy with automatic Let's Encrypt
Nginx required manually managed certs on port 8443; TLS was effectively
unused. Caddy issues and renews certificates for DOMAIN on its own.

- Caddyfile replaces nginx.conf + locations.conf; upstreams are one
  `import api <prefix> <host> <path-prefix>` line each
- routes wrapped in `route {}` so guards run before handle_path
- access control (IP allowlist, X-Proxy-Token) generated from env into
  matchers.caddy / guard.caddy by the entrypoint, as before
- listeners: 443 (+udp, HTTP/3) and 80 for ACME on DOMAIN, plus the
  unchanged plain-HTTP 8082 -> 8080 path for existing clients
- caddy_data volume added: losing it re-issues certs and burns LE limits
- scripts/migrate-host-nginx.sh backs up and retires the host nginx
- deploy.sh points at the real prod layout (/srv/elevenlabs, compose v1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:35:49 +07:00
2026-03-21 10:45:42 +07:00
2026-03-21 10:55:55 +07:00

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

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

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

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_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

# 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://<vm-ip>: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.
S
Description
No description provided
Readme
83 KiB
0 Stars 1 Watchers 0 Forks
Languages
Shell 94.3%
Dockerfile 5.7%