diff --git a/.env.example b/.env.example index e1f1481..72ef951 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,16 @@ R2_REGION=auto # Cloudflare (for custom domain, cache rules, Workers if used) CLOUDFLARE_API_TOKEN=your-cloudflare-api-token CLOUDFLARE_ACCOUNT_ID=your-account-id -MEDIA_DOMAIN=media.example.com +MEDIA_DOMAIN=media.linearvoid.tv + +# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts) +# Local defaults keep development on localhost. Production should use: +# VITE_PUBLIC_APP_URL=https://linearvoid.tv +# VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws +# VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 +VITE_PUBLIC_APP_URL=http://localhost:8080 +VITE_PUBLIC_WS_URL=ws://localhost:3000/ws +VITE_PUBLIC_HLS_URL=http://localhost:8080/hls/playlist.m3u8 # Gitea Container Registry (for self-hosted Docker images) GITEA_REGISTRY_URL=git.allucanget.biz @@ -34,7 +43,7 @@ CHAT_PORT=3000 NGINX_PORT=80 # Playout configuration -PLAYLIST_PATH=/app/playlist.txt +PLAYLIST_PATH=/app/playlist/playlist.txt HLS_SEGMENT_DURATION=6 HLS_WINDOW_SIZE=24 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3e1d5..fc6b3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ # Change Log for linearvoid +## [1.0.1] - 2026-08-03 + +### Changed + +- Expanded the setup guide with current `.env.example` variables, development prerequisites, Cloudflare R2 setup, Gitea registry setup, and a production deployment guide. +- Aligned frontend, nginx, and deployment docs with the `linearvoid.tv` app domain and `media.linearvoid.tv` HLS domain model. + ## [1.0.0] - 2026-08-02 -**Full Backend and Frontend Implementation Complete** +### Added + +Full backend and frontend implementation complete. - **Playout Service** (`backend/playout/`): Go-based playlist management, ffprobe normalization validation, continuous HLS with immutable segment naming, automatic R2 sync, health check endpoint, and Docker integration (ADR-001, ADR-003). - **Chat Backend** (`backend/chat/`): Go + Gorilla WebSocket, Redis for presence/rate limiting/pub/sub, SQLite persistence for message history and ModerationLog (ADR-006/ADR-007), public rooms, private DM routing, moderation commands with immutable audit logging, health check endpoint, and docker-compose integration. diff --git a/README.md b/README.md index 3a6d045..5b169c8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Linear Void -A self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.). +[Linear Void](https://linearvoid.tv) is a self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.). **Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid) @@ -107,6 +107,7 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu ## Development - Use Docker Compose for local development (playout + chat + frontend). +- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery. - Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements). - Monitor cache hit ratio and R2 operations closely — they determine real cost. diff --git a/SETUP.md b/SETUP.md index 5f0e733..5ad67ac 100644 --- a/SETUP.md +++ b/SETUP.md @@ -5,12 +5,24 @@ This guide covers prerequisites, environment setup, running the application loca ## Prerequisites - **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com). -- **Node.js 20+**: For frontend development (`cd frontend && npm run dev`). +- **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`). +- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow. +- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`). - **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows). - **Git**: For cloning and version control. - **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases). +- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow. - **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001). +## Development Prerequisites + +- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`. +- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`. +- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly. +- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly. +- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically. +- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow. + ## Environment Setup 1. **Clone the Repository** (if not already done): @@ -34,10 +46,17 @@ This guide covers prerequisites, environment setup, running the application loca R2_SECRET_ACCESS_KEY=your-r2-secret-key R2_BUCKET_NAME=linearvoid-media R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com + R2_REGION=auto # Cloudflare (for custom domain and cache rules) CLOUDFLARE_API_TOKEN=your-cloudflare-api-token - MEDIA_DOMAIN=media.example.com + CLOUDFLARE_ACCOUNT_ID=your-account-id + MEDIA_DOMAIN=media.linearvoid.tv + + # Public frontend URLs + VITE_PUBLIC_APP_URL=http://localhost:8080 + VITE_PUBLIC_WS_URL=ws://localhost:3000/ws + VITE_PUBLIC_HLS_URL=http://localhost:8080/hls/playlist.m3u8 # Gitea Container Registry (for self-hosted Docker images) GITEA_REGISTRY_URL=git.allucanget.biz @@ -46,33 +65,112 @@ This guide covers prerequisites, environment setup, running the application loca GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid # Chat / Database - REDIS_URL=redis://redis:6379/0 - DATABASE_URL=sqlite://./data/chat.db # or postgres://user:pass@db:5432/linearvoid + DATABASE_URL=postgres://user:password@localhost:5432/linearvoid + # or for SQLite in local development: + # DATABASE_URL=sqlite://./data/chat.db + REDIS_URL=redis://localhost:6379/0 # Ports (for local development) + PLAYOUT_PORT=8080 CHAT_PORT=3000 - NGINX_PORT=8080 - PLAYOUT_HEALTH_PORT=8081 + NGINX_PORT=80 # Playout Configuration - PLAYLIST_PATH=/app/playlist/playlist.txt + PLAYLIST_PATH=/app/playlist.txt HLS_SEGMENT_DURATION=6 HLS_WINDOW_SIZE=24 - # Logging + # Logging / Monitoring LOG_LEVEL=info + PROMETHEUS_PORT=9090 + GRAFANA_PORT=3001 + + # Development flags + NODE_ENV=development + GO_ENV=development + + # Gitea API access (optional, for CI/release automation) + GITEA_SERVER_URL=https://git.allucanget.biz + GITEA_TOKEN=your-gitea-token + GITEA_USERNAME=your-gitea-username ``` -4. **Create Data Directories**: +4. **Check local value overrides for Docker Compose**: + - `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx. + - `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry. + - For production on the VPS, change hostnames in `.env` so containers talk to each other over the compose network, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:@db:5432/linearvoid`. + - For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. + +5. **Create Data Directories**: ```bash mkdir -p data/playlist data/media data/hls data/chat ``` -5. **Prepare Sample Videos** (optional): +6. **Prepare Sample Videos** (optional): - Place normalized MP4 files in `data/media/`. - Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements). +## Cloudflare R2 Setup + +1. **Create an R2 bucket**: + - Create a bucket such as `linearvoid-media` in Cloudflare R2. + - Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`. + +2. **Generate R2 API credentials**: + - Create an access key pair with read/write permissions for the bucket. + - Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`. + +3. **Find the S3 endpoint**: + - Use the account-scoped endpoint format `https://.r2.cloudflarestorage.com`. + - Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable. + +4. **Bind a custom media domain**: + - Attach a domain such as `media.linearvoid.tv` to the bucket. + - Set `MEDIA_DOMAIN` to that hostname. + +5. **Configure app-domain DNS and TLS**: + - Point `linearvoid.tv` to the public entrypoint serving nginx. + - Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`. + - Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin. + - Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`. + +6. **Apply cache behavior**: + - Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`. + - Enable Smart Tiered Cache for the R2-backed hostname. + - If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone. + +7. **Verify access**: + - Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url `. + - After deployment, check Cloudflare analytics for cache hit ratio and origin request volume. + +## Gitea Setup + +1. **Create or verify a Gitea user**: + - The user should have access to the repository and permission to read from the container registry. + +2. **Create an access token**: + - Generate a token with the scopes needed for package/registry access and, if applicable, repository automation. + - Store it as `GITEA_TOKEN` in `.env`. + +3. **Configure registry variables**: + - Set `GITEA_REGISTRY_URL` to the Gitea host. + - Set `GITEA_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`. + - Set `GITEA_USERNAME` to the account that will push or pull images. + +4. **Authenticate Docker to the registry**: + + ```bash + docker login git.allucanget.biz + ``` + +5. **Build and publish images when needed**: + - Local development uses `docker-compose.yml`, which builds from source. + - Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace. + +6. **Validate image availability**: + - Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server. + ## Running the Application ### Local Development @@ -85,7 +183,7 @@ This guide covers prerequisites, environment setup, running the application loca 2. **Access the Application**: - Viewer: - - Chat WebSocket: ws://localhost:3000/ws + - Chat WebSocket: `ws://localhost:3000/ws` when using the local env defaults, or `ws://localhost:8080/ws` if you switch to same-origin local routing. - Admin Interface: (use the credentials from `.env` or default admin/admin) - Health Checks: - Playout: @@ -98,23 +196,85 @@ This guide covers prerequisites, environment setup, running the application loca ```bash cd frontend + npm install npm run dev ``` The dev server runs on . It proxies API calls to the backend services. -4. **Stop Services**: +4. **Build frontend assets for nginx-backed local runs**: + + ```bash + cd frontend + npm install + npm run build + ``` + + The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output. + +5. **Stop Services**: ```bash docker compose down ``` +### Production Setup Guide + +1. **Prepare the target host**: + - Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data. + - Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose. + +2. **Clone the repository and create the environment file**: + + ```bash + git clone https://git.allucanget.biz/zwitschi/linearvoid.git + cd linearvoid + cp .env.example .env + ``` + +3. **Set production-safe `.env` values**: + - Replace placeholder secrets. + - Use compose-network hostnames for internal services, for example `redis` and `db`. + - Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`. + - Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure. + - Set `MEDIA_DOMAIN=media.linearvoid.tv`. + - Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. + +4. **Create runtime directories on the server**: + + ```bash + mkdir -p data/playlist data/media data/hls data/chat + ``` + +5. **Publish or verify required images in Gitea**: + - Ensure the `playout`, `chat`, and `nginx` images exist under `${GITEA_REGISTRY_URL}/${GITEA_REGISTRY_NAMESPACE}`. + - Log in to the registry from the VPS before pulling. + +6. **Deploy the production stack**: + + ```bash + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + ``` + +7. **Run post-deploy verification**: + - Check `docker compose -f docker-compose.prod.yml ps`. + - Confirm nginx, chat, and playout health endpoints respond. + - Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare. + +8. **Harden and operate the deployment**: + - Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected. + - Restrict Grafana and Prometheus exposure to trusted IPs or a private network. + - Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies. + ### Production Deployment (Hetzner VPS + Cloudflare) 1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001). 2. **Configure Cloudflare**: - Create R2 bucket with `enam` location hint. - - Set up custom domain (`media.example.com`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`. + - Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`. + - Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain. + - Use Full (strict) TLS for both app and media hostnames. - Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management. 3. **Deploy**: - Copy the project to the VPS. @@ -133,6 +293,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs - **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard. - **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use. - **Admin Interface**: Ensure the protected route is configured in nginx and authentication is set up (default is open for local development). +- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist. ## Further Reading @@ -143,4 +304,4 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance. -**Last Updated**: 2026-08-02 +**Last Updated**: 2026-08-03 diff --git a/backend/chat/Dockerfile b/backend/chat/Dockerfile index 50263db..3a919d4 100644 --- a/backend/chat/Dockerfile +++ b/backend/chat/Dockerfile @@ -1,17 +1,17 @@ FROM golang:1.23-alpine AS builder -WORKDIR /app -COPY src/ ./src/ -COPY go.mod go.sum ./ +RUN apk add --no-cache gcc musl-dev -RUN go mod download -RUN CGO_ENABLED=0 GOOS=linux go build -o /chat-server ./src/main.go +WORKDIR /app +COPY go.mod ./ +COPY src/ ./src/ +RUN go mod tidy +RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go FROM alpine:3.20 RUN apk --no-cache add ca-certificates WORKDIR /app -COPY --from=builder /chat-server . -COPY config/ ./config/ +COPY --from=builder /chat-server /usr/local/bin/chat-server EXPOSE 3000 -CMD ["./chat-server"] \ No newline at end of file +CMD ["/usr/local/bin/chat-server"] \ No newline at end of file diff --git a/backend/playout/Dockerfile b/backend/playout/Dockerfile index 0f6d05d..cc8b564 100644 --- a/backend/playout/Dockerfile +++ b/backend/playout/Dockerfile @@ -1,8 +1,7 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY src/ ./src/ -COPY go.mod go.sum ./ # Initialize Go module if not present RUN go mod init playout || true diff --git a/backend/playout/src/playlist.go b/backend/playout/src/playlist.go index 65241d9..1844ae4 100644 --- a/backend/playout/src/playlist.go +++ b/backend/playout/src/playlist.go @@ -75,7 +75,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) { v := VideoFile{Path: videoPath} cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath) - output, err := cmd.Output() + _, err := cmd.Output() if err != nil { v.Validation = "ffprobe failed: " + err.Error() return v, err diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e59c868..618518f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -49,7 +49,7 @@ services: ports: - "${NGINX_PORT:-8080}:80" volumes: - - ./data/hls:/usr/share/nginx/html/hls:ro + - ./data/hls:/srv/hls:ro networks: - linearvoid-net depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 3dd043f..34fd56b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: playout: image: ${GITEA_REGISTRY_URL:-git.allucanget.biz}/${GITEA_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest @@ -60,8 +58,8 @@ services: ports: - "${NGINX_PORT:-8080}:80" volumes: - - ./frontend/dist:/usr/share/nginx/html:ro - - ./data/hls:/usr/share/nginx/html/hls:ro + - ./frontend/dist:/srv/frontend:ro + - ./data/hls:/srv/hls:ro networks: - linearvoid-net depends_on: diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f2f8e97..9c76382 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -16,13 +16,34 @@ let ws: WebSocket | null = null; let currentRoom = "main"; let username = "viewer" + Math.floor(Math.random() * 1000); +function resolveWebSocketBaseUrl() { + const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); + + if (configuredUrl) { + return configuredUrl; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/ws`; +} + +function resolveManifestUrl() { + const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); + + if (configuredUrl) { + return configuredUrl; + } + + return new URL("/hls/playlist.m3u8", window.location.origin).toString(); +} + function connectWebSocket(room: string) { if (ws) ws.close(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - ws = new WebSocket( - `${protocol}//localhost:3000/ws?username=${username}&room=${room}`, - ); + const socketUrl = new URL(resolveWebSocketBaseUrl()); + socketUrl.searchParams.set("username", username); + socketUrl.searchParams.set("room", room); + ws = new WebSocket(socketUrl); ws.onopen = () => { console.log(`Connected to chat room: ${room}`); @@ -113,7 +134,7 @@ window.app = () => { // Initialize HLS Player const video = document.getElementById("player") as HTMLVideoElement; - const manifestUrl = "http://localhost:8080/hls/playlist.m3u8"; // from nginx/pla yout + const manifestUrl = resolveManifestUrl(); if (Hls.isSupported()) { hls = new Hls(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9193006 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + envDir: "..", +}); diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile index 64bd375..5899292 100644 --- a/infrastructure/nginx/Dockerfile +++ b/infrastructure/nginx/Dockerfile @@ -3,7 +3,7 @@ FROM nginx:alpine COPY nginx.conf /etc/nginx/nginx.conf # Create directories for HLS and frontend -RUN mkdir -p /usr/share/nginx/html/hls /usr/share/nginx/html/admin +RUN mkdir -p /srv/frontend /srv/hls /srv/frontend/admin EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 24b81b0..2902a9e 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -3,11 +3,25 @@ events { } http { + resolver 127.0.0.11 ipv6=off valid=30s; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + server { listen 80; - server_name localhost; + server_name www.linearvoid.tv; - root /usr/share/nginx/html; + return 301 https://linearvoid.tv$request_uri; + } + + server { + listen 80 default_server; + server_name linearvoid.tv localhost 127.0.0.1; + + root /srv/frontend; index index.html; # Serve static frontend @@ -17,16 +31,20 @@ http { # Proxy chat WebSocket location /ws { - proxy_pass http://chat:3000; + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # Serve HLS from playout (with cache headers for Cloudflare) + # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. location /hls/ { - alias /usr/share/nginx/html/hls/; + alias /srv/hls/; add_header Cache-Control "public, max-age=3600"; add_header Access-Control-Allow-Origin "*"; }