Add Hostinger (managed-Traefik) deployment files (#459)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-21 14:41:28 +05:30 committed by GitHub
parent 678d4bfb1e
commit bb334106ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 463 additions and 0 deletions

View file

@ -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

View file

@ -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=<public-ip>.sslip.io` (sslip.io resolves any embedded IP), which
Let's Encrypt will happily issue for.

View file

@ -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}

View file

@ -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}