From bb334106adde28f1cb00595500c8fc7cd1646c34 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sun, 21 Jun 2026 14:41:28 +0530 Subject: [PATCH] Add Hostinger (managed-Traefik) deployment files (#459) Co-authored-by: Claude Opus 4.8 (1M context) --- deploy/hostinger/.env.example | 65 +++++ deploy/hostinger/README.md | 59 ++++ deploy/hostinger/docker-compose.traefik.yaml | 58 ++++ deploy/hostinger/docker-compose.yaml | 281 +++++++++++++++++++ 4 files changed, 463 insertions(+) create mode 100644 deploy/hostinger/.env.example create mode 100644 deploy/hostinger/README.md create mode 100644 deploy/hostinger/docker-compose.traefik.yaml create mode 100644 deploy/hostinger/docker-compose.yaml diff --git a/deploy/hostinger/.env.example b/deploy/hostinger/.env.example new file mode 100644 index 00000000..11728366 --- /dev/null +++ b/deploy/hostinger/.env.example @@ -0,0 +1,65 @@ +# Dograh — Hostinger VPS (managed Traefik) environment +# Copy to .env (in this directory) and fill in. See README.md for bring-up. + +# --------------------------------------------------------------------------- +# Public identity +# --------------------------------------------------------------------------- +# The domain users hit in the browser. Must already point (DNS A record) at +# this VPS, and be a router rule Traefik will issue a Let's Encrypt cert for. +PUBLIC_HOST=app.example.com + +# --------------------------------------------------------------------------- +# Managed Traefik wiring (confirm these three with Hostinger) +# --------------------------------------------------------------------------- +# Name of the existing Docker network Traefik watches/attaches to. +TRAEFIK_NETWORK=traefik +# Name of Traefik's HTTPS entrypoint (often "websecure" or "https"). +TRAEFIK_ENTRYPOINT=websecure +# Name of Traefik's Let's Encrypt certificate resolver. +TRAEFIK_CERTRESOLVER=letsencrypt + +# --------------------------------------------------------------------------- +# WebRTC media (coturn) — REQUIRED for voice. NOT proxied by Traefik. +# --------------------------------------------------------------------------- +# Public IP of this VPS (or a domain that resolves to it). coturn advertises +# this as its external relay address. +TURN_HOST=203.0.113.10 +# Shared secret for time-limited TURN credentials. Generate a strong random +# value, e.g.: openssl rand -hex 32 +TURN_SECRET=change-me-to-a-long-random-secret +# Set true only to *force* relay-only ICE for debugging TURN reachability. +FORCE_TURN_RELAY=false + +# --------------------------------------------------------------------------- +# Secrets +# --------------------------------------------------------------------------- +# JWT signing secret. Generate, e.g.: openssl rand -hex 32 +OSS_JWT_SECRET=change-me-to-a-long-random-secret +# Postgres password (baked into the volume on first init; changing later does +# NOT re-key an existing volume). +POSTGRES_PASSWORD=postgres + +# Internal datastore credentials. Redis and MinIO are NOT published to the host +# (reachable only on the internal Docker network), but set strong values anyway +# on a public box — the compose falls back to weak well-known defaults +# (redissecret / minioadmin) if these are unset. Generate with: openssl rand -hex 32 +REDIS_PASSWORD=change-me-to-a-long-random-secret +MINIO_ROOT_USER=dograh +MINIO_ROOT_PASSWORD=change-me-to-a-long-random-secret + +# --------------------------------------------------------------------------- +# Images — pin to a GitHub release tag for predictable upgrades/rollback. +# Leave at "latest" only for evaluation. +# --------------------------------------------------------------------------- +REGISTRY=dograhai +DOGRAH_VERSION=latest + +# --------------------------------------------------------------------------- +# Optional +# --------------------------------------------------------------------------- +ENABLE_TELEMETRY=true + +# Only needed if you run the bundled docker-compose.traefik.yaml to self-host a +# stand-in Traefik for testing (NOT on Hostinger — their Traefik provides this). +# Email Let's Encrypt uses for expiry notices. +ACME_EMAIL=admin@example.com diff --git a/deploy/hostinger/README.md b/deploy/hostinger/README.md new file mode 100644 index 00000000..366a8c06 --- /dev/null +++ b/deploy/hostinger/README.md @@ -0,0 +1,59 @@ +# Hostinger (managed-Traefik) deployment + +Deploy Dograh where a shared, managed Traefik with Let's Encrypt already +terminates TLS and routes ingress — e.g. **Hostinger's VPS Docker Manager**. +The same files work on any host that fronts containers with Traefik. + +## Files + +| File | Role | Deploy on Hostinger? | +|---|---|---| +| `docker-compose.yaml` | The Dograh app stack. **Single self-contained file** — named volumes only, no host bind-mounts, no init/sidecar that reads files outside the compose. | ✅ Yes | +| `.env.example` | Required + optional environment variables, with guidance. Copy to `.env` and fill in. | ✅ Yes (as the env template) | +| `docker-compose.traefik.yaml` | A standalone Traefik + Let's Encrypt that **stands in for** the managed Traefik, so you can reproduce the environment on a plain VPS for testing. Also documents what the platform's Traefik must provide. | ❌ **No — reference only** | + +## What the app stack needs from Traefik + +Routing is declared with Traefik labels on `ui`, `api`, and `minio`: +`/api/v1` → api (includes the signaling **WebSocket**), `/voice-audio` → minio, +everything else → ui. For that to work the platform's Traefik must offer: + +- an HTTPS entrypoint — set `TRAEFIK_ENTRYPOINT` (e.g. `websecure`) +- a Let's Encrypt certresolver — set `TRAEFIK_CERTRESOLVER` +- the Docker provider watching a shared network — set `TRAEFIK_NETWORK` +- a long `idleTimeout` so long-lived signaling WebSockets aren't cut +- (recommended) a global HTTP→HTTPS redirect + +Traefik upgrades WebSockets automatically — no special label is required. + +## WebRTC media (coturn) is NOT proxied by Traefik + +Voice audio is UDP (ICE/DTLS-SRTP), relayed by the bundled `coturn`. A reverse +proxy cannot carry it. coturn publishes host ports that **must be open in the +VPS firewall**: UDP+TCP `3478` and `5349`, and UDP `49152-49200`. `TURN_HOST` +must be the public IP (or a domain resolving to it). Without this, calls +connect (signaling succeeds) but have **no audio**. + +## Deploy on Hostinger + +The platform provides Traefik, so you only deploy the app stack: + +1. Copy `.env.example` → `.env` and fill in `PUBLIC_HOST`, `TURN_HOST`, the + secrets, and the three `TRAEFIK_*` values (matched to Hostinger's Traefik). +2. Import / deploy `docker-compose.yaml`. +3. Ensure the coturn UDP/TCP ports above are open in the firewall. + +## Test on a generic VPS (self-managed stand-in Traefik) + +On a box that does **not** already run Traefik: + +```bash +cp .env.example .env # fill in PUBLIC_HOST, TURN_HOST, secrets, ACME_EMAIL +docker network create traefik +docker compose -f docker-compose.traefik.yaml --env-file .env up -d # stand-in Traefik +docker compose --env-file .env up -d # app stack +``` + +A no-cost trick for a real cert without owning a domain: set +`PUBLIC_HOST=.sslip.io` (sslip.io resolves any embedded IP), which +Let's Encrypt will happily issue for. diff --git a/deploy/hostinger/docker-compose.traefik.yaml b/deploy/hostinger/docker-compose.traefik.yaml new file mode 100644 index 00000000..ceaa7f59 --- /dev/null +++ b/deploy/hostinger/docker-compose.traefik.yaml @@ -0,0 +1,58 @@ +# Standalone Traefik + Let's Encrypt — STANDS IN FOR Hostinger's managed Traefik. +# ================================================================= +# On Hostinger's VPS Docker Manager you do NOT deploy this — their platform +# already runs Traefik. Use this file to reproduce that environment on a +# generic VPS (e.g. a plain EC2 box) so you can test docker-compose.yaml +# end to end: TLS issuance, HTTP->HTTPS redirect, WebSocket upgrade, routing. +# +# It also documents exactly what we need Hostinger's Traefik to provide: +# - an HTTPS entrypoint (here: websecure / :443) +# - a Let's Encrypt certresolver (here: letsencrypt) +# - the Docker provider watching a shared network (here: traefik) +# - a long idleTimeout so long-lived signaling WebSockets aren't cut +# +# Bring up BEFORE the app stack, on the same external network: +# docker network create traefik +# docker compose -f docker-compose.traefik.yaml --env-file .env up -d +# docker compose --env-file .env up -d +# ================================================================= + +services: + traefik: + image: traefik:v3.1 + container_name: traefik + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + # Global HTTP->HTTPS redirect (the ACME HTTP-01 challenge is still served + # on :80 — Traefik handles the challenge ahead of this redirect). + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + # Keep long-lived WebSockets (signaling) from being cut while idle. + - --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=3600s + # Let's Encrypt via HTTP-01. Must match TRAEFIK_CERTRESOLVER in the app .env. + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in .env} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + # For repeated test runs, point at LE staging to avoid prod rate limits: + # - --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-acme:/letsencrypt + networks: + - traefik + +volumes: + traefik-acme: + +networks: + traefik: + external: true + name: ${TRAEFIK_NETWORK:-traefik} diff --git a/deploy/hostinger/docker-compose.yaml b/deploy/hostinger/docker-compose.yaml new file mode 100644 index 00000000..ceddc180 --- /dev/null +++ b/deploy/hostinger/docker-compose.yaml @@ -0,0 +1,281 @@ +# Dograh — Hostinger VPS Docker Manager deployment +# ================================================================= +# This variant is for environments where a SHARED, MANAGED Traefik +# (with Let's Encrypt) already terminates TLS and routes ingress — +# e.g. Hostinger's VPS Docker Manager catalog. +# +# Differences from the canonical docker-compose.yaml: +# - No bundled `nginx` service. Traefik is the ingress; we attach +# routing intent via labels instead of shipping our own proxy. +# - No `cloudflared` tunnel. The app is reachable at a real domain. +# - Web tier (ui/api/minio) publishes NO host ports — Traefik reaches +# them over its own Docker network. coturn is the ONLY service that +# binds host ports, because WebRTC media is UDP and CANNOT traverse +# an HTTP reverse proxy. +# - `api` runs ENVIRONMENT=production (correct public ICE-candidate +# filtering + UDP-first TURN). +# - coturn is configured entirely from CLI flags (no `dograh-init` +# renderer, no host bind-mounts). The whole stack is therefore a single +# self-contained file — nothing outside it needs to exist on the host, +# which is what a catalog compose-import requires. +# - FASTAPI_WORKERS is pinned to 1 (see the note on the `api` service). +# +# Required .env keys — see .env.example. At minimum: +# PUBLIC_HOST, TURN_HOST, TURN_SECRET, OSS_JWT_SECRET, +# TRAEFIK_NETWORK, TRAEFIK_CERTRESOLVER, TRAEFIK_ENTRYPOINT +# ================================================================= + +services: + postgres: + image: pgvector/pgvector:pg17 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}" + POSTGRES_DB: postgres + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # No host port: Postgres is reachable only inside app-network. + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 10 + networks: + - app-network + + redis: + image: redis:7 + restart: unless-stopped + command: > + --requirepass ${REDIS_PASSWORD:-redissecret} + # No host port: Redis is reachable only inside app-network. + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"] + interval: 3s + timeout: 10s + retries: 10 + networks: + - app-network + + minio: + image: minio/minio + container_name: minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-minioadmin}" + MINIO_API_CORS_ALLOW_ORIGIN: "*" + # No host ports. Browsers fetch audio via Traefik at + # https://${PUBLIC_HOST}/voice-audio/... (objects are public-read, + # unsigned URLs — no presign/host-signature to break). For console + # admin, port-forward 9001 over SSH instead of publishing it. + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}" + # Audio file downloads. Highest priority so it wins over the UI catch-all. + - "traefik.http.routers.dograh-audio.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/voice-audio`)" + - "traefik.http.routers.dograh-audio.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.dograh-audio.tls=true" + - "traefik.http.routers.dograh-audio.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" + - "traefik.http.routers.dograh-audio.priority=20" + - "traefik.http.services.dograh-audio.loadbalancer.server.port=9000" + networks: + - app-network + - traefik + + # TURN/STUN relay for WebRTC NAT traversal. This is the voice media path — + # it is UDP and is NOT, and cannot be, proxied by Traefik. These host ports + # MUST be published and opened in the VPS firewall, and TURN_HOST must be the + # VPS public IP (or a domain resolving to it). Without this, calls connect + # (signaling succeeds over Traefik) but carry NO audio. + # + # Configured entirely from CLI flags (`-n` disables config-file lookup), so + # the stack ships no host-side config and needs no init/renderer container — + # it is a single self-contained file, which is what a catalog import requires. + coturn: + image: coturn/coturn:4.8.0 + container_name: coturn + restart: unless-stopped + command: + - -n + - "--listening-port=3478" + - "--tls-listening-port=5349" + - "--min-port=49152" + - "--max-port=49200" + - "--external-ip=${TURN_HOST}" + - "--realm=dograh.com" + - --use-auth-secret + - "--static-auth-secret=${TURN_SECRET}" + - --fingerprint + - --no-cli + - --no-multicast-peers + - "--log-file=stdout" + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + - "5349:5349/udp" + - "5349:5349/tcp" + - "49152-49200:49152-49200/udp" + networks: + - app-network + + api: + image: ${REGISTRY:-dograhai}/dograh-api:${DOGRAH_VERSION:-latest} + restart: unless-stopped + volumes: + - shared-tmp:/tmp + environment: + # production => drop private-IP host ICE candidates on a public VPS and + # order TURN URIs UDP-first. Required for correct remote WebRTC. + ENVIRONMENT: "production" + LOG_LEVEL: "INFO" + + # Public HTTPS origin (Traefik-terminated). Used for absolute URLs and + # for verifying inbound telephony webhook signatures. + BACKEND_API_ENDPOINT: "https://${PUBLIC_HOST}" + + DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" + REDIS_URL: "redis://:${REDIS_PASSWORD:-redissecret}@redis:6379" + + ENABLE_AWS_S3: "false" + MINIO_ENDPOINT: "minio:9000" + # Full public URL browsers use to fetch audio. Traefik routes + # /voice-audio/ to minio:9000. This replaces the nginx sub_filter hack. + MINIO_PUBLIC_ENDPOINT: "https://${PUBLIC_HOST}" + # Must match the MinIO root creds above (same env vars). + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}" + MINIO_BUCKET: "voice-audio" + MINIO_SECURE: "false" + + # IMPORTANT: pinned to 1. The image launches FASTAPI_WORKERS as + # independent uvicorn processes on consecutive ports (8000, 8001, ...) + # expecting the bundled nginx to least_conn-balance long-lived + # WebSockets across them. We dropped that nginx for Traefik, and a + # managed Traefik (label provider) can only target one port — so values + # >1 would leave the extra workers idle. The container also runs + # singletons (telephony ARI manager, campaign orchestrator) + migrations + # that must run exactly once. Scale vertically (bigger VPS) here; do not + # raise this and do not naively replicate this service. + FASTAPI_WORKERS: "1" + + # Trust Traefik's X-Forwarded-Proto: https so request.url is https and + # inbound webhook signature checks pass. Narrow to the Docker subnet if + # you prefer. + FORWARDED_ALLOW_IPS: "*" + + # TURN — must match coturn. TURN_HOST is the VPS public IP / TURN domain. + TURN_HOST: "${TURN_HOST:?TURN_HOST is required for WebRTC media}" + TURN_SECRET: "${TURN_SECRET:?TURN_SECRET is required for WebRTC media}" + FORCE_TURN_RELAY: "${FORCE_TURN_RELAY:-false}" + + OSS_JWT_SECRET: "${OSS_JWT_SECRET:?OSS_JWT_SECRET must be set to a strong secret}" + + ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" + POSTHOG_API_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr" + POSTHOG_HOST: "https://us.i.posthog.com" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + 'python -c "import urllib.request; urllib.request.urlopen(''http://localhost:8000/api/v1/health'').read()"', + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}" + # REST API + the signaling WebSocket (/api/v1/ws/signaling/...). + # Traefik upgrades WebSockets automatically — no extra labels needed. + # Higher priority than the UI so /api/v1 is matched first. + - "traefik.http.routers.dograh-api.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.dograh-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.dograh-api.tls=true" + - "traefik.http.routers.dograh-api.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" + - "traefik.http.routers.dograh-api.priority=10" + - "traefik.http.services.dograh-api.loadbalancer.server.port=8000" + networks: + - app-network + - traefik + + ui: + image: ${REGISTRY:-dograhai}/dograh-ui:${DOGRAH_VERSION:-latest} + restart: unless-stopped + environment: + HOSTNAME: "0.0.0.0" + # Server-side (SSR) calls stay on the internal Docker network. + BACKEND_URL: "${BACKEND_URL:-http://api:8000}" + NODE_ENV: "oss" + ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" + POSTHOG_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr" + POSTHOG_HOST: "https://us.posthog.com" + depends_on: + api: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://127.0.0.1:3010 || exit 1", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}" + # Catch-all for everything that is not /api/v1 or /voice-audio. + - "traefik.http.routers.dograh-ui.rule=Host(`${PUBLIC_HOST}`)" + - "traefik.http.routers.dograh-ui.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.dograh-ui.tls=true" + - "traefik.http.routers.dograh-ui.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" + - "traefik.http.routers.dograh-ui.priority=1" + - "traefik.http.services.dograh-ui.loadbalancer.server.port=3010" + networks: + - app-network + - traefik + +volumes: + postgres_data: + redis_data: + minio-data: + driver: local + shared-tmp: + driver: local + +networks: + # Internal network for service-to-service traffic (db, redis, minio, coturn). + app-network: + driver: bridge + # The EXTERNAL network that the managed Traefik is attached to. Its name is + # provider-specific — set TRAEFIK_NETWORK in .env to match Hostinger's actual + # Traefik network. This file does not create it; it must already exist. + traefik: + external: true + name: ${TRAEFIK_NETWORK:-traefik}