13 KiB
Deployment Guide
Table of Contents
- Deployment Guide
- Table of Contents
- Environments
- Production Target (Hetzner)
- Target Topology
- Prerequisites
- Environment File
- Pull-and-Run Deployment
- First-Time or Recovery Provisioning
- Health Verification
- Zero/Low-Downtime Update Pattern
- Rollback
- Operational Tasks
- Nginx and Proxy Considerations
- Security Baseline
- Common Deployment Pitfalls
- Troubleshooting
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://<hetzner-ip>: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 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
- Provision Hetzner VPS (see Production Deployment section)
- Add production secrets to Gitea (
CI_PROD_*prefix) - Update
DOCKER_HOSTin CD workflow to Hetzner public IP - Trigger deploy → validate health checks
- Update Cloudflare A record
linearvoid.tv→ Hetzner IP - Enable HTTPS, WAF, cache rules on production zone
- Decommission test lab as public target
Production Preflight
Before the cutover in the plan above, treat the following as the minimum readiness gate:
- Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
- Confirm all required production secrets are present under the
CI_PROD_*prefix. - Confirm
linearvoid.tv,www.linearvoid.tv, andmedia.linearvoid.tvDNS targets are correct for the intended phase. - Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in Cloudflare Uptimerobot checklist.
- Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
- Verify rollback is still possible before the final DNS switch.
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
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
Three Gitea Actions workflows drive the full pipeline:
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
Push to main |
Lint, build frontend, vet Go, build & push Docker images (:latest, :main) |
cd.yml |
Push to main |
Deploy to test lab (192.168.88.40:2375) — runs in parallel with CI |
release.yml |
Tag push *.*.* |
Build & push versioned Docker images (:X.Y.Z, :X.Y, :latest), create Gitea Release |
workers-deploy.yml |
After CI succeeds | Deploy frontend to Cloudflare Workers (see docs/operations/gitea-cloudflare-workers.md) |
CI and CD run simultaneously on every push to main. The deploy pulls whatever :latest images are available in the registry at that moment.
The CD workflow (cd.yml) uses DOCKER_HOST to target the deployment host. To switch targets:
# Test lab (current)
DOCKER_HOST=tcp://192.168.88.40:2375
# Production (when provisioned)
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
No code changes needed in the pipeline. See DEPLOYMENT.md Production Deployment section for full 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:
DATABASE_URL— PostgreSQL connection stringREDIS_URL— Redis connection stringADMIN_USERNAME/ADMIN_PASSWORD— admin dashboard credentialsADMIN_SESSION_TTL_HOURS— session expiry (default 12)VITE_PUBLIC_APP_URL/VITE_PUBLIC_WS_URL/VITE_PUBLIC_HLS_URL— frontend URLs
Cloudflare R2 (required for HLS segment sync to edge):
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY— R2 write credentialsR2_BUCKET_NAME— bucket name (e.g.linearvoid-media)R2_ENDPOINT— S3-compatible endpoint URLR2_REGION— set toauto
Cloudflare (optional, for Workers):
CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDMEDIA_DOMAIN— split media domain (e.g.media.linearvoid.tv)
See .env.example and SETUP.md for full variable reference.
Gitea Secrets
The CD workflow (cd.yml) injects sensitive values from Gitea secrets into the .env file at deploy time. The following secrets must be configured in the Gitea repository:
| Secret Name | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
ADMIN_USERNAME |
Admin dashboard username |
ADMIN_PASSWORD |
Admin dashboard password |
R2_ACCESS_KEY_ID |
Cloudflare R2 access key |
R2_SECRET_ACCESS_KEY |
Cloudflare R2 secret key |
R2_BUCKET_NAME |
R2 bucket name |
R2_ENDPOINT |
R2 S3-compatible endpoint URL |
R2_ADMIN_ACCESS_KEY_ID |
R2 admin access key (optional) |
R2_ADMIN_SECRET_ACCESS_KEY |
R2 admin secret key (optional) |
R2_ADMIN_ENDPOINT |
R2 admin endpoint (optional) |
CLOUDFLARE_API_TOKEN |
Cloudflare API token (optional) |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare account ID (optional) |
CI_REGISTRY_USER |
Gitea container registry username |
CI_REGISTRY_TOKEN |
Gitea container registry token |
Pull-and-Run Deployment
Images are built and pushed automatically by the CI and release workflows (see CONTRIBUTING.md for the release procedure).
Every push to main triggers CI (builds :main and :latest tags) and CD (deploys :latest to test lab) in parallel. Semantic version tags (*.*.*, e.g. 1.0.9) trigger the release workflow which builds :X.Y.Z, :X.Y, and :latest tags and creates a Gitea Release.
From repository root:
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
- Ensure host paths exist:
mkdir -p data/chat data/playlist data/media data/hls
- Confirm volumes and networks:
docker volume ls
docker network ls
- Start stack:
docker compose -f docker-compose.prod.yml up -d
Health Verification
After startup, check service status:
docker compose -f docker-compose.prod.yml ps
Endpoint checks:
- App: http://{host}:8080/
- Nginx health: http://{host}:8080/health
- Chat health: http://{host}:3000/health
- Playout health: http://{host}:8081/health
- Prometheus: http://{host}:9090
- Grafana: http://{host}:3001
Domain checks:
Zero/Low-Downtime Update Pattern
- Pull newest images.
- Recreate changed services only:
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
- Re-run health checks.
Rollback
If new images are unhealthy:
- Pin previous tags in docker-compose.prod.yml.
- Pull pinned tags.
- Restart impacted services.
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
Operational Tasks
View logs
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
Restart one service
docker compose -f docker-compose.prod.yml restart chat
Stop stack
docker compose -f docker-compose.prod.yml down
Keep volumes (recommended) vs remove volumes (destructive)
# 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, see nginx.conf.
- 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-<svc> --tail 50 |
Check .env vars, volume mounts, port conflicts |
| Playout stall / slate mode | curl http://<host>:8081/health — mode=slate |
Check playlist file, media paths, disk space |
| Chat not connecting | curl http://<host>: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://<host>/hls/playlist.m3u8 |
Check playout health, HLS dir permissions |
| Prometheus no targets | curl http://<host>: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.