# Deployment Guide ## Environments Linear Void runs in two separate environments: | Environment | Host | Docker Target | Purpose | | -------------- | -------------------------- | -------------------------- | ---------------------------------------------- | | **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation | | **Production** | Hetzner VPS (TBD) | `tcp://:2375` | Public linearvoid.tv serving real viewers | **Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-deployment-hetzner) below. ## Production Target (Hetzner) **Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created. ### Planned Infrastructure | Resource | Spec | | -------------- | ------------------------------------------------ | | Provider | Hetzner Cloud | | Region | Ashburn, VA (us-east) | | Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent | | OS | Ubuntu 24.04 LTS or Debian 12 | | Bandwidth | 20 TB included traffic/month | | Estimated cost | ~€7–10/month base | ### Cutover Plan 1. Provision Hetzner VPS (see `TODO.md` #92) 2. Add production secrets to Gitea (`CI_PROD_*` prefix) 3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP 4. Trigger deploy → validate health checks 5. Update Cloudflare A record `linearvoid.tv` → Hetzner IP 6. Enable HTTPS, WAF, cache rules on production zone 7. Decommission test lab as public target ### Firewall / Security Group | Port | Source | Purpose | | ---- | --------------------- | ------------------------------ | | 22 | Admin IPs | SSH management | | 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) | | 443 | 0.0.0.0/0 | HTTPS (nginx) | | 2375 | Gitea runner IP only | Docker remote API (TLS) | | 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) | | 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) | ### Pipeline Readiness The Gitea Actions CD workflow (`cd.yml`) already supports `DOCKER_HOST` as a configurable variable. To switch targets: ```bash # Test lab (current) DOCKER_HOST=tcp://192.168.88.40:2375 # Production (when provisioned) DOCKER_HOST=tcp://:2375 ``` No code changes needed in the pipeline. See `TODO.md` #91 and #92–#99 for full production deployment checklist. ## Target Topology - Host: Linux server with Docker Engine - Reverse proxy: nginx container (linearvoid-nginx) - Core services: playout, chat, redis, postgres - Observability: prometheus, grafana - CDN/edge: Cloudflare ## Prerequisites - Docker Engine + Docker Compose plugin - Network access to git.allucanget.biz - A valid .env file with production values - DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used) ## Environment File Use .env with at least: - CI_REGISTRY_URL - CI_REGISTRY_NAMESPACE - CHAT_PORT - NGINX_PORT - DATABASE_URL (if overridden) - REDIS_URL (if overridden) - ADMIN_USERNAME - ADMIN_PASSWORD - VITE_PUBLIC_APP_URL - VITE_PUBLIC_WS_URL - VITE_PUBLIC_HLS_URL - Cloudflare/R2 variables when media sync is enabled See .env.example and SETUP.md for full variable reference. ## Pull-and-Run Deployment Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure). Every push to `main` produces `:main` and `:latest` tags. Semantic version tags (`v*`) produce `:X.Y.Z`, `:X.Y`, and `:latest` tags. From repository root: ```bash docker login git.allucanget.biz docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d --remove-orphans ``` ## First-Time or Recovery Provisioning 1. Ensure host paths exist: ```bash mkdir -p data/chat data/playlist data/media data/hls ``` 1. Confirm volumes and networks: ```bash docker volume ls docker network ls ``` 1. Start stack: ```bash docker compose -f docker-compose.prod.yml up -d ``` ## Health Verification After startup, check service status: ```bash docker compose -f docker-compose.prod.yml ps ``` Endpoint checks: - App: - Nginx health: - Chat health: - Playout health: - Prometheus: - Grafana: Domain checks: - - - ## Zero/Low-Downtime Update Pattern 1. Pull newest images. 1. Recreate changed services only: ```bash docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d nginx chat playout ``` 1. Re-run health checks. ## Rollback If new images are unhealthy: 1. Pin previous tags in docker-compose.prod.yml. 2. Pull pinned tags. 3. Restart impacted services. ```bash docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d nginx chat playout ``` ## Operational Tasks ### View logs ```bash docker compose -f docker-compose.prod.yml logs -f nginx chat playout ``` ### Restart one service ```bash docker compose -f docker-compose.prod.yml restart chat ``` ### Stop stack ```bash docker compose -f docker-compose.prod.yml down ``` ### Keep volumes (recommended) vs remove volumes (destructive) ```bash # keep data docker compose -f docker-compose.prod.yml down # destructive (remove data) docker compose -f docker-compose.prod.yml down -v ``` ## Nginx and Proxy Considerations - If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx. - For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers. ## Security Baseline - Use strong ADMIN_PASSWORD and rotate regularly. - Keep Docker host access restricted. - Prefer HTTPS/WSS through Cloudflare. - Restrict exposed ports if only proxy access is needed. ## Common Deployment Pitfalls - Missing frontend build artifacts in local-only compose workflows. - Wrong registry credentials or missing login. - Misaligned domain env vars (app/ws/hls) causing mixed-origin errors. ## Troubleshooting | Symptom | Check | Fix | | -------------------------- | --------------------------------------------- | ------------------------------------------------------------- | | Service not starting | `docker logs linearvoid- --tail 50` | Check `.env` vars, volume mounts, port conflicts | | Playout stall / slate mode | `curl http://:8081/health` — mode=slate | Check playlist file, media paths, disk space | | Chat not connecting | `curl http://:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` | | Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers | | Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) | | HLS not playing | `curl http:///hls/playlist.m3u8` | Check playout health, HLS dir permissions | | Prometheus no targets | `curl http://:9090/targets` | Verify container network, scrape config | | Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned | - Proxy-layer body size limits returning 413 on upload. - Health checks failing before service warmup is complete.