chore: refactor setup scripts

This commit is contained in:
Abhishek Kumar 2026-06-26 17:04:01 +05:30
parent 53b8ba08fd
commit 964ee652d8
21 changed files with 247 additions and 72 deletions

View file

@ -19,7 +19,23 @@ LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY")
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
# 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")
DATABASE_URL = os.environ["DATABASE_URL"]
@ -44,7 +60,12 @@ ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
# MinIO Configuration
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_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
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_RETENTION = os.getenv("LOG_RETENTION", "7 days")
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:
@ -149,7 +170,9 @@ DEFAULT_CIRCUIT_BREAKER_CONFIG = {
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_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))

View file

@ -68,6 +68,10 @@ class HealthResponse(BaseModel):
status: str
version: 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
auth_provider: str
turn_enabled: bool
@ -84,21 +88,34 @@ async def health() -> HealthResponse:
from api.constants import (
APP_VERSION,
AUTH_PROVIDER,
BACKEND_API_ENDPOINT,
DEPLOYMENT_MODE,
FORCE_TURN_RELAY,
STACK_AUTH_PROJECT_ID,
STACK_PUBLISHABLE_CLIENT_KEY,
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")
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"
return HealthResponse(
status="ok",
version=APP_VERSION,
backend_api_endpoint=backend_endpoint,
backend_api_endpoint=BACKEND_API_ENDPOINT,
tunnel_url=tunnel_url,
deployment_mode=DEPLOYMENT_MODE,
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),

View file

@ -3,6 +3,7 @@ Common utilities.
Shared functions used across the application.
"""
import ipaddress
import re
from loguru import logger
@ -22,6 +23,43 @@ def get_scheme(url: str) -> str | None:
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:
"""
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)
if BACKEND_API_ENDPOINT:
# Handle localhost/127.0.0.1 special case - use tunnel URL if available
if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT:
# Non-public address (localhost or a private/reserved IP) - the host isn't
# reachable from the internet, so prefer a running Cloudflare tunnel's URL.
if is_local_or_private_url(BACKEND_API_ENDPOINT):
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:
tunnel_urls = await TunnelURLProvider.get_tunnel_urls()

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:
postgres:
image: pgvector/pgvector:pg17
@ -135,9 +149,18 @@ services:
ENVIRONMENT: "${ENVIRONMENT:-local}"
LOG_LEVEL: "INFO"
# Replace this environment variable if you are using a custom
# domain to host the stack
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}"
# Public origin for this deployment. The API derives BACKEND_API_ENDPOINT,
# MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when
# 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_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
@ -169,10 +192,10 @@ services:
# MinIO
MINIO_ENDPOINT: "minio:9000"
# Full URL (with scheme) browsers use to reach MinIO. For remote
# deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to
# e.g. https://your-server.example.com (nginx proxies /voice-audio/).
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
# Full URL (with scheme) browsers use to reach MinIO. Defaults to
# PUBLIC_BASE_URL for remote deployments (nginx proxies /voice-audio/) and to
# http://localhost:9000 for local; override only for a separate object store.
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_BUCKET: "voice-audio"
@ -223,8 +246,6 @@ services:
condition: service_healthy
minio:
condition: service_healthy
cloudflared:
condition: service_started
healthcheck:
test:
[
@ -272,12 +293,35 @@ services:
networks:
- 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:
image: cloudflare/cloudflared:latest
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:
- "2000:2000" # Expose metrics endpoint
- "2000:2000" # metrics endpoint (quick-tunnel URL discovery)
networks:
- app-network

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:
```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`.

View file

@ -65,7 +65,9 @@ Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https:/
| 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 |
| `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 |
@ -82,7 +84,7 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme
| Variable | Default | Description |
|---|---|---|
| `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_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 |
@ -128,7 +130,7 @@ Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from t
| 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_TLS_PORT` | `5349` | TURN server TLS port |
| `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation |

View file

@ -65,10 +65,20 @@ else
COMPOSE_CMD=(sudo docker compose)
fi
# 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
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate)
CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --build --force-recreate)
else
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate)
CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --pull always --force-recreate)
fi
# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`.

View file

@ -29,9 +29,12 @@ 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`.
- `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.
- 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.
- `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`.
- 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`.

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" PUBLIC_HOST "$public_host"
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"
dograh_set_env_key "$env_file" TURN_HOST "$public_host"
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
# 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() {
@ -262,14 +264,12 @@ dograh_validate_remote_runtime_env() {
[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing"
[[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST 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"
[[ "${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"
[[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL"
[[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST"
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
# from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so they are not
# required here. When an operator sets them explicitly (split deployment),
# their value is honored as-is — no equality check.
}
dograh_uses_init_compose_layout() {

View file

@ -109,6 +109,13 @@ dograh_set_env_key .env SERVER_IP "$SERVER_IP"
dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME"
dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME"
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)"
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the

View file

@ -307,13 +307,16 @@ Write-Host ''
if ($UseCoturn) {
Write-Warn 'To start Dograh with TURN, run:'
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 {
Write-Warn 'To start Dograh, run:'
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 '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-Host ''
Write-Host ' http://localhost:3010' -ForegroundColor Blue

View file

@ -211,13 +211,16 @@ echo ""
if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
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
echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
echo -e " ${BLUE}docker compose up --pull always${NC}"
echo -e " ${BLUE}docker compose --profile tunnel up --pull always${NC}"
fi
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 ""
echo -e " ${BLUE}http://localhost:3010${NC}"

View file

@ -324,19 +324,14 @@ ENVIRONMENT=production
# 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.
# 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
PUBLIC_HOST=$PUBLIC_HOST_VALUE
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
BACKEND_API_ENDPOINT=https://$PUBLIC_HOST_VALUE
# Public URL browsers use to fetch objects from MinIO (proxied by nginx)
MINIO_PUBLIC_ENDPOINT=https://$PUBLIC_HOST_VALUE
# TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$PUBLIC_HOST_VALUE
TURN_SECRET=$TURN_SECRET
# Relay-only ICE candidates for explicit TURN diagnostics
FORCE_TURN_RELAY=$FORCE_TURN_RELAY

View file

@ -207,10 +207,9 @@ if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
Write-Host ''
Write-Host "Docker registry: $Registry"
Write-Host "Telemetry enabled: $EnableTelemetry"
Write-Host ''
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 ''
$answer = Read-Host 'Start Dograh now? [Y/n]'
@ -222,7 +221,7 @@ if ($answer -match '^[Nn]') {
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
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) {
exit $LASTEXITCODE
}

View file

@ -200,10 +200,9 @@ fi
echo ""
echo "Docker registry: $REGISTRY"
echo "Telemetry enabled: $ENABLE_TELEMETRY"
echo ""
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 ""
if [[ ! -t 0 ]]; then
@ -222,4 +221,4 @@ esac
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
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

@ -35,6 +35,7 @@ export async function GET() {
let authProvider = "local";
let turnEnabled = false;
let forceTurnRelay = false;
let tunnelUrl: string | null = null;
let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
@ -53,6 +54,7 @@ export async function GET() {
authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
tunnelUrl = data.tunnel_url ?? null;
backendStatus = "reachable";
backendMessage = null;
}
@ -68,6 +70,7 @@ export async function GET() {
authProvider,
turnEnabled,
forceTurnRelay,
tunnelUrl,
backend: {
status: backendStatus,
url: backendUrl,

View file

@ -55,24 +55,21 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAppConfig } from "@/context/AppConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
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() {
const router = useRouter();
const params = useParams<{ configId: string }>();
const configId = Number(params.configId);
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 [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [loading, setLoading] = useState(true);
@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() {
<button
type="button"
onClick={() => {
const url = getInboundWebhookUrl();
const url = inboundWebhookUrl;
navigator.clipboard
.writeText(url)
.then(() => toast.success("Inbound webhook URL copied"))
@ -275,7 +272,7 @@ export default function TelephonyConfigurationDetailPage() {
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"
>
<span className="truncate">{getInboundWebhookUrl()}</span>
<span className="truncate">{inboundWebhookUrl}</span>
<Copy className="h-3 w-3 shrink-0" />
</button>
</div>

View file

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

View file

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

View file

@ -11,6 +11,9 @@ interface AppConfig {
authProvider: string;
turnEnabled: 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;
backendStatus: BackendStatus;
backendUrl: string;
backendMessage: string | null;
@ -29,6 +32,7 @@ const defaultConfig: AppConfig = {
authProvider: 'local',
turnEnabled: false,
forceTurnRelay: false,
tunnelUrl: null,
backendStatus: 'unreachable',
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
@ -64,6 +68,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
backendStatus,
backendUrl,
backendMessage: typeof backend.message === 'string' && backend.message.length > 0

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 : "")
);
}