feat(scripts): free trusted HTTPS via sslip.io for public-IP remote i… (#460)

* feat(scripts): free trusted HTTPS via sslip.io for public-IP remote installs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: refactor setup scripts

* chore: generate sdk

* chore: fix messaging for setup_remote script

* fix: fix ffmpeg download url

* feat: centralise and simplify the url configuration

* fix: force script run as sudo

* fix: fix documentation

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-27 17:19:29 +05:30 committed by GitHub
parent 3309face2c
commit 78427817a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 838 additions and 392 deletions

View file

@ -59,56 +59,53 @@ RUN npm ci --omit=dev && npm cache clean --force
# Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for # Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for
# hardware acceleration we don't use server-side). # hardware acceleration we don't use server-side).
# #
# Resilient download: johnvansickle.com is the primary source but it's a single # Source: BtbN/FFmpeg-Builds, served from GitHub's release-assets CDN (fast,
# self-hosted host with no CDN and goes down intermittently. Use bounded-timeout # highly available, multi-arch). We pin a specific build for reproducibility,
# retries, then fall back to a pinned BtbN/FFmpeg-Builds autobuild. Every archive # but to a *month-end* autobuild tag — not a daily one. BtbN prunes daily
# is SHA256-verified before extraction. The two sources have different internal # autobuilds after ~2 weeks (the previous pin was a daily tag and started
# layouts, so locate the binaries with `find` rather than a fixed strip path. # 404ing once GC'd), but keeps one month-end snapshot per month long-term
# (~2 years back). A dated tag's assets are immutable, so the per-arch sha256
# below never rots: builds stay reproducible AND integrity-verified.
#
# To upgrade ffmpeg: bump BTBN_TAG + BTBN_REV to a newer month-end autobuild
# and refresh the two sha256s. No download needed — read tag, revision and
# per-asset sha256 straight from the GitHub release-asset metadata:
# gh api repos/BtbN/FFmpeg-Builds/releases/tags/<tag> \
# --jq '.assets[] | select(.name|test("(linux64|linuxarm64)-gpl\\.tar\\.xz$")) | "\(.name) \(.digest)"'
#
# `--speed-limit/--speed-time` aborts a *stalled* transfer after 30s of <1KB/s
# (the cause of "stuck" builds) without killing a slow-but-progressing
# download; `--max-time` is a hard backstop; `--retry` rides out transient CDN
# hiccups. The archive nests binaries under bin/, so locate them with `find`.
FROM debian:trixie-slim AS ffmpeg-static FROM debian:trixie-slim AS ffmpeg-static
ARG TARGETARCH ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \ ARG BTBN_TAG=autobuild-2026-05-31-13-22
curl ca-certificates xz-utils \ ARG BTBN_REV=N-124714-g49a77d37be
&& rm -rf /var/lib/apt/lists/* \ RUN set -eu ; \
&& case "${TARGETARCH}" in \ apt-get update && apt-get install -y --no-install-recommends \
amd64) \ curl ca-certificates xz-utils ; \
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ; \ rm -rf /var/lib/apt/lists/* ; \
primary_sha256="abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67" ; \ case "${TARGETARCH}" in \
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linux64-gpl.tar.xz" ; \ amd64) btbn_arch=linux64 ; \
fallback_sha256="6cfd689ee95ff128e89080af10c93f16e48760eb2acc124c5c8258dc922cc13b" ; \ sha256=ee052121296e6479325e09c6097d48e72a4af472d18c2b94388b5405dcde6cce ;; \
;; \ arm64) btbn_arch=linuxarm64 ; \
arm64) \ sha256=e97545305043794cdf7b698d713e29291464e0c35bb8e0f3ff1f62e4c56eedd6 ;; \
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ; \ *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2 ; exit 1 ;; \
primary_sha256="f4149bb2b0784e30e99bdda85471c9b5930d3402014e934a5098b41d0f7201b1" ; \ esac ; \
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linuxarm64-gpl.tar.xz" ; \ url="https://github.com/BtbN/FFmpeg-Builds/releases/download/${BTBN_TAG}/ffmpeg-${BTBN_REV}-${btbn_arch}-gpl.tar.xz" ; \
fallback_sha256="b90a31f1d0b030f5d8a3d11cfec736e369bd5a1371b19bf65421a07f72b1d547" ; \ mkdir -p /tmp/ffmpeg ; cd /tmp/ffmpeg ; \
;; \ echo "Downloading ffmpeg (${BTBN_TAG}) from ${url}" ; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ curl -fsSL --connect-timeout 20 --speed-limit 1024 --speed-time 30 \
esac \ --max-time 600 --retry 3 --retry-delay 5 --retry-all-errors \
&& mkdir -p /tmp/ffmpeg \ -o ffmpeg.tar.xz "${url}" ; \
&& ok= \ echo "${sha256} ffmpeg.tar.xz" | sha256sum -c - ; \
&& for source in \ tar -xJf ffmpeg.tar.xz ; \
"primary ${primary_sha256} ${primary_url}" \ ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" ; \
"fallback ${fallback_sha256} ${fallback_url}" ; do \ ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" ; \
source_name="${source%% *}" ; \ [ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] ; \
source_data="${source#* }" ; \ mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ ; \
sha256="${source_data%% *}" ; \ chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe ; \
url="${source_data#* }" ; \ rm -rf /tmp/ffmpeg
echo "Downloading ffmpeg (${source_name}) from ${url}" ; \
if curl -fsSL --connect-timeout 20 --max-time 300 \
--retry 3 --retry-delay 5 --retry-all-errors \
-o /tmp/ffmpeg.tar.xz "${url}" \
&& echo "${sha256} /tmp/ffmpeg.tar.xz" | sha256sum -c - ; then ok=1 ; break ; fi ; \
rm -f /tmp/ffmpeg.tar.xz ; \
echo "ffmpeg source failed, trying next: ${url}" >&2 ; \
done \
&& [ -n "${ok}" ] || { echo "all ffmpeg download sources failed" >&2 ; exit 1 ; } \
&& tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg \
&& ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" \
&& ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" \
&& [ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] \
&& mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz
# Stage 4: Runtime - Minimal image with only runtime dependencies # Stage 4: Runtime - Minimal image with only runtime dependencies
FROM python:3.13-slim AS runner FROM python:3.13-slim AS runner

View file

@ -19,7 +19,23 @@ LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY")
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY") LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
# URLs for deployment # URLs for deployment
BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", "http://localhost:8000") #
# PUBLIC_BASE_URL is the single canonical origin a deployment is reached at
# (scheme + host, e.g. https://203-0-113-10.sslip.io). For a standard single-host
# install it is the only endpoint value an operator sets — the per-subsystem URLs
# below derive from it (and from PUBLIC_HOST for the TURN/ICE host). Each derived
# var can still be set explicitly to override it for a split deployment.
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL") or None
PUBLIC_HOST = os.getenv("PUBLIC_HOST") or None
# Public URL the backend builds webhook/callback/embed links from. Derives from
# PUBLIC_BASE_URL (public IP / domain), falling back to localhost for local dev.
# When this is a non-public address (localhost or a private/reserved IP) the host
# isn't reachable from the internet, so get_backend_endpoints() resolves a running
# Cloudflare tunnel's URL at runtime instead (see api/utils/common.py).
BACKEND_API_ENDPOINT = (
os.getenv("BACKEND_API_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:8000"
)
UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010") UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010")
DATABASE_URL = os.environ["DATABASE_URL"] DATABASE_URL = os.environ["DATABASE_URL"]
@ -44,7 +60,12 @@ ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
# MinIO Configuration # MinIO Configuration
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000") MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_PUBLIC_ENDPOINT = os.getenv("MINIO_PUBLIC_ENDPOINT") # Full URL (scheme + host) browsers use to reach object storage. Derives from
# PUBLIC_BASE_URL (remote nginx proxies /voice-audio/ to MinIO); set explicitly
# only to point object storage at a separate origin.
MINIO_PUBLIC_ENDPOINT = (
os.getenv("MINIO_PUBLIC_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:9000"
)
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio") MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio")
@ -84,7 +105,7 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB") LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB")
LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days") LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days")
LOG_COMPRESSION = os.getenv("LOG_COMPRESSION", "gz") LOG_COMPRESSION = os.getenv("LOG_COMPRESSION", "gz")
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true" ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "true").lower() == "true"
def _get_version() -> str: def _get_version() -> str:
@ -149,7 +170,9 @@ DEFAULT_CIRCUIT_BREAKER_CONFIG = {
TURN_SECRET = os.getenv("TURN_SECRET") TURN_SECRET = os.getenv("TURN_SECRET")
TURN_HOST = os.getenv("TURN_HOST", "localhost") # Host browsers dial for TURN/ICE. Derives from PUBLIC_HOST; set explicitly only
# when the TURN server runs on a separate host from the app.
TURN_HOST = os.getenv("TURN_HOST") or PUBLIC_HOST or "localhost"
TURN_PORT = int(os.getenv("TURN_PORT", "3478")) TURN_PORT = int(os.getenv("TURN_PORT", "3478"))
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349")) TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400")) TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))

View file

@ -68,6 +68,10 @@ class HealthResponse(BaseModel):
status: str status: str
version: str version: str
backend_api_endpoint: str backend_api_endpoint: str
# Public URL the deployment is reachable at when it sits behind a Cloudflare
# tunnel (the host has no public IP). null for a directly-reachable deployment.
# The UI shows this so operators know the URL telephony providers should call.
tunnel_url: str | None = None
deployment_mode: str deployment_mode: str
auth_provider: str auth_provider: str
turn_enabled: bool turn_enabled: bool
@ -84,21 +88,34 @@ async def health() -> HealthResponse:
from api.constants import ( from api.constants import (
APP_VERSION, APP_VERSION,
AUTH_PROVIDER, AUTH_PROVIDER,
BACKEND_API_ENDPOINT,
DEPLOYMENT_MODE, DEPLOYMENT_MODE,
FORCE_TURN_RELAY, FORCE_TURN_RELAY,
STACK_AUTH_PROJECT_ID, STACK_AUTH_PROJECT_ID,
STACK_PUBLISHABLE_CLIENT_KEY, STACK_PUBLISHABLE_CLIENT_KEY,
TURN_SECRET, TURN_SECRET,
) )
from api.utils.common import get_backend_endpoints from api.utils.common import get_backend_endpoints, is_local_or_private_url
logger.debug("Health endpoint called") logger.debug("Health endpoint called")
backend_endpoint, _ = await get_backend_endpoints() backend_endpoint, _ = await get_backend_endpoints()
# tunnel_url is set only when a Cloudflare tunnel was actually resolved: the
# configured address isn't publicly reachable, but get_backend_endpoints found
# a public tunnel URL for it. This is the URL the UI shows for inbound webhooks.
# It stays null for a directly-reachable (public IP / domain) deployment, where
# backend_api_endpoint itself is the public URL.
tunnel_url = (
backend_endpoint
if is_local_or_private_url(BACKEND_API_ENDPOINT)
and not is_local_or_private_url(backend_endpoint)
else None
)
is_stack = AUTH_PROVIDER == "stack" is_stack = AUTH_PROVIDER == "stack"
return HealthResponse( return HealthResponse(
status="ok", status="ok",
version=APP_VERSION, version=APP_VERSION,
backend_api_endpoint=backend_endpoint, backend_api_endpoint=BACKEND_API_ENDPOINT,
tunnel_url=tunnel_url,
deployment_mode=DEPLOYMENT_MODE, deployment_mode=DEPLOYMENT_MODE,
auth_provider=AUTH_PROVIDER, auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET), turn_enabled=bool(TURN_SECRET),

View file

@ -3,6 +3,7 @@ Common utilities.
Shared functions used across the application. Shared functions used across the application.
""" """
import ipaddress
import re import re
from loguru import logger from loguru import logger
@ -22,6 +23,43 @@ def get_scheme(url: str) -> str | None:
return url[:idx] return url[:idx]
def is_local_or_private_url(url: str) -> bool:
"""True when the URL's host is localhost or a private/reserved/loopback IP.
Such an address is not reachable from the public internet, so external callers
(telephony webhooks/callbacks) can't reach it directly — the backend resolves a
Cloudflare tunnel URL at runtime instead. A public IP or a hostname/domain
returns False (assumed publicly reachable).
"""
host = url
if "://" in host:
host = host.split("://", 1)[1]
host = host.split("/", 1)[0]
# Strip a :port suffix (skip bare IPv6, which contains multiple colons).
if host.count(":") == 1:
host = host.rsplit(":", 1)[0]
if host == "localhost" or host.endswith(".localhost"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False # hostname / domain -> assume publicly reachable
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
return True
# Carrier-grade NAT (RFC 6598) — behind NAT, not publicly reachable. Kept in
# sync with scripts/lib/setup_common.sh:dograh_is_local_ipv4.
return isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network(
"100.64.0.0/10"
)
def _validate_url(url: str) -> None: def _validate_url(url: str) -> None:
""" """
Validate URL format and raise ValueError for invalid URLs. Validate URL format and raise ValueError for invalid URLs.
@ -119,10 +157,11 @@ async def get_backend_endpoints() -> tuple[str, str]:
_validate_url(BACKEND_API_ENDPOINT) _validate_url(BACKEND_API_ENDPOINT)
if BACKEND_API_ENDPOINT: if BACKEND_API_ENDPOINT:
# Handle localhost/127.0.0.1 special case - use tunnel URL if available # Non-public address (localhost or a private/reserved IP) - the host isn't
if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT: # reachable from the internet, so prefer a running Cloudflare tunnel's URL.
if is_local_or_private_url(BACKEND_API_ENDPOINT):
logger.debug( logger.debug(
f"BACKEND_API_ENDPOINT is local ({BACKEND_API_ENDPOINT}), checking tunnel URL" f"BACKEND_API_ENDPOINT is not publicly reachable ({BACKEND_API_ENDPOINT}), checking tunnel URL"
) )
try: try:
tunnel_urls = await TunnelURLProvider.get_tunnel_urls() tunnel_urls = await TunnelURLProvider.get_tunnel_urls()

View file

@ -4,8 +4,21 @@ server {
listen 80; listen 80;
server_name __DOGRAH_PUBLIC_HOST__; server_name __DOGRAH_PUBLIC_HOST__;
# Redirect all HTTP to HTTPS # Serve Let's Encrypt HTTP-01 challenges out of the certs webroot that
return 301 https://$host$request_uri; # certbot --webroot writes into (./certs is bind-mounted here read-only).
# Only this path is exposed; local.crt/local.key are never served.
location ^~ /.well-known/acme-challenge/ {
root /etc/nginx/certs;
default_type "text/plain";
try_files $uri =404;
}
# Redirect everything else to HTTPS. This must live in a location block,
# not a server-level `return`, or it would fire before location matching
# and hijack the ACME challenge above.
location / {
return 301 https://$host$request_uri;
}
} }
server { server {

View file

@ -1,3 +1,17 @@
# Dograh deployment stack — driven by the helper scripts, not by a bare
# `docker compose up`.
#
# This stack needs a generated .env (secrets, public host/URL) and, for the
# remote/TURN profiles, runtime nginx/coturn config rendered by the dograh-init
# service. The setup scripts create those for you — start with them:
#
# Local: ./start_docker.sh (Windows: .\start_docker.ps1)
# Remote server: sudo ./setup_remote.sh then ./remote_up.sh
#
# Running `docker compose up` against a fresh checkout will fail or come up
# misconfigured (e.g. OSS_JWT_SECRET is required). Full guide:
# https://docs.dograh.com/deployment/docker
services: services:
postgres: postgres:
image: pgvector/pgvector:pg17 image: pgvector/pgvector:pg17
@ -135,9 +149,18 @@ services:
ENVIRONMENT: "${ENVIRONMENT:-local}" ENVIRONMENT: "${ENVIRONMENT:-local}"
LOG_LEVEL: "INFO" LOG_LEVEL: "INFO"
# Replace this environment variable if you are using a custom # Public origin for this deployment. The API derives BACKEND_API_ENDPOINT,
# domain to host the stack # MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" # they are not set explicitly (see api/constants.py), so a standard remote
# install only needs PUBLIC_BASE_URL + PUBLIC_HOST in .env.
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}"
PUBLIC_HOST: "${PUBLIC_HOST:-}"
# Optional explicit override of the public URL the backend builds webhook /
# embed links from. Defaults to PUBLIC_BASE_URL. When the value is non-public
# (localhost or a private/reserved IP), the API resolves a running Cloudflare
# tunnel's URL at runtime instead (see api/utils/common.py).
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-}"
# Database configuration (using containerized postgres) # Database configuration (using containerized postgres)
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
@ -169,10 +192,10 @@ services:
# MinIO # MinIO
MINIO_ENDPOINT: "minio:9000" MINIO_ENDPOINT: "minio:9000"
# Full URL (with scheme) browsers use to reach MinIO. For remote # Full URL (with scheme) browsers use to reach MinIO. Defaults to
# deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to # PUBLIC_BASE_URL for remote deployments (nginx proxies /voice-audio/) and to
# e.g. https://your-server.example.com (nginx proxies /voice-audio/). # http://localhost:9000 for local; override only for a separate object store.
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}" MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}" MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_BUCKET: "voice-audio" MINIO_BUCKET: "voice-audio"
@ -223,8 +246,6 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
cloudflared:
condition: service_started
healthcheck: healthcheck:
test: test:
[ [
@ -272,12 +293,35 @@ services:
networks: networks:
- app-network - app-network
# Cloudflare tunnel for inbound webhook / WSS reachability when the host has no
# usable public IP (behind NAT or a firewall). Gated behind the "tunnel" profile
# so public-IP installs (served directly by nginx) never start it, and the api
# service no longer hard-depends on it. Two modes, chosen by the token:
# - CLOUDFLARE_TUNNEL_TOKEN set -> named tunnel with a stable hostname. Point
# its ingress at http://api:8000 in the Cloudflare dashboard and set
# BACKEND_API_ENDPOINT in .env to that hostname.
# - token unset -> quick tunnel with an ephemeral
# *.trycloudflare.com URL the API discovers from the metrics endpoint
# (api/utils/tunnel.py). Convenient for local dev.
cloudflared: cloudflared:
image: cloudflare/cloudflared:latest image: cloudflare/cloudflared:latest
container_name: cloudflared-tunnel container_name: cloudflared-tunnel
command: tunnel --no-autoupdate --url http://api:8000 --metrics 0.0.0.0:2000 profiles: ["tunnel"]
restart: unless-stopped
environment:
# cloudflared automatically picks up the token from TUNNEL_TOKEN when
# running `tunnel run`. Leave empty to fall back to a quick tunnel.
TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN:-}"
# The cloudflared image is distroless (no `sh`), so a `sh -c` conditional
# entrypoint can't run. The image's own entrypoint is already
# `cloudflared --no-autoupdate`; pick the mode via a single command instead:
# - token set -> set CLOUDFLARED_COMMAND="tunnel run" in .env for a named
# tunnel (cloudflared reads TUNNEL_TOKEN from the env above).
# - token unset -> the default below runs a quick tunnel with an ephemeral
# *.trycloudflare.com URL the API discovers from the metrics endpoint.
command: ${CLOUDFLARED_COMMAND:-tunnel --url http://api:8000 --metrics 0.0.0.0:2000}
ports: ports:
- "2000:2000" # Expose metrics endpoint - "2000:2000" # metrics endpoint (quick-tunnel URL discovery)
networks: networks:
- app-network - app-network

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,10 @@ description: "Deploy Dograh AI with custom domain names and SSL certificates"
Deploy Dograh AI with your own custom domain name for a professional production setup. By now, you should be able to create and test a voice agent by following the previous guide to setup the platform on a remote server using [Docker](docker#option-2%3A-remote-server-deployment) Deploy Dograh AI with your own custom domain name for a professional production setup. By now, you should be able to create and test a voice agent by following the previous guide to setup the platform on a remote server using [Docker](docker#option-2%3A-remote-server-deployment)
<Tip>
**You don't need to own a domain just to remove the browser warning.** If your server has a public IP, the [remote setup](docker#option-2%3A-remote-server-deployment) already issues a free, auto-renewing Let's Encrypt certificate via [sslip.io](https://sslip.io) (e.g. `https://203-0-113-10.sslip.io`) — no DNS configuration required. Follow this Custom Domain guide only when you want your **own** domain name (e.g. `voice.yourcompany.com`).
</Tip>
## What is Custom Domain Deployment? ## What is Custom Domain Deployment?
Custom domain deployment allows you to run Dograh AI with a personalized domain name (like `voice.yourcompany.com`) instead of using IP addresses. This setup includes: Custom domain deployment allows you to run Dograh AI with a personalized domain name (like `voice.yourcompany.com`) instead of using IP addresses. This setup includes:
@ -109,65 +113,50 @@ sudo apt install certbot -y
sudo yum install certbot -y sudo yum install certbot -y
``` ```
### Stop Dograh Services ### Point `.env` at your domain
Before generating certificates, stop the running Dograh services to free up port 80: Update `.env` so the canonical remote settings use your domain — `dograh-init` reads these to render nginx's `server_name`. Replace `voice.yourcompany.com` with your actual domain throughout:
```bash ```bash
cd dograh cd dograh
sudo docker compose --profile remote down sed -i "s/^PUBLIC_HOST=.*/PUBLIC_HOST=voice.yourcompany.com/" .env
sed -i "s|^PUBLIC_BASE_URL=.*|PUBLIC_BASE_URL=https://voice.yourcompany.com|" .env
``` ```
### Generate SSL Certificates ### Start services so nginx can answer the ACME challenge
Run Certbot to obtain SSL certificates for your domain: Bring the stack up (or recreate it) through the validated wrapper. nginx serves the Let's Encrypt HTTP-01 challenge from `certs/.well-known/acme-challenge/` on port 80, so the stack must be **running** during issuance — there's no need to stop it:
```bash ```bash
sudo certbot certonly --standalone -d voice.yourcompany.com ./remote_up.sh
``` ```
Replace `voice.yourcompany.com` with your actual domain name. ### Generate the SSL certificate (webroot)
Issue the certificate using the webroot challenge served by the running nginx:
```bash
sudo certbot certonly --webroot -w "$(pwd)/certs" -d voice.yourcompany.com
```
Certbot will: Certbot will:
1. Verify that you control the domain 1. Write a challenge file under `certs/.well-known/acme-challenge/`
2. Generate SSL certificates 2. Have Let's Encrypt fetch it over HTTP (port 80) to verify you control the domain
3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/` 3. Store the certificate in `/etc/letsencrypt/live/voice.yourcompany.com/`
<Note> <Note>
You'll be prompted to enter an email address for renewal notifications and agree to the terms of service. You'll be prompted for an email address (for renewal notices) and to agree to the terms of service. Because nginx keeps serving traffic throughout, issuance and renewal happen with **no downtime** — unlike the older `--standalone` flow, which had to stop the stack to free port 80.
</Note> </Note>
### Copy Certificates to Dograh Directory ### Copy the certificate and load it
Copy the generated certificates to the dograh certs directory: Copy the issued certificate into the `certs/` directory nginx reads, then restart nginx to load it:
```bash ```bash
cd dograh
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key
sudo chmod 644 certs/local.crt certs/local.key sudo chmod 644 certs/local.crt certs/local.key
``` sudo docker compose --profile remote restart nginx
### Update Canonical Public URL Settings
Update `.env` so the canonical remote settings point at your domain:
```bash
nano dograh/.env
```
```bash
PUBLIC_HOST=voice.yourcompany.com
PUBLIC_BASE_URL=https://voice.yourcompany.com
```
### Start Dograh Services
Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts:
```bash
cd dograh
./remote_up.sh
``` ```
### Access Your Application ### Access Your Application
@ -222,7 +211,7 @@ sudo certbot renew --dry-run
If Certbot fails to generate certificates: If Certbot fails to generate certificates:
1. **Port 80 blocked**: Ensure port 80 is open in your firewall and no service is using it 1. **Port 80 blocked**: Ensure port 80 is open in your firewall and reachable from the internet. With the webroot flow nginx must be **running** and serving the challenge on port 80 (don't stop the stack)
2. **DNS not propagated**: Wait for DNS changes to propagate and verify with `nslookup` 2. **DNS not propagated**: Wait for DNS changes to propagate and verify with `nslookup`
3. **Rate limits**: Let's Encrypt has rate limits. If you've exceeded them, wait before retrying 3. **Rate limits**: Let's Encrypt has rate limits. If you've exceeded them, wait before retrying

View file

@ -102,7 +102,7 @@ The script will prompt you for:
It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services:
```bash ```bash
docker compose --profile local-turn up --pull always docker compose --profile local-turn --profile tunnel up --pull always
``` ```
The application is still available at `http://localhost:3010`. The application is still available at `http://localhost:3010`.
@ -123,13 +123,14 @@ Watch the video tutorial below for a step-by-step walkthrough of deploying Dogra
allowFullScreen allowFullScreen
></iframe> ></iframe>
Deploy Dograh AI on a remote server to make it accessible from anywhere using your server's IP address. This setup includes HTTPS support via nginx reverse proxy with self-signed certificates. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**. Deploy Dograh AI on a remote server to make it accessible from anywhere. If your server has a **public IP**, setup obtains a free, trusted HTTPS certificate automatically via [sslip.io](https://sslip.io) and Let's Encrypt — no domain name or DNS configuration required, and no browser warning. On a private/reserved IP it falls back to a self-signed certificate. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**.
<Warning>We highly recommend you set up the platform on a fresh server, so that there are less chances of confliciting dependencies, and ports from other applications.</Warning> <Warning>We highly recommend you set up the platform on a fresh server, so that there are less chances of confliciting dependencies, and ports from other applications.</Warning>
### Prerequisites ### Prerequisites
- A server with Docker and Docker Compose installed. It should have minimum of 8 GB RAM and 4 vCPUs. - A server with Docker and Docker Compose installed. It should have minimum of 8 GB RAM and 4 vCPUs.
- Root (`sudo`) access on the server — the setup script must be run as root and exits early otherwise.
- Public IP address for your server. You can also access the server using a local IP address in your VPC as long as its reachable from your browser. - Public IP address for your server. You can also access the server using a local IP address in your VPC as long as its reachable from your browser.
- TCP Ports 80, 443, 3478, 5349 and UDP Ports 3478, 5349 and 49152:49200 reachable from Internet (Port 80 and 443 to access the UI and rest of the ports for WebRTC Signaling) - TCP Ports 80, 443, 3478, 5349 and UDP Ports 3478, 5349 and 49152:49200 reachable from Internet (Port 80 and 443 to access the UI and rest of the ports for WebRTC Signaling)
@ -140,9 +141,13 @@ Deploy Dograh AI on a remote server to make it accessible from anywhere using yo
Run the automated setup script that will configure everything for you: Run the automated setup script that will configure everything for you:
```bash ```bash
curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh
``` ```
<Note>
Run with `sudo` — the script must run as root (it provisions Docker, binds ports 80/443, and installs a Let's Encrypt certificate with a system renewal hook) and exits early if it isn't. On a public IP it obtains and auto-renews a trusted certificate; on a private IP it falls back to a self-signed one, since Let's Encrypt can't validate a private address. Optional environment-variable overrides: `CERT_MODE=self-signed` forces a self-signed cert, `ACME_DOMAIN_SUFFIX=nip.io` switches to the nip.io pool if sslip.io is rate-limited, and `LETSENCRYPT_EMAIL=you@example.com` sets the address for expiry notices.
</Note>
The script will prompt you for: The script will prompt you for:
- Your server's public IP address - Your server's public IP address
- A password for the TURN server (optional, press Enter for default) - A password for the TURN server (optional, press Enter for default)
@ -152,7 +157,8 @@ The script will prompt you for:
It will automatically: It will automatically:
- Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode) - Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode)
- Download the validated remote deployment helper bundle - Download the validated remote deployment helper bundle
- Generate SSL certificates - Obtain a free trusted Let's Encrypt certificate via sslip.io and configure auto-renewal (public IP), or generate a self-signed certificate (private IP / no root)
- For a public IP, also start the stack and print your ready-to-use `https://…sslip.io` URL
- Create an environment file with TURN server configuration - Create an environment file with TURN server configuration
- Validate the runtime config that `dograh-init` will render from `.env` - Validate the runtime config that `dograh-init` will render from `.env`
- Write a `docker-compose.override.yaml` with build directives (build mode only) - Write a `docker-compose.override.yaml` with build directives (build mode only)
@ -163,7 +169,7 @@ It will automatically:
Please ensure that Docker Compose is installed on your machine before proceeding further. You can check whether its installed by running `docker compose version` command. If its not installed, please install it by following your server provider documentation. Please ensure that Docker Compose is installed on your machine before proceeding further. You can check whether its installed by running `docker compose version` command. If its not installed, please install it by following your server provider documentation.
</Note> </Note>
After the setup script completes, start Dograh. The script prints the exact command to run at the end — it differs slightly between modes: After the setup script completes, start Dograh. For a **public-IP install the trusted-certificate flow already started the stack** and printed your `https://…sslip.io` URL — you can skip to [Access Your Application](#access-your-application). For a self-signed install, start it yourself (the script prints the exact command at the end — it differs slightly between modes):
<CodeGroup> <CodeGroup>
```bash Prebuilt mode ```bash Prebuilt mode
@ -180,13 +186,13 @@ First boot in build mode takes several minutes — Docker has to build both the
### Access Your Application ### Access Your Application
Your application will be available at Your application will be available at the URL the setup script printed at the end:
```
https://YOUR_SERVER_IP - **Public IP (trusted certificate):** `https://<your-ip-with-dashes>.sslip.io` — for example `https://203-0-113-10.sslip.io`. No browser warning.
``` - **Self-signed (private IP):** `https://YOUR_SERVER_IP`
<Note> <Note>
Since we are using a self-signed certificate, your browser will show a security warning. You can safely accept it to proceed. With a self-signed certificate your browser shows a security warning you can safely accept. With the sslip.io Let's Encrypt certificate there is no warning.
</Note> </Note>
You should be able to create and test a voice agent now. You should be able to create and test a voice agent now.
@ -196,7 +202,7 @@ You should be able to create and test a voice agent now.
- The remote deployment includes an nginx reverse proxy for HTTPS termination - The remote deployment includes an nginx reverse proxy for HTTPS termination
- File downloads (transcripts, recordings) are automatically routed through nginx - File downloads (transcripts, recordings) are automatically routed through nginx
- WebSocket connections for real-time features are properly proxied - WebSocket connections for real-time features are properly proxied
- The setup uses self-signed certificates - browsers will show a security warning that you can safely accept for testing - Public-IP installs get a trusted Let's Encrypt certificate (via sslip.io) that renews automatically; private-IP installs use a self-signed certificate (browser warning)
- The TURN server (coturn) is configured for WebRTC NAT traversal - The TURN server (coturn) is configured for WebRTC NAT traversal
- For production deployments with proper SSL and domain names, see the [Custom Domain](custom-domain) documentation - For production deployments with proper SSL and domain names, see the [Custom Domain](custom-domain) documentation
@ -213,7 +219,7 @@ The setup script creates the following files in the `dograh/` directory:
| `scripts/lib/setup_common.sh` | Shared deployment helper library | | `scripts/lib/setup_common.sh` | Shared deployment helper library |
| `deploy/templates/` | nginx and coturn runtime config templates | | `deploy/templates/` | nginx and coturn runtime config templates |
| `generate_certificate.sh` | Script to regenerate SSL certificates | | `generate_certificate.sh` | Script to regenerate SSL certificates |
| `certs/local.crt` | Self-signed SSL certificate | | `certs/local.crt` | SSL certificate (Let's Encrypt via sslip.io, or self-signed) |
| `certs/local.key` | SSL private key | | `certs/local.key` | SSL private key |
| `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) | | `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) |

View file

@ -55,7 +55,7 @@ Number of FastAPI workers (uvicorn processes nginx will load-balance):
Press Enter for the default (`4`) or enter a different positive integer. Non-interactive callers (cloud-init, CI, Terraform) can set the value via environment variable instead: Press Enter for the default (`4`) or enter a different positive integer. Non-interactive callers (cloud-init, CI, Terraform) can set the value via environment variable instead:
```bash ```bash
SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh sudo SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
``` ```
The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned. The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned.

View file

@ -65,7 +65,9 @@ Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https:/
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `BACKEND_API_ENDPOINT` | `http://localhost:8000` | Internal URL of the backend API | | `PUBLIC_BASE_URL` | `null` | Canonical public origin for the deployment (scheme + host, e.g. `https://203-0-113-10.sslip.io`). For a standard single-host install this is the only endpoint value you set — `BACKEND_API_ENDPOINT` and `MINIO_PUBLIC_ENDPOINT` derive from it |
| `PUBLIC_HOST` | `null` | Public host without scheme (e.g. `203-0-113-10.sslip.io`); `TURN_HOST` derives from it |
| `BACKEND_API_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:8000` | Public URL the backend builds webhook / callback / embed links from. Set explicitly only to override the value derived from `PUBLIC_BASE_URL` |
| `UI_APP_URL` | `http://localhost:3010` | URL of the frontend application | | `UI_APP_URL` | `http://localhost:3010` | URL of the frontend application |
| `MPS_API_URL` | `https://services.dograh.com` | Dograh Managed Platform Services URL | | `MPS_API_URL` | `https://services.dograh.com` | Dograh Managed Platform Services URL |
| `DOGRAH_MPS_SECRET_KEY` | `null` | **Required for non-OSS deployments.** Secret key for authenticating with MPS | | `DOGRAH_MPS_SECRET_KEY` | `null` | **Required for non-OSS deployments.** Secret key for authenticating with MPS |
@ -82,7 +84,7 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `MINIO_ENDPOINT` | `localhost:9000` | MinIO server host and port | | `MINIO_ENDPOINT` | `localhost:9000` | MinIO server host and port |
| `MINIO_PUBLIC_ENDPOINT` | `null` | Publicly accessible MinIO URL (for download links) | | `MINIO_PUBLIC_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:9000` | Publicly accessible MinIO URL for download links. Derives from `PUBLIC_BASE_URL`; set explicitly only for a separate object-storage origin |
| `MINIO_ACCESS_KEY` | N/A | **Required for OSS deployments.** MinIO access key. Must be set to a secure value in production | | `MINIO_ACCESS_KEY` | N/A | **Required for OSS deployments.** MinIO access key. Must be set to a secure value in production |
| `MINIO_SECRET_KEY` | N/A | **Required for OSS deployments.** MinIO secret key. Must be set to a secure value in production | | `MINIO_SECRET_KEY` | N/A | **Required for OSS deployments.** MinIO secret key. Must be set to a secure value in production |
| `MINIO_BUCKET` | `voice-audio` | Bucket name for audio files | | `MINIO_BUCKET` | `voice-audio` | Bucket name for audio files |
@ -128,7 +130,7 @@ Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from t
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `TURN_HOST` | `localhost` | TURN server hostname for WebRTC NAT traversal | | `TURN_HOST` | `PUBLIC_HOST`, else `localhost` | TURN server hostname for WebRTC NAT traversal. Derives from `PUBLIC_HOST`; set explicitly only when TURN runs on a separate host |
| `TURN_PORT` | `3478` | TURN server port | | `TURN_PORT` | `3478` | TURN server port |
| `TURN_TLS_PORT` | `5349` | TURN server TLS port | | `TURN_TLS_PORT` | `5349` | TURN server TLS port |
| `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation | | `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation |

View file

@ -65,10 +65,25 @@ else
COMPOSE_CMD=(sudo docker compose) COMPOSE_CMD=(sudo docker compose)
fi fi
# Reconcile the Postgres role password with .env before starting the API.
# POSTGRES_PASSWORD only applies on first volume init, so an existing volume can
# hold a stale password the API would fail to authenticate against. Idempotent.
dograh_sync_postgres_password "$SCRIPT_DIR" "${COMPOSE_CMD[@]}"
# When SERVER_IP (sourced from .env above) is a private/reserved address the host
# has no public IP, so start the cloudflared service (tunnel profile) to make
# webhooks reachable. The backend resolves the tunnel's public URL at runtime using
# the same private-IP classification (api/utils/common.py:is_local_or_private_url),
# so the two stay in sync. A public-IP install runs nginx only.
PROFILE_ARGS=(--profile remote)
if dograh_is_local_ipv4 "${SERVER_IP:-}"; then
PROFILE_ARGS+=(--profile tunnel)
fi
if [[ "$MODE" == "build" ]]; then if [[ "$MODE" == "build" ]]; then
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate) CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --build --force-recreate)
else else
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate) CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --pull always --force-recreate)
fi fi
# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`. # Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`.

View file

@ -29,12 +29,15 @@ This directory now has a shared deployment model for OSS Docker installs. If you
- `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`. - `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`.
- `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers. - `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers.
- `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around. - `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around.
- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. - Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. `PUBLIC_BASE_URL` (+ `PUBLIC_HOST`, and `SERVER_IP` for coturn's literal `external-ip`) is the single endpoint source of truth.
- `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST` are **derived in-app** from `PUBLIC_BASE_URL` / `PUBLIC_HOST` (`api/constants.py`) and are no longer written to a remote `.env`. `dograh_sync_remote_env_file` neither writes nor deletes them — new installs omit them, and a value an operator sets by hand is left untouched as an explicit override for a split deployment (separate object store / TURN host). `dograh_validate_remote_runtime_env` therefore no longer requires them or asserts they equal `PUBLIC_BASE_URL`.
- `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack. - `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack.
- `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`. - `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`.
- `cloudflared` is gated behind a `tunnel` profile (no longer always-on; `api` no longer `depends_on` it). `remote_up.sh` adds `--profile tunnel` when `SERVER_IP` is a private/reserved address (`dograh_is_local_ipv4`) — i.e. the host has no public IP; public-IP installs run `--profile remote` only and start no tunnel. Local installs opt in with `--profile tunnel`. The backend mirrors this exactly: `api/utils/common.py:is_local_or_private_url` decides when `get_backend_endpoints()` resolves the tunnel URL at runtime, so deploy-side and runtime stay in sync (keep the two IP classifiers aligned, incl. CGNAT 100.64.0.0/10).
- `cloudflared` picks a mode by token: with `CLOUDFLARE_TUNNEL_TOKEN` it runs a named tunnel (stable hostname — set `BACKEND_API_ENDPOINT` to it and point its Cloudflare-dashboard ingress at `http://api:8000`); without a token it runs a quick tunnel (ephemeral `*.trycloudflare.com`, discovered via the `:2000` metrics endpoint by `api/utils/tunnel.py`).
- `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`. - `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`.
- Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them. - Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them.
- `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. - `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. It hard-requires root (a guard near the top exits non-root with a "re-run with sudo" message) because it provisions Docker, binds :80/:443, and installs a Let's Encrypt cert + system renewal hook. Cloud-init/user-data callers (e.g. `infrastructure/`) already run as root, so they pass; interactive callers must use `sudo`. Docs that invoke it (`docs/deployment/docker.mdx`, `docs/deployment/scaling.mdx`) and the hint printed by `setup_custom_domain.sh` all use `sudo`.
- `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path. - `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path.
- `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`. - `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`.
- `setup_local.{sh,ps1}` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config. - `setup_local.{sh,ps1}` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config.

View file

@ -252,9 +252,11 @@ dograh_sync_remote_env_file() {
dograh_set_env_key "$env_file" SERVER_IP "$server_ip" dograh_set_env_key "$env_file" SERVER_IP "$server_ip"
dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host" dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host"
dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url" dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url"
dograh_set_env_key "$env_file" BACKEND_API_ENDPOINT "$public_base_url"
dograh_set_env_key "$env_file" MINIO_PUBLIC_ENDPOINT "$public_base_url" # BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
dograh_set_env_key "$env_file" TURN_HOST "$public_host" # from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so sync neither
# writes nor removes them: new installs simply omit them, and any value an
# operator set by hand is left untouched as an explicit override.
} }
dograh_validate_remote_runtime_env() { dograh_validate_remote_runtime_env() {
@ -262,14 +264,12 @@ dograh_validate_remote_runtime_env() {
[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing" [[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing"
[[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing" [[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing"
[[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing" [[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing"
[[ -n "${BACKEND_API_ENDPOINT:-}" ]] || dograh_fail "BACKEND_API_ENDPOINT is missing"
[[ -n "${MINIO_PUBLIC_ENDPOINT:-}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT is missing"
[[ -n "${TURN_HOST:-}" ]] || dograh_fail "TURN_HOST is missing"
dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address" dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address"
[[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" [[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://"
[[ "${BACKEND_API_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "BACKEND_API_ENDPOINT must match PUBLIC_BASE_URL" # BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
[[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL" # from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so they are not
[[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST" # required here. When an operator sets them explicitly (split deployment),
# their value is honored as-is — no equality check.
} }
dograh_uses_init_compose_layout() { dograh_uses_init_compose_layout() {
@ -401,6 +401,59 @@ dograh_preflight_remote_init_render() {
rm -rf "$tmp_root" rm -rf "$tmp_root"
} }
# Reconcile the running Postgres role password with POSTGRES_PASSWORD in .env.
#
# POSTGRES_PASSWORD only takes effect when the postgres data volume is first
# initialized. If the volume was created before .env had a generated password
# (e.g. an early start used the compose fallback `:-postgres`), or the password
# was later rotated, the role keeps its old password while the API connects with
# the .env value over TCP (pg_hba `scram-sha-256`) and dies with "password
# authentication failed for user postgres". start_docker.sh handles this for the
# OSS quickstart; the remote path (remote_up.sh) needs the same reconciliation.
#
# Bring postgres up on its own, then ALTER the role over the trusted local
# socket (pg_hba trusts `local`, so this works even when the password is
# currently mismatched). Idempotent: on a fresh volume it just re-sets the same
# value. Survives the later `--force-recreate` because the password lives in the
# data volume, not the container.
dograh_sync_postgres_password() {
local project_dir=$1
shift
local compose=("$@")
local env_file="$project_dir/.env"
local password=""
local ready=""
local i
[[ ${#compose[@]} -gt 0 ]] || compose=(docker compose)
if [[ -f "$env_file" ]]; then
password="$(awk -F= '/^POSTGRES_PASSWORD=/{sub(/^POSTGRES_PASSWORD=/, ""); print; exit}' "$env_file")"
fi
# No explicit password: the compose fallback (`:-postgres`) governs both the
# DB init and the API's DATABASE_URL, so the two already agree — nothing to do.
[[ -n "$password" ]] || return 0
dograh_info "Syncing Postgres password from .env..."
( cd "$project_dir" && "${compose[@]}" up -d postgres ) >/dev/null
for ((i = 0; i < 30; i++)); do
if ( cd "$project_dir" && "${compose[@]}" exec -T postgres pg_isready -U postgres ) >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
[[ -n "$ready" ]] || dograh_fail "Postgres did not become ready while syncing POSTGRES_PASSWORD."
printf '%s\n' "ALTER USER postgres WITH PASSWORD :'pw';" \
| ( cd "$project_dir" && "${compose[@]}" exec -T postgres \
psql -U postgres -d postgres -v ON_ERROR_STOP=1 -v "pw=$password" ) >/dev/null \
|| dograh_fail "Failed to sync Postgres password from .env."
dograh_success "✓ Postgres password synced with .env"
}
dograh_prepare_remote_install() { dograh_prepare_remote_install() {
local project_dir=${1:-$(dograh_project_dir)} local project_dir=${1:-$(dograh_project_dir)}
local env_file="$project_dir/.env" local env_file="$project_dir/.env"
@ -410,6 +463,101 @@ dograh_prepare_remote_install() {
dograh_preflight_remote_init_render "$project_dir" dograh_preflight_remote_init_render "$project_dir"
} }
# ---------------------------------------------------------------------------
# TLS certificate helpers (self-signed bootstrap + Let's Encrypt via webroot)
# ---------------------------------------------------------------------------
# Map an IPv4 address to a public sslip.io / nip.io hostname, e.g.
# 203.0.113.10 -> 203-0-113-10.sslip.io. The hostname resolves back to the
# embedded IP from any public resolver, so Let's Encrypt can validate it over
# the HTTP-01 challenge without the operator owning a domain. Public IPs only:
# Let's Encrypt refuses to validate private/reserved addresses.
dograh_sslip_host_from_ip() {
local ip=$1
local suffix=${2:-sslip.io}
dograh_is_ipv4 "$ip" || dograh_fail "dograh_sslip_host_from_ip: '$ip' is not an IPv4 address"
printf '%s.%s\n' "${ip//./-}" "$suffix"
}
# Install certbot via the host package manager if it is not already present.
# Returns non-zero (instead of exiting) when no supported package manager is
# found or the install fails, so callers can fall back to a self-signed cert.
dograh_install_certbot() {
if command -v certbot >/dev/null 2>&1; then
return 0
fi
dograh_info "Installing Certbot..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq certbot
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q certbot
elif command -v yum >/dev/null 2>&1; then
yum install -y -q certbot
else
dograh_warn "Could not detect a package manager (apt/dnf/yum) to install certbot."
return 1
fi
}
# Obtain (or renew) a Let's Encrypt certificate for $host using the webroot
# challenge served by the running nginx container out of <project>/certs, then
# copy the issued cert to certs/local.{crt,key} (the files nginx reads). This
# needs nginx already running and serving /.well-known/acme-challenge/ on :80.
# Returns non-zero on failure so callers can keep the self-signed cert.
dograh_issue_letsencrypt_webroot() {
local project_dir=$1
local host=$2
local email=${3:-}
local webroot="$project_dir/certs"
local live_dir="/etc/letsencrypt/live/$host"
local -a email_args
if [[ -n "$email" ]]; then
email_args=(--email "$email")
else
email_args=(--register-unsafely-without-email)
fi
mkdir -p "$webroot/.well-known/acme-challenge"
certbot certonly --webroot -w "$webroot" \
--non-interactive --agree-tos --keep-until-expiring \
"${email_args[@]}" \
-d "$host" || return 1
[[ -f "$live_dir/fullchain.pem" && -f "$live_dir/privkey.pem" ]] || return 1
cp "$live_dir/fullchain.pem" "$webroot/local.crt"
cp "$live_dir/privkey.pem" "$webroot/local.key"
chmod 644 "$webroot/local.crt" "$webroot/local.key"
}
# Install a certbot deploy hook so renewed certificates are copied into
# <project>/certs and nginx is restarted to load them. Renewal itself is driven
# by certbot's packaged systemd timer / cron; webroot renewals need no downtime
# because the running nginx serves the challenge.
dograh_install_cert_renewal_hook() {
local project_dir=$1
local host=$2
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
local hook_path="$hook_dir/dograh-reload.sh"
mkdir -p "$hook_dir"
cat > "$hook_path" << HOOK_EOF
#!/bin/bash
cp /etc/letsencrypt/live/$host/fullchain.pem $project_dir/certs/local.crt
cp /etc/letsencrypt/live/$host/privkey.pem $project_dir/certs/local.key
chmod 644 $project_dir/certs/local.crt $project_dir/certs/local.key
cd $project_dir
docker compose --profile remote restart nginx 2>/dev/null || true
HOOK_EOF
chmod +x "$hook_path"
}
dograh_download_bundle_file_for_ref() { dograh_download_bundle_file_for_ref() {
local destination=$1 local destination=$1
local remote_path=$2 local remote_path=$2

View file

@ -43,7 +43,7 @@ if [[ ! -d "dograh" ]]; then
echo -e "${RED}Error: 'dograh' directory not found.${NC}" echo -e "${RED}Error: 'dograh' directory not found.${NC}"
echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}" echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}"
echo -e "${YELLOW}If you haven't set up Dograh yet, run the remote setup first:${NC}" echo -e "${YELLOW}If you haven't set up Dograh yet, run the remote setup first:${NC}"
echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh${NC}" echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh${NC}"
exit 1 exit 1
fi fi
@ -65,7 +65,7 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}"
echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}" echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}"
echo "" echo ""
echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}" echo -e "${BLUE}[1/6] Verifying DNS configuration...${NC}"
SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")" SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")"
RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)" RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)"
@ -84,22 +84,14 @@ else
echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}" echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}"
fi fi
echo -e "${BLUE}[2/7] Installing Certbot...${NC}" echo -e "${BLUE}[2/6] Installing Certbot...${NC}"
if command -v apt-get &> /dev/null; then dograh_install_certbot || dograh_fail "Could not install certbot. Please install it manually and re-run."
apt-get update -qq
apt-get install -y -qq certbot
elif command -v yum &> /dev/null; then
yum install -y -q certbot
elif command -v dnf &> /dev/null; then
dnf install -y -q certbot
else
dograh_fail "Could not detect package manager. Please install certbot manually."
fi
echo -e "${GREEN}✓ Certbot installed${NC}" echo -e "${GREEN}✓ Certbot installed${NC}"
echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}" echo -e "${BLUE}[3/6] Pointing .env at $DOMAIN_NAME and starting services...${NC}"
cd dograh cd dograh
DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)" DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
DOGRAH_PATH="$(pwd)"
if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then
dograh_download_remote_support_bundle "$(pwd)" "main" dograh_download_remote_support_bundle "$(pwd)" "main"
@ -107,113 +99,74 @@ fi
dograh_require_init_compose_layout "$(pwd)" dograh_require_init_compose_layout "$(pwd)"
if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then
docker compose --profile remote down
echo -e "${GREEN}✓ Dograh services stopped${NC}"
else
echo -e "${YELLOW}⚠ No running services found${NC}"
fi
echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}"
CERTBOT_OUTPUT=$(certbot certonly --standalone \
--non-interactive \
--agree-tos \
--email "$EMAIL_ADDRESS" \
-d "$DOMAIN_NAME" 2>&1) || {
echo -e "${RED}✗ Certificate generation failed${NC}"
echo ""
if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership."
echo ""
elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo "You've requested too many certificates recently."
echo "Please wait before trying again (usually 1 hour)."
echo ""
elif echo "$CERTBOT_OUTPUT" | grep -qi "dns\|resolve\|NXDOMAIN"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} DNS resolution failed.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo "The domain '$DOMAIN_NAME' does not resolve to this server."
echo "Please verify your DNS A record is correctly configured."
echo ""
else
echo -e "${YELLOW}Certbot output:${NC}"
echo "$CERTBOT_OUTPUT"
echo ""
fi
echo -e "After fixing the issue, re-run this script:"
echo -e " ${BLUE}sudo ./setup_custom_domain.sh${NC}"
echo ""
exit 1
}
echo -e "${GREEN}✓ SSL certificate generated${NC}"
CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME"
echo ""
echo -e "${BLUE}Certificate location:${NC}"
echo -e " ${CERT_PATH}/"
[[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}${NC} fullchain.pem exists" || echo -e " ${RED}${NC} fullchain.pem NOT FOUND"
[[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}${NC} privkey.pem exists" || echo -e " ${RED}${NC} privkey.pem NOT FOUND"
echo ""
mkdir -p certs
cp "$CERT_PATH/fullchain.pem" certs/local.crt
cp "$CERT_PATH/privkey.pem" certs/local.key
chmod 644 certs/local.crt certs/local.key
echo -e "${GREEN}${NC} Certificates copied to certs/ directory"
echo ""
echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}"
dograh_load_env_file .env dograh_load_env_file .env
if [[ -z "${SERVER_IP:-}" ]]; then if [[ -z "${SERVER_IP:-}" ]]; then
SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)" SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)"
fi fi
[[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install" [[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install"
dograh_set_env_key .env SERVER_IP "$SERVER_IP" dograh_set_env_key .env SERVER_IP "$SERVER_IP"
dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME" dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME"
dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME" dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME"
dograh_delete_env_key .env BACKEND_URL dograh_delete_env_key .env BACKEND_URL
# Switching domains is an explicit repoint of the whole deployment. Drop any
# legacy per-subsystem endpoint keys an older install pinned to the previous host
# so they re-derive from the new PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py).
# No-op on current installs, which don't write these keys.
dograh_delete_env_key .env BACKEND_API_ENDPOINT
dograh_delete_env_key .env MINIO_PUBLIC_ENDPOINT
dograh_delete_env_key .env TURN_HOST
dograh_prepare_remote_install "$(pwd)" dograh_prepare_remote_install "$(pwd)"
echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}"
echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}" # Bring the stack up (recreating it) so dograh-init re-renders nginx with the
DOGRAH_PATH="$(pwd)" # domain server_name and the ACME challenge location, served with the existing
# certificate. certbot --webroot then validates against the running nginx:
# no downtime, and (unlike --standalone) renewal keeps working later while
# nginx holds port 80.
./remote_up.sh
cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
#!/bin/bash nginx_ready=0
cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt for ((i=1; i<=60; i++)); do
cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key nginx_ready=1
break
fi
sleep 2
done
[[ "$nginx_ready" == "1" ]] || dograh_fail "nginx did not come up on port 80; cannot run the ACME challenge."
echo -e "${GREEN}✓ Services running and serving the ACME challenge${NC}"
cd $DOGRAH_PATH echo -e "${BLUE}[4/6] Obtaining Let's Encrypt certificate for $DOMAIN_NAME...${NC}"
docker compose --profile remote restart nginx 2>/dev/null || true if ! dograh_issue_letsencrypt_webroot "$(pwd)" "$DOMAIN_NAME" "$EMAIL_ADDRESS"; then
HOOK_EOF echo -e "${RED}✗ Certificate issuance failed${NC}"
chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh echo ""
echo -e "${YELLOW}Common causes:${NC}"
echo " - Port 80 not reachable from the internet (open it in your firewall)"
echo " - DNS A record for $DOMAIN_NAME does not point to this server yet"
echo " - Let's Encrypt rate limit reached (wait, then retry)"
echo " - Upgrading an older install: run ./update_remote.sh first to refresh the"
echo " nginx template so it serves the ACME challenge, then re-run this script"
echo ""
echo -e "The stack is still running with the previous certificate."
echo -e "After fixing the issue, re-run: ${BLUE}sudo ./setup_custom_domain.sh${NC}"
echo ""
exit 1
fi
echo -e "${GREEN}✓ Certificate issued and copied to certs/${NC}"
echo -e "${BLUE}[5/6] Loading the new certificate (restarting nginx)...${NC}"
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
echo -e "${GREEN}✓ nginx restarted${NC}"
echo -e "${BLUE}[6/6] Configuring automatic certificate renewal...${NC}"
dograh_install_cert_renewal_hook "$(pwd)" "$DOMAIN_NAME"
if certbot renew --dry-run --quiet; then if certbot renew --dry-run --quiet; then
echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}"
else else
echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" echo -e "${YELLOW}⚠ Auto-renewal dry-run had issues, but the certificate is installed${NC}"
fi fi
echo ""
echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}"
./remote_up.sh
echo "" echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Custom Domain Setup Complete! ║${NC}" echo -e "${GREEN}║ Custom Domain Setup Complete! ║${NC}"

View file

@ -307,13 +307,16 @@ Write-Host ''
if ($UseCoturn) { if ($UseCoturn) {
Write-Warn 'To start Dograh with TURN, run:' Write-Warn 'To start Dograh with TURN, run:'
Write-Host '' Write-Host ''
Write-Host ' docker compose --profile local-turn up --pull always' -ForegroundColor Blue Write-Host ' docker compose --profile local-turn --profile tunnel up --pull always' -ForegroundColor Blue
} else { } else {
Write-Warn 'To start Dograh, run:' Write-Warn 'To start Dograh, run:'
Write-Host '' Write-Host ''
Write-Host ' docker compose up --pull always' -ForegroundColor Blue Write-Host ' docker compose --profile tunnel up --pull always' -ForegroundColor Blue
} }
Write-Host '' Write-Host ''
Write-Host 'This starts a Cloudflare quick tunnel so inbound telephony webhooks can' -ForegroundColor Yellow
Write-Host 'reach your local API over a temporary public URL.' -ForegroundColor Yellow
Write-Host ''
Write-Warn 'Your application will be available at:' Write-Warn 'Your application will be available at:'
Write-Host '' Write-Host ''
Write-Host ' http://localhost:3010' -ForegroundColor Blue Write-Host ' http://localhost:3010' -ForegroundColor Blue

View file

@ -211,13 +211,16 @@ echo ""
if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}" echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
echo "" echo ""
echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}" echo -e " ${BLUE}docker compose --profile local-turn --profile tunnel up --pull always${NC}"
else else
echo -e "${YELLOW}To start Dograh, run:${NC}" echo -e "${YELLOW}To start Dograh, run:${NC}"
echo "" echo ""
echo -e " ${BLUE}docker compose up --pull always${NC}" echo -e " ${BLUE}docker compose --profile tunnel up --pull always${NC}"
fi fi
echo "" echo ""
echo -e "${YELLOW}This starts a Cloudflare quick tunnel so inbound telephony webhooks can${NC}"
echo -e "${YELLOW}reach your local API over a temporary public URL.${NC}"
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}" echo -e "${YELLOW}Your application will be available at:${NC}"
echo "" echo ""
echo -e " ${BLUE}http://localhost:3010${NC}" echo -e " ${BLUE}http://localhost:3010${NC}"

View file

@ -35,9 +35,17 @@ echo "║ Automated HTTPS deployment with TURN server ║"
echo "╚══════════════════════════════════════════════════════════════╝" echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}" echo -e "${NC}"
# Get the public IP address (skip prompt if SERVER_IP is already set) # This setup must run as root: it provisions Docker, binds privileged ports
# 80/443, and (for public IPs) installs a Let's Encrypt certificate plus a
# system renewal hook under /etc/letsencrypt — all of which require root. Stop
# early with clear guidance rather than getting halfway and degrading the install.
if [[ $EUID -ne 0 ]]; then
dograh_fail "setup_remote.sh must be run as root.\nRe-run with sudo:\n sudo ./setup_remote.sh"
fi
# Get the server IP address (skip prompt if SERVER_IP is already set)
if [[ -z "${SERVER_IP:-}" ]]; then if [[ -z "${SERVER_IP:-}" ]]; then
echo -e "${YELLOW}Enter your server's public IP address:${NC}" echo -e "${YELLOW}Enter your server's IP address:${NC}"
read -p "> " SERVER_IP read -p "> " SERVER_IP
fi fi
@ -49,6 +57,61 @@ if ! dograh_is_ipv4 "$SERVER_IP"; then
dograh_fail "Invalid IP address format" dograh_fail "Invalid IP address format"
fi fi
# Certificate strategy. CERT_MODE selects how HTTPS is secured:
# auto - public IP + root + docker -> sslip (trusted); otherwise self-signed
# sslip - free trusted Let's Encrypt cert via <ip>.sslip.io (public IP only)
# self-signed - generate a self-signed cert (browser shows a warning)
# Reserved for future private-network paths (not implemented yet):
# letsencrypt-dns, cloudflare-tunnel, external
CERT_MODE="${CERT_MODE:-auto}"
ACME_DOMAIN_SUFFIX="${ACME_DOMAIN_SUFFIX:-sslip.io}"
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-}"
if [[ "$CERT_MODE" == "auto" ]]; then
if dograh_is_local_ipv4 "$SERVER_IP"; then
CERT_MODE="self-signed"
dograh_warn "$SERVER_IP is a private IP — using a self-signed certificate."
dograh_warn "For a trusted cert, deploy on a public IP or a domain you own"
dograh_warn "(https://docs.dograh.com/deployment/custom-domain)."
elif ! command -v docker >/dev/null 2>&1; then
CERT_MODE="self-signed"
dograh_warn "Docker not found — skipping automatic Let's Encrypt setup and using a self-signed cert."
else
CERT_MODE="sslip"
fi
fi
case "$CERT_MODE" in
self-signed) ;;
sslip)
if dograh_is_local_ipv4 "$SERVER_IP"; then
dograh_fail "CERT_MODE=sslip needs a public IP; $SERVER_IP is private/reserved."
fi
command -v docker >/dev/null 2>&1 || dograh_fail "CERT_MODE=sslip needs Docker to serve the ACME challenge."
;;
letsencrypt-dns|cloudflare-tunnel|external)
dograh_fail "CERT_MODE=$CERT_MODE is reserved but not implemented yet. Use 'sslip' (public IP) or 'self-signed'."
;;
*)
dograh_fail "Unknown CERT_MODE '$CERT_MODE' (expected: auto, sslip, self-signed)."
;;
esac
if [[ "$CERT_MODE" == "sslip" ]]; then
PUBLIC_HOST_VALUE="$(dograh_sslip_host_from_ip "$SERVER_IP" "$ACME_DOMAIN_SUFFIX")"
CERT_DESC="Let's Encrypt via $ACME_DOMAIN_SUFFIX (trusted)"
else
PUBLIC_HOST_VALUE="$SERVER_IP"
CERT_DESC="self-signed (browser warning)"
fi
CERT_RESULT="$CERT_MODE"
if [[ "$CERT_MODE" == "sslip" && -z "$LETSENCRYPT_EMAIL" && -t 0 ]]; then
echo ""
echo -e "${YELLOW}Email for Let's Encrypt expiry notices (optional, press Enter to skip):${NC}"
read -p "> " LETSENCRYPT_EMAIL
fi
FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}" FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}"
# Get the TURN secret (skip prompt if TURN_SECRET is already set) # Get the TURN secret (skip prompt if TURN_SECRET is already set)
@ -185,6 +248,8 @@ fi
echo "" echo ""
echo -e "${GREEN}Configuration:${NC}" echo -e "${GREEN}Configuration:${NC}"
echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" echo -e " Server IP: ${BLUE}$SERVER_IP${NC}"
echo -e " Public host: ${BLUE}$PUBLIC_HOST_VALUE${NC}"
echo -e " Certificate: ${BLUE}$CERT_DESC${NC}"
echo -e " TURN Secret: ${BLUE}********${NC}" echo -e " TURN Secret: ${BLUE}********${NC}"
echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}" echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}"
echo -e " Force TURN relay: ${BLUE}$FORCE_TURN_RELAY${NC}" echo -e " Force TURN relay: ${BLUE}$FORCE_TURN_RELAY${NC}"
@ -240,7 +305,7 @@ openssl req -x509 -nodes -newkey rsa:2048 \\
-keyout certs/local.key \\ -keyout certs/local.key \\
-out certs/local.crt \\ -out certs/local.crt \\
-days 365 \\ -days 365 \\
-subj "/CN=$SERVER_IP" -subj "/CN=$PUBLIC_HOST_VALUE"
CERT_EOF CERT_EOF
chmod +x generate_certificate.sh chmod +x generate_certificate.sh
echo -e "${GREEN}✓ generate_certificate.sh created${NC}" echo -e "${GREEN}✓ generate_certificate.sh created${NC}"
@ -260,19 +325,16 @@ cat > .env << ENV_EOF
# Remote deployments run with production signaling and HTTPS defaults # Remote deployments run with production signaling and HTTPS defaults
ENVIRONMENT=production ENVIRONMENT=production
# Canonical public host/base URL for this install. # Canonical public host/base URL for this install. SERVER_IP stays the raw IP
# (coturn external-ip and validation need it); PUBLIC_HOST is the sslip.io
# hostname when using a trusted cert, otherwise the IP. BACKEND_API_ENDPOINT,
# MINIO_PUBLIC_ENDPOINT and TURN_HOST are derived from these by the API
# (see api/constants.py) — set them here only to override for a split deployment.
SERVER_IP=$SERVER_IP SERVER_IP=$SERVER_IP
PUBLIC_HOST=$SERVER_IP PUBLIC_HOST=$PUBLIC_HOST_VALUE
PUBLIC_BASE_URL=https://$SERVER_IP PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
BACKEND_API_ENDPOINT=https://$SERVER_IP
# Public URL browsers use to fetch objects from MinIO (proxied by nginx)
MINIO_PUBLIC_ENDPOINT=https://$SERVER_IP
# TURN Server Configuration (time-limited credentials via TURN REST API) # TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$SERVER_IP
TURN_SECRET=$TURN_SECRET TURN_SECRET=$TURN_SECRET
# Relay-only ICE candidates for explicit TURN diagnostics # Relay-only ICE candidates for explicit TURN diagnostics
FORCE_TURN_RELAY=$FORCE_TURN_RELAY FORCE_TURN_RELAY=$FORCE_TURN_RELAY
@ -332,6 +394,46 @@ OVERRIDE_EOF
echo -e "${GREEN}✓ docker-compose.override.yaml created${NC}" echo -e "${GREEN}✓ docker-compose.override.yaml created${NC}"
fi fi
if [[ "$CERT_MODE" == "sslip" ]]; then
echo ""
echo -e "${BLUE}Starting Dograh and requesting a trusted certificate for ${PUBLIC_HOST_VALUE}...${NC}"
if [[ "$DEPLOY_MODE" == "build" ]]; then
./remote_up.sh --build
else
./remote_up.sh
fi
echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
nginx_ready=0
for ((i=1; i<=60; i++)); do
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
nginx_ready=1
break
fi
sleep 2
done
if [[ "$nginx_ready" != "1" ]]; then
CERT_RESULT="self-signed"
dograh_warn "nginx did not become reachable on port 80 — skipping Let's Encrypt for now."
dograh_warn "The stack is running with the bootstrap self-signed certificate."
elif dograh_install_certbot && dograh_issue_letsencrypt_webroot "$(pwd)" "$PUBLIC_HOST_VALUE" "$LETSENCRYPT_EMAIL"; then
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
dograh_install_cert_renewal_hook "$(pwd)" "$PUBLIC_HOST_VALUE"
CERT_RESULT="sslip"
dograh_success "✓ Trusted Let's Encrypt certificate installed; auto-renewal configured"
else
CERT_RESULT="self-signed"
echo ""
dograh_warn "Let's Encrypt issuance failed — the stack is running with the self-signed certificate."
dograh_warn "Common causes and fixes:"
dograh_warn " - Port 80 not reachable from the internet: open it in your firewall/security group"
dograh_warn " - Rate limited on ${ACME_DOMAIN_SUFFIX}: re-run with ACME_DOMAIN_SUFFIX=nip.io"
dograh_warn " - Then retry: sudo certbot certonly --webroot -w \"$(pwd)/certs\" -d ${PUBLIC_HOST_VALUE}"
fi
fi
echo "" echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete! ║${NC}" echo -e "${GREEN}║ Setup Complete! ║${NC}"
@ -350,25 +452,42 @@ echo " - certs/local.crt"
echo " - certs/local.key" echo " - certs/local.key"
echo " - .env" echo " - .env"
echo "" echo ""
echo -e "${YELLOW}To start Dograh, run:${NC}" if [[ "$CERT_MODE" == "sslip" ]]; then
echo "" if [[ "$CERT_RESULT" == "sslip" ]]; then
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then echo -e "${GREEN}Dograh is running with a trusted certificate at:${NC}"
echo -e " ${BLUE}cd $(pwd)${NC}" echo ""
fi echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
if [[ "$DEPLOY_MODE" == "build" ]]; then echo ""
echo -e " ${BLUE}./remote_up.sh --build${NC}" echo -e "${GREEN}No browser warning — the certificate renews automatically before expiry.${NC}"
echo "" else
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}" echo -e "${YELLOW}Dograh is running (with a temporary self-signed certificate) at:${NC}"
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}" echo ""
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}" echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${YELLOW}Let's Encrypt issuance did not complete (see the message above). Your${NC}"
echo -e "${YELLOW}browser will warn until a trusted certificate is issued.${NC}"
fi
else else
echo -e " ${BLUE}./remote_up.sh${NC}" echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
echo -e " ${BLUE}cd $(pwd)${NC}"
fi
if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e " ${BLUE}./remote_up.sh --build${NC}"
echo ""
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}"
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}"
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
else
echo -e " ${BLUE}./remote_up.sh${NC}"
fi
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
echo "certificate. You can safely accept it to proceed."
fi fi
echo "" echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}https://$SERVER_IP${NC}"
echo ""
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
echo "certificate. You can safely accept it to proceed."
echo ""

View file

@ -207,10 +207,9 @@ if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
Write-Host '' Write-Host ''
Write-Host "Docker registry: $Registry" Write-Host "Docker registry: $Registry"
Write-Host "Telemetry enabled: $EnableTelemetry"
Write-Host '' Write-Host ''
Write-Host 'This will run:' Write-Host 'This will run:'
Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose up --pull always" Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose --profile tunnel up --pull always"
Write-Host '' Write-Host ''
$answer = Read-Host 'Start Dograh now? [Y/n]' $answer = Read-Host 'Start Dograh now? [Y/n]'
@ -222,7 +221,7 @@ if ($answer -match '^[Nn]') {
$env:REGISTRY = $Registry $env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry $env:ENABLE_TELEMETRY = $EnableTelemetry
Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD') Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD')
docker compose up --pull always docker compose --profile tunnel up --pull always
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }

View file

@ -200,10 +200,9 @@ fi
echo "" echo ""
echo "Docker registry: $REGISTRY" echo "Docker registry: $REGISTRY"
echo "Telemetry enabled: $ENABLE_TELEMETRY"
echo "" echo ""
echo "This will run:" echo "This will run:"
echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose up --pull always" echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose --profile tunnel up --pull always"
echo "" echo ""
if [[ ! -t 0 ]]; then if [[ ! -t 0 ]]; then
@ -222,4 +221,4 @@ esac
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)" postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
sync_postgres_password "$postgres_password" sync_postgres_password "$postgres_password"
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose --profile tunnel up --pull always

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.FJTeRc3I3o # filename: dograh-openapi-XXXXXX.json.MU08hRFPE8
# timestamp: 2026-06-25T16:50:38+00:00 # timestamp: 2026-06-26T11:33:34+00:00
from __future__ import annotations from __future__ import annotations

View file

@ -35,6 +35,8 @@ export async function GET() {
let authProvider = "local"; let authProvider = "local";
let turnEnabled = false; let turnEnabled = false;
let forceTurnRelay = false; let forceTurnRelay = false;
let tunnelUrl: string | null = null;
let backendApiEndpoint: string | null = null;
let backendStatus: "reachable" | "unreachable" = "unreachable"; let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`; let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
@ -53,6 +55,12 @@ export async function GET() {
authProvider = data.auth_provider; authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled); turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay); forceTurnRelay = Boolean(data.force_turn_relay);
tunnelUrl = data.tunnel_url ?? null;
backendApiEndpoint =
typeof data.backend_api_endpoint === "string" &&
data.backend_api_endpoint.length > 0
? trimTrailingSlash(data.backend_api_endpoint)
: null;
backendStatus = "reachable"; backendStatus = "reachable";
backendMessage = null; backendMessage = null;
} }
@ -68,6 +76,8 @@ export async function GET() {
authProvider, authProvider,
turnEnabled, turnEnabled,
forceTurnRelay, forceTurnRelay,
tunnelUrl,
backendApiEndpoint,
backend: { backend: {
status: backendStatus, status: backendStatus,
url: backendUrl, url: backendUrl,

View file

@ -55,24 +55,21 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useAppConfig } from "@/context/AppConfigContext";
import { detailFromError } from "@/lib/apiError"; import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run"; const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
function getInboundWebhookUrl(): string {
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return `${backendUrl}${INBOUND_WEBHOOK_PATH}`;
}
export default function TelephonyConfigurationDetailPage() { export default function TelephonyConfigurationDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ configId: string }>(); const params = useParams<{ configId: string }>();
const configId = Number(params.configId); const configId = Number(params.configId);
const { user, getAccessToken, loading: authLoading } = useAuth(); const { user, getAccessToken, loading: authLoading } = useAuth();
const { config: appConfig } = useAppConfig();
const inboundWebhookUrl = `${resolveWebhookBaseUrl(appConfig?.tunnelUrl)}${INBOUND_WEBHOOK_PATH}`;
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null); const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]); const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const url = getInboundWebhookUrl(); const url = inboundWebhookUrl;
navigator.clipboard navigator.clipboard
.writeText(url) .writeText(url)
.then(() => toast.success("Inbound webhook URL copied")) .then(() => toast.success("Inbound webhook URL copied"))
@ -275,7 +272,7 @@ export default function TelephonyConfigurationDetailPage() {
aria-label="Copy inbound webhook URL" aria-label="Copy inbound webhook URL"
className="inline-flex items-center gap-1 self-start rounded font-mono text-xs text-muted-foreground hover:text-foreground" className="inline-flex items-center gap-1 self-start rounded font-mono text-xs text-muted-foreground hover:text-foreground"
> >
<span className="truncate">{getInboundWebhookUrl()}</span> <span className="truncate">{inboundWebhookUrl}</span>
<Copy className="h-3 w-3 shrink-0" /> <Copy className="h-3 w-3 shrink-0" />
</button> </button>
</div> </div>

View file

@ -6,6 +6,7 @@ import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types"; import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation"; import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext"; import { useAppConfig } from "@/context/AppConfigContext";
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
import logger from '@/lib/logger'; import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils"; import { sdpFilterCodec } from "../utils";
@ -37,34 +38,6 @@ const HANDLED_SERVICE_ERROR_TYPES = new Set([
'quota_check_failed', 'quota_check_failed',
]); ]);
const LOCALHOST_API_BASE_URL = 'http://localhost:8000';
const LOCALHOST_API_HEALTH_URL = `${LOCALHOST_API_BASE_URL}/api/v1/health`;
const LOCALHOST_API_PROBE_TIMEOUT_MS = 1500;
function isLocalhostUi() {
if (typeof window === 'undefined') return false;
return ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
}
async function probeLocalhostApi() {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), LOCALHOST_API_PROBE_TIMEOUT_MS);
try {
const response = await fetch(LOCALHOST_API_HEALTH_URL, {
cache: 'no-store',
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
window.clearTimeout(timeout);
}
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => { export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle'); const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
const [connectionActive, setConnectionActive] = useState(false); const [connectionActive, setConnectionActive] = useState(false);
@ -137,41 +110,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const currentAllowInterruptRef = useRef<boolean | undefined>(undefined); const currentAllowInterruptRef = useRef<boolean | undefined>(undefined);
const interruptWarningShownRef = useRef(false); const interruptWarningShownRef = useRef(false);
const getWebSocketUrl = useCallback(async () => { const getWebSocketUrl = useCallback(() => {
// An explicitly configured backend URL always wins. When set, honor it // Single source of truth for the browser→API base URL: the centrally
// verbatim and skip the localhost autodetect below — the operator has // resolved API client config (NEXT_PUBLIC_BACKEND_URL → the backend
// told us exactly where the API lives. Read the env var directly (not // endpoint reported by /health → window.location.origin), seeded by
// client.getConfig().baseUrl) so we can distinguish "explicitly set" // createClientConfig and upgraded by AppConfigProvider. The backend now
// from the client's window.location.origin fallback. // reports the endpoint it runs on, so the old localhost autodetect that
const configuredBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL; // forced :8000 (back when an unset endpoint fell through to the UI origin)
// is no longer needed.
let baseUrl: string; const baseUrl = client.getConfig().baseUrl || resolveBrowserBackendUrl();
if (configuredBackendUrl) {
baseUrl = configuredBackendUrl;
} else if (isLocalhostUi()) {
// No backend URL configured and the UI is on localhost: the client
// would otherwise fall back to window.location.origin (the UI port,
// e.g. 3010), which is wrong for the API. Local Docker exposes the
// API on localhost:8000. WebSocket upgrades cannot pass through the
// Next.js route-handler HTTP proxy, so connect to the API directly
// when that port is reachable. A Next.js rewrite/proxy for the
// upgrade was considered, but we keep the WebRTC signaling path
// direct so signaling and the API's ICE/WebRTC handling terminate
// at the same local endpoint.
const localhostApiReachable = await probeLocalhostApi();
if (!localhostApiReachable) {
throw new Error('Dograh API is not reachable at http://localhost:8000. Ensure the api container is running and port 8000 is published.');
}
baseUrl = LOCALHOST_API_BASE_URL;
} else {
// Same-origin deployment: UI and API share an origin.
baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
}
// Convert HTTP to WS protocol
const wsUrl = baseUrl.replace(/^http/, 'ws'); const wsUrl = baseUrl.replace(/^http/, 'ws');
return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`; return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`;
}, [workflowId, workflowRunId, accessToken]); }, [workflowId, workflowRunId, accessToken]);
@ -352,7 +299,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}; };
const connectWebSocket = useCallback(async () => { const connectWebSocket = useCallback(async () => {
const wsUrl = await getWebSocketUrl(); const wsUrl = getWebSocketUrl();
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
logger.info(`Connecting to WebSocket: ${wsUrl}`); logger.info(`Connecting to WebSocket: ${wsUrl}`);

View file

@ -2830,6 +2830,10 @@ export type HealthResponse = {
* Backend Api Endpoint * Backend Api Endpoint
*/ */
backend_api_endpoint: string; backend_api_endpoint: string;
/**
* Tunnel Url
*/
tunnel_url?: string | null;
/** /**
* Deployment Mode * Deployment Mode
*/ */

View file

@ -6,22 +6,41 @@ import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useAppConfig } from "@/context/AppConfigContext";
import { resolveBrowserBackendUrl } from "@/lib/apiClient";
const MCP_PATH = "/api/v1/mcp/";
export function MCPSection() { export function MCPSection() {
const backendUrl = const { config } = useAppConfig();
process.env.NEXT_PUBLIC_BACKEND_URL || // Backend URL: the address the deployment runs on (a private IP when the backend
(typeof window !== "undefined" ? window.location.origin : ""); // sits on one). Tunnel URL, when present: the publicly reachable Cloudflare tunnel
const endpoint = `${backendUrl}/api/v1/mcp/`; // URL externally-hosted assistants should use to reach an otherwise-private host.
const backendUrl = resolveBrowserBackendUrl(config?.backendApiEndpoint);
const tunnelUrl = config?.tunnelUrl ?? null;
const [endpointCopied, setEndpointCopied] = useState(false); const endpoints = [
...(tunnelUrl
? [
{
key: "tunnel",
label: "Public URL (Cloudflare tunnel)",
url: `${tunnelUrl}${MCP_PATH}`,
},
]
: []),
{ key: "backend", label: "Backend URL", url: `${backendUrl}${MCP_PATH}` },
];
const handleCopy = async ( const [copiedKey, setCopiedKey] = useState<string | null>(null);
value: string,
setter: (v: boolean) => void, const handleCopy = async (value: string, key: string) => {
) => {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setter(true); setCopiedKey(key);
setTimeout(() => setter(false), 2000); setTimeout(
() => setCopiedKey((current) => (current === key ? null : current)),
2000,
);
}; };
return ( return (
@ -39,23 +58,40 @@ export function MCPSection() {
Get your API key Get your API key
</Link> </Link>
</p> </p>
<div className="flex items-center gap-2"> <div className="grid gap-3">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1"> {endpoints.map(({ key, label, url }) => (
{endpoint} <div key={key} className="grid gap-1">
</code> {endpoints.length > 1 && (
<Button <span className="text-xs font-medium text-muted-foreground">
variant="outline" {label}
size="icon" </span>
className="shrink-0" )}
onClick={() => handleCopy(endpoint, setEndpointCopied)} <div className="flex items-center gap-2">
> <code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{endpointCopied ? ( {url}
<Check className="h-4 w-4" /> </code>
) : ( <Button
<Copy className="h-4 w-4" /> variant="outline"
)} size="icon"
</Button> className="shrink-0"
onClick={() => handleCopy(url, key)}
>
{copiedKey === key ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
))}
</div> </div>
{tunnelUrl && (
<p className="text-xs text-muted-foreground">
Use the public URL from externally-hosted assistants; the backend URL
works from the deployment&apos;s own network.
</p>
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View file

@ -13,7 +13,9 @@ import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation"; import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAppConfig } from "@/context/AppConfigContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
import { NodeContent } from "./common/NodeContent"; import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog"; import { NodeEditDialog } from "./common/NodeEditDialog";
@ -90,14 +92,12 @@ interface TriggerEndpoints {
function buildTriggerEndpoints( function buildTriggerEndpoints(
triggerPath: string | undefined, triggerPath: string | undefined,
baseUrl: string,
): TriggerEndpoints { ): TriggerEndpoints {
if (!triggerPath) return { production: "", test: "" }; if (!triggerPath) return { production: "", test: "" };
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return { return {
production: `${backendUrl}/api/v1/public/agent/${triggerPath}`, production: `${baseUrl}/api/v1/public/agent/${triggerPath}`,
test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`, test: `${baseUrl}/api/v1/public/agent/test/${triggerPath}`,
}; };
} }
@ -182,8 +182,12 @@ function CanvasPreview({
onStaleTools: (uuids: string[]) => void; onStaleTools: (uuids: string[]) => void;
onStaleDocuments: (uuids: string[]) => void; onStaleDocuments: (uuids: string[]) => void;
}) { }) {
const { config: appConfig } = useAppConfig();
if (spec.name === "trigger") { if (spec.name === "trigger") {
const endpoint = buildTriggerEndpoints(data.trigger_path).production; const endpoint = buildTriggerEndpoints(
data.trigger_path,
resolveWebhookBaseUrl(appConfig?.tunnelUrl),
).production;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground">API Endpoint:</p> <p className="text-xs text-muted-foreground">API Endpoint:</p>
@ -474,7 +478,9 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
}); });
const { saveWorkflow, tools, documents, recordings } = useWorkflow(); const { saveWorkflow, tools, documents, recordings } = useWorkflow();
const { bySpecName } = useNodeSpecs(); const { bySpecName } = useNodeSpecs();
const { config: appConfig } = useAppConfig();
const spec = bySpecName.get(type); const spec = bySpecName.get(type);
const webhookBaseUrl = resolveWebhookBaseUrl(appConfig?.tunnelUrl);
// ── Form state ───────────────────────────────────────────────────── // ── Form state ─────────────────────────────────────────────────────
// mcp_tool_filters is not a spec property, so seedValues won't carry it; // mcp_tool_filters is not a spec property, so seedValues won't carry it;
@ -500,12 +506,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
// ── Trigger auto-UUID + canvas copy state ────────────────────────── // ── Trigger auto-UUID + canvas copy state ──────────────────────────
const [triggerCopied, setTriggerCopied] = useState(false); const [triggerCopied, setTriggerCopied] = useState(false);
const handleCopyTrigger = useCallback(async () => { const handleCopyTrigger = useCallback(async () => {
const endpoint = buildTriggerEndpoints(data.trigger_path).production; const endpoint = buildTriggerEndpoints(data.trigger_path, webhookBaseUrl).production;
if (!endpoint) return; if (!endpoint) return;
await navigator.clipboard.writeText(endpoint); await navigator.clipboard.writeText(endpoint);
setTriggerCopied(true); setTriggerCopied(true);
setTimeout(() => setTriggerCopied(false), 2000); setTimeout(() => setTriggerCopied(false), 2000);
}, [data.trigger_path]); }, [data.trigger_path, webhookBaseUrl]);
// For trigger nodes without a path yet, generate one and persist. // For trigger nodes without a path yet, generate one and persist.
useEffect(() => { useEffect(() => {
@ -684,7 +690,7 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
/> />
{type === "trigger" && ( {type === "trigger" && (
<TriggerWebhookUrls <TriggerWebhookUrls
endpoints={buildTriggerEndpoints(data.trigger_path)} endpoints={buildTriggerEndpoints(data.trigger_path, webhookBaseUrl)}
/> />
)} )}
</div> </div>

View file

@ -2,6 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { client } from '@/client/client.gen';
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
type BackendStatus = 'reachable' | 'unreachable'; type BackendStatus = 'reachable' | 'unreachable';
interface AppConfig { interface AppConfig {
@ -11,6 +14,14 @@ interface AppConfig {
authProvider: string; authProvider: string;
turnEnabled: boolean; turnEnabled: boolean;
forceTurnRelay: boolean; forceTurnRelay: boolean;
// Public URL when the deployment is reached through a Cloudflare tunnel
// (host has no public IP); null for a directly-reachable deployment.
tunnelUrl: string | null;
// The URL the backend reports it is running on (via /health). This is the
// address the browser reaches the backend at — a private IP when the backend
// runs on one. null until /health is reached. Used to resolve the API client
// base URL; distinct from tunnelUrl, which is only for external consumers.
backendApiEndpoint: string | null;
backendStatus: BackendStatus; backendStatus: BackendStatus;
backendUrl: string; backendUrl: string;
backendMessage: string | null; backendMessage: string | null;
@ -29,6 +40,8 @@ const defaultConfig: AppConfig = {
authProvider: 'local', authProvider: 'local',
turnEnabled: false, turnEnabled: false,
forceTurnRelay: false, forceTurnRelay: false,
tunnelUrl: null,
backendApiEndpoint: null,
backendStatus: 'unreachable', backendStatus: 'unreachable',
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown', backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
@ -56,6 +69,21 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0 const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
? backend.url ? backend.url
: defaultConfig.backendUrl; : defaultConfig.backendUrl;
const backendApiEndpoint = typeof data.backendApiEndpoint === 'string' && data.backendApiEndpoint.length > 0
? data.backendApiEndpoint
: null;
// createClientConfig seeds the API client base URL before /health is
// known. Now that the backend has reported the endpoint it runs on,
// re-apply the single browser→API preference order so all SDK calls
// (and anything reading client.getConfig().baseUrl) hit it directly —
// window.location.origin would be wrong when the API is served from a
// different host/port. resolveBrowserBackendUrl keeps NEXT_PUBLIC_BACKEND_URL
// ahead of the reported endpoint. Guard on a present endpoint so a
// transient /health failure never downgrades a good base URL to origin.
if (backendApiEndpoint) {
client.setConfig({ baseUrl: resolveBrowserBackendUrl(backendApiEndpoint) });
}
setConfig({ setConfig({
uiVersion: data.ui || 'dev', uiVersion: data.ui || 'dev',
@ -64,6 +92,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
authProvider: data.authProvider || 'local', authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled), turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay), forceTurnRelay: Boolean(data.forceTurnRelay),
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
backendApiEndpoint,
backendStatus, backendStatus,
backendUrl, backendUrl,
backendMessage: typeof backend.message === 'string' && backend.message.length > 0 backendMessage: typeof backend.message === 'string' && backend.message.length > 0

View file

@ -5,6 +5,31 @@ export function getServerBackendUrl() {
return process.env.BACKEND_URL || 'http://api:8000'; return process.env.BACKEND_URL || 'http://api:8000';
} }
/**
* Resolve the base URL the browser should use to reach the backend API.
*
* Precedence:
* 1. NEXT_PUBLIC_BACKEND_URL explicit build-time operator config, always wins.
* 2. backendApiEndpoint the URL the backend reports it is running on via /health
* (surfaced through AppConfigContext). This is the address the browser actually
* reaches the backend at; for a backend on a private IP it is that private IP.
* Unknown at module init, so createClientConfig seeds without it and
* AppConfigProvider upgrades the client once /health resolves.
* 3. window.location.origin same-origin public deployment.
*
* This is the browserAPI order. It is intentionally NOT tunnel-aware: the
* Cloudflare tunnel URL is only for externally-hosted consumers (telephony
* webhooks, MCP, external API triggers) that cannot reach a private IP see
* resolveWebhookBaseUrl.
*/
export function resolveBrowserBackendUrl(backendApiEndpoint?: string | null): string {
return (
process.env.NEXT_PUBLIC_BACKEND_URL ||
backendApiEndpoint ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
}
export const createClientConfig: CreateClientConfig = (config) => { export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side // Use different URLs for server-side vs client-side
const isServer = typeof window === 'undefined'; const isServer = typeof window === 'undefined';
@ -13,7 +38,10 @@ export const createClientConfig: CreateClientConfig = (config) => {
if (isServer) { if (isServer) {
baseUrl = getServerBackendUrl(); baseUrl = getServerBackendUrl();
} else { } else {
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin; // The backend-reported endpoint is not known yet at module init;
// AppConfigProvider upgrades the client base URL once /health reports it
// (when no explicit NEXT_PUBLIC_BACKEND_URL is configured).
baseUrl = resolveBrowserBackendUrl();
} }
return { return {

16
ui/src/lib/webhookUrl.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Public base URL that external callers (telephony providers, webhook senders)
* should use to reach this deployment's API.
*
* Prefers the Cloudflare tunnel URL the backend reports via /health (set when the
* host has no public IP, so `window.location.origin` would be a private/LAN
* address an external caller can't reach), then a build-time configured backend
* URL, then the current origin (correct for a same-origin public deployment).
*/
export function resolveWebhookBaseUrl(tunnelUrl?: string | null): string {
return (
tunnelUrl ||
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "")
);
}