# Reverse proxy: backend API + раздача фронтенд-SPA (статика в образе) + # LiveKit SFU signaling. TLS-терминация — здесь же (см. блок HTTPS ниже), # сертификаты берутся из /etc/letsencrypt (см. docker-compose.yml, volume ro). # # Шаблон рендерится штатным entrypoint-скриптом официального образа nginx # (docker-entrypoint.d/20-envsubst-on-templates.sh): при старте контейнера # envsubst подставляет ТОЛЬКО переменные, реально заданные в окружении # контейнера (см. `environment:` сервиса nginx в docker-compose.yml), поэтому # нативные nginx-переменные ($scheme, $host, $http_upgrade, $remote_addr, # $connection_upgrade и т.п.) остаются нетронутыми — с ними коллизий по # именам нет. Результат пишется в /etc/nginx/conf.d/default.conf. # # ВАЖНО (stale DNS): статический `upstream { server backend:8000; }` nginx # резолвит один раз при старте/reload и держит IP в памяти. После пересоздания # контейнера backend (`docker compose up -d --force-recreate backend`) Docker # выдаёт ему новый IP, а nginx продолжает стучаться по старому → 502, пока # nginx не перезапустят. Чтобы резолвить имя заново на каждый запрос, # используем embedded DNS Docker (127.0.0.11) через directive `resolver` и # ПЕРЕМЕННУЮ в proxy_pass — переменные nginx не кэшируются на старте и # резолвятся заново по истечении `valid=`. resolver 127.0.0.11 valid=10s ipv6=off; map $http_upgrade $connection_upgrade { default upgrade; '' close; } # ========================================== # БЛОК 1: HTTP (порт 80) # Задача: ACME-challenge для Certbot + редирект всего остального на HTTPS. # ========================================== server { listen 80; # Список доменов — параметр (NGINX_SERVER_NAMES в .env), не хардкод. server_name ${NGINX_SERVER_NAMES}; # Раздача файлов для (авто)обновления Certbot — ОБЯЗАТЕЛЬНО оставить # для работы webroot-плагина. location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; } # Healthcheck контейнера (см. docker-compose.yml) ходит сюда напрямую по # HTTP — редирект ниже иначе увёл бы его на HTTPS и ломал бы wget без -L. location /healthz { return 200 "ok\n"; add_header Content-Type text/plain; } # Жёсткий редирект всего остального на HTTPS. location / { return 301 https://$host$request_uri; } } # ========================================== # БЛОК 2: HTTPS (порт 443) # Задача: вся основная логика приложения — API, фронтенд, WebSocket, LiveKit. # ========================================== server { listen 443 ssl; server_name ${NGINX_SERVER_NAMES}; # Путь СТАТИЧЕН (не envsubst) — реальный источник переменный: # /docker-entrypoint.d/15-vidconf-certs.sh (запускается раньше рендера # этого шаблона) копирует сюда боевой сертификат из # /etc/letsencrypt/live/${NGINX_CERT_NAME}, если он смонтирован с хоста # (см. docker-compose.yml), иначе генерирует самоподписанный — это # позволяет `docker compose up` стартовать локально (dev) БЕЗ реальных # сертификатов Let's Encrypt (см. docs/deploy/dev-setup.md). ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; # --- Backend API --- # Проброс через переменную: proxy_pass с переменной НЕ выполняет # автоматическую подстановку URI (в отличие от статического # `proxy_pass http://upstream/prefix/;`), поэтому URI-часть в proxy_pass # не указываем — nginx передаёт исходный URI запроса как есть (path + # query), что и сохраняет прежний маппинг /api/ -> backend:8000/api/ # (префикс совпадает 1-в-1, подмены не требовалось и раньше). # Upgrade-заголовки нужны и обычным HTTP-запросам (без `Upgrade` в запросе # `map $http_upgrade $connection_upgrade` выше подставляет `close`, что не # ломает keep-alive обычных ответов), и WS-эндпоинту чата конференции # (`WS /api/v1/conferences/{id}/chat`) — он живёт под тем же # префиксом /api/, а не под отдельным /ws/. location /api/ { set $backend_upstream http://backend:8000; proxy_pass $backend_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # $scheme здесь равен 'https' — критично, чтобы бэкенд (FastAPI) # генерировал корректные абсолютные ссылки и принимал Secure-куки. proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } # --- WebSocket upgrade для чата --- location /ws/ { set $backend_upstream http://backend:8000; proxy_pass $backend_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } # --- LiveKit SFU signaling (WebSocket) --- # Доступен только когда стек запущен с профилем `media` # (см. docker-compose.yml) — без него resolver вернёт NXDOMAIN на первый # запрос к этому location, что ожидаемо. # Здесь префикс /livekit/ нужно СНЯТЬ (как раньше делал # `proxy_pass http://livekit_upstream/;`). С переменной в proxy_pass # автоматическая подмена недоступна, поэтому переписываем URI явно через # rewrite ... break — nginx передаст на upstream уже переписанный $uri. # ВАЖНО: `set` должен идти ДО `rewrite ... break` — break прерывает # выполнение всех последующих директив модуля rewrite (включая set) в # этом location, иначе переменная останется неинициализированной. location /livekit/ { set $livekit_upstream http://livekit:7880; rewrite ^/livekit/(.*)$ /$1 break; proxy_pass $livekit_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } # --- Метрики backend: `GET /metrics` без авторизации # внутри приложения (backend/api/metrics.py) — снаружи периметра нарочно # закрываем явным запретом. Это ОБЯЗАТЕЛЬНО именно exact-match # (`location = /metrics`): ниже есть catch-all `location /` (раздача # фронтенд-SPA), который иначе отдал бы на /metrics файл фронта (или # index.html через try_files). Exact-match в nginx приоритетнее любого # prefix-location, поэтому 403 срабатывает раньше SPA-раздачи. # Prometheus (профиль compose `monitoring`) ходит в backend НАПРЯМУЮ по # внутренней docker-сети (`backend:8000/metrics`), минуя nginx. location = /metrics { return 403; } # --- Схема OpenAPI: FastAPI отдаёт её на КОРНЕВОМ пути /openapi.json (не # под /api/ — маршруты backend объявлены с явным префиксом /api/... в # декораторах, а не через APIRouter(prefix=...), см. backend/main.py), # поэтому без этого location запрос попадал бы в SPA-фолбэк ниже # (`location /` отдаёт index.html фронта вместо схемы — мешало отладке). location = /openapi.json { set $backend_upstream http://backend:8000; proxy_pass $backend_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # --- Медиа (аватары): раздача напрямую из volume, в обход backend. --- location /media/ { alias /media/; autoindex off; } # --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx # (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с # history-fallback на /index.html нужен для клиентского роутинга # react-router (deep-ссылки вида /conferences/, /admin/settings — # при перезагрузке страницы отдаётся index.html, дальше роутит SPA). # Более специфичные location выше (/api/, /media/, = /metrics, /livekit/, # /ws/) матчатся раньше этого catch-all, поэтому API и метрики не # перехватываются фронтом. location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } }