refactor: switch nginx to Caddy with automatic Let's Encrypt
Build Admin / Build image (push) Successful in 13s

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>
This commit is contained in:
vasyanskandClaude Opus 5 committed 2026-09-20 13:35:49 +07:00
1 parent c31f6d8d90
commit d71001a2bf
15 files changed
+288 -465

No files matched your search

+6
View File
@@ -1,3 +1,9 @@
# Public domain served over HTTPS (Let's Encrypt certificate is issued for it)
DOMAIN=proxy.example.com
# Email for the ACME account (expiry notices from Let's Encrypt)
ACME_EMAIL=
# Secret token — k8s pods must send this in X-Proxy-Token header (optional)
# If empty or unset — token auth is disabled (open mode, protected by IP allowlist only)
# Generate with: openssl rand -hex 32
+1
View File
@@ -1 +1,2 @@
.env
.DS_Store
+70
View File
@@ -0,0 +1,70 @@
# elevenlabs.io/Caddyfile
# Outbound API gateway. Two listeners, one shared route set:
# :8080 — plain HTTP (published as 8082 on the host, legacy clients)
# {$DOMAIN} — HTTPS 443 with automatic Let's Encrypt certificates
{
email {$ACME_EMAIL}
admin off
}
# --- Access control (generated by docker-entrypoint.sh from env) ---
# matchers.caddy: @denied_ip / @bad_token definitions (empty when disabled)
# guard.caddy: matching respond directives (empty when disabled)
# --- Shared upstream request hygiene ---
(scrub) {
header_up Host {args[0]}
header_up -X-Forwarded-For
header_up -X-Forwarded-Proto
header_up -X-Forwarded-Host
header_up -X-Real-IP
header_up -True-Client-IP
header_up -CF-Connecting-IP
header_up -X-Client-IP
header_up -Forwarded
header_up -Via
header_up -X-Proxy-Token
}
# (api) <prefix> <upstream-host> <upstream-path-prefix>
(api) {
handle_path /{args[0]}/* {
rewrite * {args[2]}{uri}
reverse_proxy https://{args[1]} {
import scrub {args[1]}
flush_interval -1
transport http {
dial_timeout 15s
response_header_timeout 300s
read_timeout 300s
}
}
}
}
(routes) {
import /etc/caddy/matchers.caddy
route {
# Health check — no auth, evaluated before the guards
respond /health `{"status":"ok","version":"2.0"}` 200
import /etc/caddy/guard.caddy
import api elevenlabs api.elevenlabs.io ""
import api openai api.openai.com ""
import api telegram api.telegram.org ""
import api deepseek api.deepseek.com ""
import api openrouter openrouter.ai /api/v1
respond `{"error":"unknown_upstream","hint":"use /elevenlabs/, /openai/, /telegram/, /openrouter/ or /deepseek/"}` 404
}
}
{$DOMAIN} {
import routes
}
:8080 {
import routes
}
+5 -14
View File
@@ -1,21 +1,12 @@
# proxy-vm/Dockerfile
FROM nginx:1.27-alpine
# elevenlabs.io/Dockerfile
FROM caddy:2.11.4-alpine
# Remove default config
RUN rm -f /etc/nginx/conf.d/default.conf
# Copy nginx configs
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY nginx/locations.conf /etc/nginx/locations.conf
# Copy entrypoint script
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Create directories for generated configs and optional SSL certs
RUN mkdir -p /etc/nginx/conf.d /etc/nginx/ssl
EXPOSE 8080 8443
# 80/443 — public HTTPS endpoint (ACME + TLS), 8080 — plain HTTP for legacy clients
EXPOSE 80 443 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
+81 -83
View File
@@ -1,12 +1,16 @@
# API Proxy — HTTPS Reverse Proxy
Production-ready nginx reverse proxy in Docker for forwarding requests from an internal Kubernetes cluster to external APIs (ElevenLabs, OpenAI, Telegram Bot API).
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
- Port 8080 (HTTP) and optionally 8443 (HTTPS) open **only** to k8s cluster CIDR
- 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
@@ -14,26 +18,36 @@ Production-ready nginx reverse proxy in Docker for forwarding requests from an i
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
# Set your secret and cluster CIDR:
PROXY_SECRET=<generated-token>
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 --build
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:8080/health
curl http://localhost:8082/health
curl https://proxy.bildme.ru/health
# Test with auth:
curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices
curl -H "X-Proxy-Token: YOUR_SECRET" https://proxy.bildme.ru/elevenlabs/v1/voices
```
---
@@ -49,29 +63,40 @@ curl -H "X-Proxy-Token: YOUR_SECRET" http://localhost:8080/elevenlabs/v1/voices
| /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
To enable HTTPS on port 8443, place your certificate and key in the `ssl/` directory:
Caddy obtains a certificate for `DOMAIN` on first start and renews it automatically (~30 days before expiry).
```bash
mkdir -p ssl
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).
# Option A: self-signed cert
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/tls.key \
-out ssl/tls.crt \
-subj "/CN=api-proxy"
To rehearse against the staging CA, add to the global block of `Caddyfile`:
# Option B: copy existing certs
cp /path/to/your/cert.pem ssl/tls.crt
cp /path/to/your/key.pem ssl/tls.key
docker compose up -d --build
```caddyfile
{
email {$ACME_EMAIL}
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
```
The entrypoint auto-detects `ssl/tls.crt` and `ssl/tls.key`. If present — HTTPS is enabled on port 8443. If absent — only HTTP on 8080.
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
```
---
@@ -79,47 +104,19 @@ The entrypoint auto-detects `ssl/tls.crt` and `ssl/tls.key`. If present — HTTP
Example: adding `api.anthropic.com` → `/anthropic/*`
Add this block to `nginx/locations.conf`:
Add one line to the `(routes)` snippet in `Caddyfile`:
```nginx
location /anthropic/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
if ($auth_ok = 0) {
return 403 '{"error":"invalid_token"}';
}
set $anthropic_upstream https://api.anthropic.com;
rewrite ^/anthropic/(.*) /$1 break;
proxy_pass $anthropic_upstream;
proxy_ssl_server_name on;
proxy_ssl_name api.anthropic.com;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.anthropic.com;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
```caddyfile
import api anthropic api.anthropic.com ""
```
Then rebuild: `docker compose up -d --build`
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:
```bash
docker-compose up -d --build
```
---
@@ -130,7 +127,7 @@ Then rebuild: `docker compose up -d --build`
```yaml
env:
- name: PROXY_BASE_URL
value: "http://10.0.1.50:8080" # or https://10.0.1.50:8443
value: "https://proxy.bildme.ru" # or http://<vm-ip>:8082 inside the private network
- name: PROXY_SECRET
valueFrom:
secretKeyRef:
@@ -156,30 +153,30 @@ const response = await fetch(
);
const buffer = Buffer.from(await response.arrayBuffer());
// Telegram Bot API
const tgResponse = await fetch(
`${process.env.PROXY_BASE_URL}/telegram/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Proxy-Token': process.env.PROXY_SECRET,
},
body: JSON.stringify({ chat_id: chatId, text: message }),
}
);
// 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 8080/8443 closed to the public (only k8s CIDR in firewall/NSG)
- [ ] VM has no public IP or is behind NAT
- [ ] 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)
- [ ] TLS certs have restricted permissions (`chmod 600 ssl/tls.key`)
---
@@ -187,11 +184,11 @@ const tgResponse = await fetch(
```bash
# Tail logs
docker compose logs -f proxy | jq .
docker-compose logs -f proxy | jq .
# Requests per upstream per minute
docker compose logs --since=1m proxy --no-log-prefix \
| jq -r '.uri' \
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
@@ -203,8 +200,9 @@ docker inspect --format='{{.State.Health.Status}}' api-proxy
## How It Works
1. A k8s pod sends an HTTP request to `http://<proxy-vm>:8080/elevenlabs/v1/text-to-speech/...` with the `X-Proxy-Token` header and the original API key.
2. Nginx checks the source IP against `ALLOWED_CIDR` and validates `X-Proxy-Token` — rejecting with 403 if either fails.
3. The prefix (`/elevenlabs/`, `/openai/`, `/telegram/`, `/openrouter/`, `/deepseek/`) is stripped via rewrite, and the request is forwarded over HTTPS to the upstream with SNI enabled.
4. `X-Proxy-Token` and all IP-leaking headers are removed before forwarding; API keys pass through unchanged.
5. The response streams back unbuffered to the pod — critical for binary audio data and long-polling (Telegram).
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`.
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.2.0
+15 -5
View File
@@ -1,16 +1,22 @@
services:
proxy:
image: git.realmanual.ru/pub/elevenlabs.io/elevenlabs:latest
build: .
container_name: api-proxy
restart: unless-stopped
ports:
- "8080:8080"
- "8443:8443"
# Public HTTPS endpoint + ACME HTTP-01 challenge
- "80:80"
- "443:443"
- "443:443/udp"
# Legacy plain-HTTP endpoint (unchanged for existing clients)
- "8082:8080"
env_file: .env
volumes:
# Mount TLS certs (optional — HTTPS disabled if files are absent)
# Place tls.crt and tls.key in ./ssl/ directory
- ./ssl:/etc/nginx/ssl:ro
# Let's Encrypt certificates and ACME account keys — MUST persist,
# otherwise every recreate re-issues certs and burns LE rate limits
- caddy_data:/data
- caddy_config:/config
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
@@ -21,3 +27,7 @@ services:
options:
max-size: "50m"
max-file: "5"
volumes:
caddy_data:
caddy_config:
+42 -72
View File
@@ -1,87 +1,57 @@
#!/bin/sh
# proxy-vm/docker-entrypoint.sh
# Generates nginx config fragments from environment variables at container start.
# elevenlabs.io/docker-entrypoint.sh
# Generates Caddy access-control fragments from environment variables at container start.
set -e
CONF_DIR="/etc/nginx/conf.d"
mkdir -p "${CONF_DIR}"
CONF_DIR="/etc/caddy"
MATCHERS="${CONF_DIR}/matchers.caddy"
GUARD="${CONF_DIR}/guard.caddy"
# --- 1. Generate IP allowlist (geo block) ---
ALLOWLIST_FILE="${CONF_DIR}/allowlist.conf"
: > "${MATCHERS}"
: > "${GUARD}"
if [ -z "${ALLOWED_CIDR}" ]; then
cat > "${ALLOWLIST_FILE}" <<'GEO'
geo $allowed_ip {
default 1;
}
GEO
echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)"
else
{
echo 'geo $allowed_ip {'
echo ' default 0;'
echo "${ALLOWED_CIDR}" | tr ',' '\n' | while read -r cidr; do
cidr=$(echo "${cidr}" | xargs)
[ -n "${cidr}" ] && echo " ${cidr} 1;"
done
echo '}'
} > "${ALLOWLIST_FILE}"
echo "[entrypoint] IP allowlist configured: ${ALLOWED_CIDR}"
# --- 0. Required settings ---
if [ -z "${DOMAIN}" ]; then
echo "[entrypoint] FATAL: DOMAIN is not set (e.g. proxy.example.com)" >&2
exit 1
fi
if [ -z "${ACME_EMAIL}" ]; then
echo "[entrypoint] FATAL: ACME_EMAIL is not set — Let's Encrypt account needs it" >&2
exit 1
fi
# --- 2. Generate token auth (map block) ---
AUTH_FILE="${CONF_DIR}/auth.conf"
# --- 1. IP allowlist ---
if [ -z "${ALLOWED_CIDR}" ]; then
echo "[entrypoint] ALLOWED_CIDR is empty — allowing all IPs (dev mode)"
else
CIDR_LIST=$(echo "${ALLOWED_CIDR}" | tr ',' ' ' | tr -s ' ')
cat >> "${MATCHERS}" <<MATCH
@denied_ip not remote_ip ${CIDR_LIST}
MATCH
cat >> "${GUARD}" <<GUARDRULE
respond @denied_ip \`{"error":"ip_not_allowed"}\` 403 {
close
}
GUARDRULE
echo "[entrypoint] IP allowlist configured: ${CIDR_LIST}"
fi
# --- 2. Token auth ---
if [ -z "${PROXY_SECRET}" ]; then
echo "[entrypoint] PROXY_SECRET is not set — token auth disabled (open mode)"
cat > "${AUTH_FILE}" <<'MAP'
map $http_x_proxy_token $auth_ok {
default 1;
}
MAP
else
cat > "${AUTH_FILE}" <<MAP
map \$http_x_proxy_token \$auth_ok {
default 0;
"${PROXY_SECRET}" 1;
cat >> "${MATCHERS}" <<MATCH
@bad_token not header X-Proxy-Token "${PROXY_SECRET}"
MATCH
cat >> "${GUARD}" <<GUARDRULE
respond @bad_token \`{"error":"invalid_token"}\` 403 {
close
}
MAP
GUARDRULE
echo "[entrypoint] Token auth configured"
fi
# --- 3. Copy locations.conf ---
cp /etc/nginx/locations.conf "${CONF_DIR}/locations.conf"
echo "[entrypoint] Locations config copied"
# --- 4. Generate HTTPS server block (if certs exist) ---
HTTPS_FILE="${CONF_DIR}/https_server.conf"
SSL_CERT="/etc/nginx/ssl/tls.crt"
SSL_KEY="/etc/nginx/ssl/tls.key"
if [ -f "${SSL_CERT}" ] && [ -f "${SSL_KEY}" ]; then
cat > "${HTTPS_FILE}" <<HTTPS
server {
listen 8443 ssl;
server_name _;
ssl_certificate ${SSL_CERT};
ssl_certificate_key ${SSL_KEY};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
include /etc/nginx/conf.d/locations.conf;
}
HTTPS
echo "[entrypoint] HTTPS enabled on port 8443 (cert: ${SSL_CERT})"
else
# Empty file so nginx include does not fail
: > "${HTTPS_FILE}"
echo "[entrypoint] No TLS certs found at ${SSL_CERT} — HTTPS disabled"
fi
# --- 5. Start nginx ---
echo "[entrypoint] Starting nginx..."
exec nginx -g 'daemon off;'
# --- 3. Validate and start ---
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
echo "[entrypoint] Starting caddy (domain: ${DOMAIN})..."
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
-204
View File
@@ -1,204 +0,0 @@
# proxy-vm/nginx/locations.conf
# Shared location blocks — included by both HTTP and HTTPS server blocks.
# --- Health check (no auth) ---
location = /health {
access_log off;
default_type application/json;
return 200 '{"status":"ok","version":"1.0"}';
}
# --- ElevenLabs ---
location /elevenlabs/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
if ($auth_ok = 0) {
return 403 '{"error":"invalid_token"}';
}
set $elevenlabs_upstream https://api.elevenlabs.io;
rewrite ^/elevenlabs/(.*) /$1 break;
proxy_pass $elevenlabs_upstream;
proxy_ssl_server_name on;
proxy_ssl_name api.elevenlabs.io;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.elevenlabs.io;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# --- OpenAI ---
location /openai/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
if ($auth_ok = 0) {
return 403 '{"error":"invalid_token"}';
}
set $openai_upstream https://api.openai.com;
rewrite ^/openai/(.*) /$1 break;
proxy_pass $openai_upstream;
proxy_ssl_server_name on;
proxy_ssl_name api.openai.com;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.openai.com;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# --- Telegram Bot API ---
location /telegram/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
if ($auth_ok = 0) {
return 403 '{"error":"invalid_token"}';
}
set $telegram_upstream https://api.telegram.org;
rewrite ^/telegram/(.*) /$1 break;
proxy_pass $telegram_upstream;
proxy_ssl_server_name on;
proxy_ssl_name api.telegram.org;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.telegram.org;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# --- openrouter Bot API ---
location /openrouter/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
set $openrouter_upstream https://openrouter.ai;
rewrite ^/openrouter/(.*) /api/v1/$1 break;
proxy_pass $openrouter_upstream;
proxy_ssl_server_name on;
proxy_ssl_name openrouter.ai;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host openrouter.ai;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_buffer_size 16k;
proxy_buffers 4 16k;
proxy_busy_buffers_size 32k;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# --- DeepSeek ---
location /deepseek/ {
if ($allowed_ip = 0) {
return 403 '{"error":"ip_not_allowed"}';
}
if ($auth_ok = 0) {
return 403 '{"error":"invalid_token"}';
}
set $deepseek_upstream https://api.deepseek.com;
rewrite ^/deepseek/(.*) /$1 break;
proxy_pass $deepseek_upstream;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header Host api.deepseek.com;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Via "";
proxy_set_header X-Proxy-Token "";
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_buffer_size 16k;
proxy_buffers 4 16k;
proxy_busy_buffers_size 32k;
proxy_connect_timeout 15s;
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
# --- Catch-all ---
location / {
default_type application/json;
return 404 '{"error":"unknown_upstream","hint":"use /elevenlabs/, /openai/, /telegram/, /openrouter/ or /deepseek/"}';
}
-57
View File
@@ -1,57 +0,0 @@
# proxy-vm/nginx/nginx.conf
worker_processes auto;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
# --- JSON access log ---
log_format json_log escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes_sent":$bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_response_time":"$upstream_response_time"'
'}';
access_log /dev/stdout json_log;
# --- Hash bucket size for long map keys (PROXY_SECRET) ---
map_hash_bucket_size 128;
# --- Performance ---
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
client_max_body_size 50m;
gzip off;
# --- DNS resolver (Google + Cloudflare, re-resolve every 30s) ---
resolver 8.8.8.8 1.1.1.1 valid=30s ipv6=off;
resolver_timeout 5s;
# --- IP allowlist (generated at container start) ---
include /etc/nginx/conf.d/allowlist.conf;
# --- Token auth ---
include /etc/nginx/conf.d/auth.conf;
# --- HTTP server ---
server {
listen 8080;
server_name _;
include /etc/nginx/conf.d/locations.conf;
}
# --- HTTPS server (generated at container start if certs exist) ---
include /etc/nginx/conf.d/https_server.conf;
}
+15 -14
View File
@@ -1,25 +1,26 @@
#!/usr/bin/env bash
# proxy-vm/scripts/deploy.sh
# Pull latest changes and redeploy containers.
# elevenlabs.io/scripts/deploy.sh
# Pull the latest image built by Gitea Actions and restart the proxy.
set -euo pipefail
PROXY_DIR="/opt/proxy/proxy-vm"
PROXY_DIR="${PROXY_DIR:-/srv/elevenlabs}"
cd "${PROXY_DIR}"
echo "=== [1/5] Pulling latest code ==="
git -C "$(dirname "${PROXY_DIR}")" pull
COMPOSE="docker-compose"
command -v docker-compose >/dev/null 2>&1 || COMPOSE="docker compose"
echo "=== [2/5] Building container ==="
docker compose build --no-cache
echo "=== [1/4] Pulling latest image ==="
${COMPOSE} pull
echo "=== [3/5] Restarting containers ==="
docker compose up -d --force-recreate
echo "=== [2/4] Recreating container ==="
${COMPOSE} up -d
echo "=== [4/5] Container status ==="
docker compose ps
echo "=== [3/4] Status ==="
${COMPOSE} ps
echo "=== [5/5] Recent logs ==="
docker compose logs --tail=20 proxy
echo "=== [4/4] Recent logs ==="
${COMPOSE} logs --tail=30 proxy
echo ""
echo "Deploy complete."
echo "Health: curl -s http://localhost:8082/health"
echo "TLS: curl -s https://${DOMAIN:-proxy.bildme.ru}/health"
+9 -9
View File
@@ -3,7 +3,7 @@
# Bootstrap script for fresh Ubuntu 24.04 VM.
set -euo pipefail
PROXY_DIR="/opt/proxy"
PROXY_DIR="/srv/elevenlabs"
REPO_URL="https://github.com/YOUR_ORG/YOUR_REPO.git" # TODO: replace with actual repo
echo "=== [1/6] Installing Docker and dependencies ==="
@@ -25,7 +25,7 @@ else
fi
echo "=== [5/6] Setting up .env ==="
cd "${PROXY_DIR}/proxy-vm"
cd "${PROXY_DIR}"
if [ ! -f .env ]; then
cp .env.example .env
# Generate a random PROXY_SECRET
@@ -33,21 +33,21 @@ if [ ! -f .env ]; then
sed -i "s/change-me-generate-with-openssl-rand-hex-32/${GENERATED_SECRET}/" .env
echo ""
echo ">>> .env created with auto-generated PROXY_SECRET"
echo ">>> Edit /opt/proxy/proxy-vm/.env to set ALLOWED_CIDR"
echo ">>> Edit ${PROXY_DIR}/.env to set DOMAIN, ACME_EMAIL and ALLOWED_CIDR"
echo ""
else
echo ".env already exists, skipping..."
fi
echo "=== [6/6] Starting containers ==="
docker compose up -d --build
docker-compose up -d
echo ""
echo "=========================================="
echo " Proxy is running on port 8080"
echo " Health check: curl http://localhost:8080/health"
echo " Proxy is running on ports 80/443 (HTTPS) and 8082 (plain HTTP)"
echo " Health check: curl http://localhost:8082/health"
echo ""
echo " IMPORTANT: Edit ${PROXY_DIR}/proxy-vm/.env"
echo " - Set ALLOWED_CIDR to your k8s cluster CIDR"
echo " - Then run: docker compose restart"
echo " IMPORTANT: Edit ${PROXY_DIR}/.env"
echo " - Set DOMAIN, ACME_EMAIL and ALLOWED_CIDR"
echo " - Then run: docker-compose up -d"
echo "=========================================="
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# elevenlabs.io/scripts/migrate-host-nginx.sh
# Frees ports 80/443 on the host so the Caddy container can obtain Let's Encrypt certs.
# Backs up the whole host nginx configuration first, then stops and disables the service.
set -euo pipefail
BACKUP_DIR="/root/nginx-backup-$(date +%Y%m%d-%H%M%S)"
if ! systemctl is-active --quiet nginx; then
echo "Host nginx is not running — nothing to migrate."
exit 0
fi
echo "=== [1/5] Dumping host nginx config to ${BACKUP_DIR} ==="
mkdir -p "${BACKUP_DIR}"
nginx -T > "${BACKUP_DIR}/nginx-T.dump" 2>&1 || true
tar -czf "${BACKUP_DIR}/etc-nginx.tar.gz" /etc/nginx 2>/dev/null
cp -a /etc/letsencrypt "${BACKUP_DIR}/letsencrypt" 2>/dev/null || true
echo "Backup written."
echo "=== [2/5] Virtual hosts currently served ==="
grep -hE '^\s*server_name' "${BACKUP_DIR}/nginx-T.dump" | sed 's/^[ \t]*//' | sort -u
read -r -p "Stop and disable host nginx? These vhosts will go offline [y/N] " answer
case "${answer}" in
[yY]) ;;
*) echo "Aborted. Backup kept at ${BACKUP_DIR}"; exit 1 ;;
esac
echo "=== [3/5] Stopping nginx ==="
systemctl disable --now nginx
echo "=== [4/5] Certbot renewal hooks ==="
# Certbot keeps trying to renew certs through the nginx plugin — it would fail now.
systemctl disable --now certbot.timer 2>/dev/null || true
echo "certbot.timer disabled (Caddy manages its own certificates)."
echo "=== [5/5] Port check ==="
ss -tlnp | grep -E ':(80|443)\s' && { echo "Ports 80/443 are still busy — inspect before starting Caddy."; exit 1; }
echo "Ports 80 and 443 are free."
echo ""
echo "Backup: ${BACKUP_DIR}"
echo "Next: cd /srv/elevenlabs && docker-compose pull && docker-compose up -d"
-6
View File
@@ -1,6 +0,0 @@
# Ignore actual TLS certificates — never commit to git
*.crt
*.key
*.pem
!.gitkeep
!.gitignore
View File
Whitespace-only changes.