Merge branch 'main' into helm-deployment

This commit is contained in:
Abhishek Kumar 2026-06-30 18:14:32 +05:30
commit 89d1e5ee89
523 changed files with 37767 additions and 11930 deletions

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

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() {
@ -401,6 +401,59 @@ dograh_preflight_remote_init_render() {
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() {
local project_dir=${1:-$(dograh_project_dir)}
local env_file="$project_dir/.env"
@ -410,6 +463,101 @@ dograh_prepare_remote_install() {
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() {
local destination=$1
local remote_path=$2

View file

@ -81,8 +81,29 @@ ts.write_text(
re.sub(r'"version": "[^"]+"', f'"version": "{version}"', ts.read_text(), count=1)
)
ts_lock = pathlib.Path("sdk/typescript/package-lock.json")
if ts_lock.exists():
lock_text = ts_lock.read_text()
lock_text = re.sub(
r'^ "version": "[^"]+"',
f' "version": "{version}"',
lock_text,
count=1,
flags=re.M,
)
lock_text = re.sub(
r'^( "version": ")[^"]+(")',
rf'\g<1>{version}\2',
lock_text,
count=1,
flags=re.M,
)
ts_lock.write_text(lock_text)
print(f" pyproject.toml → {version}")
print(f" package.json → {version}")
if ts_lock.exists():
print(f" package-lock.json → {version}")
PY
echo "→ Building Python wheel + sdist..."

View file

@ -28,6 +28,8 @@ NGINX_UPSTREAM_TEMPLATE="$BASE_DIR/nginx/dograh_upstream.conf.template"
NGINX_UPSTREAM_CONF="/etc/nginx/conf.d/dograh_upstream.conf"
HEALTH_CHECK_ENDPOINT="/api/v1/health"
ACTIVE_CALLS_ENDPOINT="/api/v1/health/active-calls"
DOGRAH_DEVOPS_SECRET_HEADER="X-Dograh-Devops-Secret"
# Load environment
if [[ -f "$ENV_FILE" ]]; then
@ -40,7 +42,9 @@ FASTAPI_WORKERS=${FASTAPI_WORKERS:-$CPU_CORES}
ARQ_WORKERS=${ARQ_WORKERS:-1}
# Tuning knobs (override via environment)
DRAIN_TIMEOUT=${DRAIN_TIMEOUT:-300} # seconds to wait for old workers to drain
DRAIN_TIMEOUT=${DRAIN_TIMEOUT:-300} # seconds to wait for active calls to finish
DRAIN_INTERVAL=${DRAIN_INTERVAL:-5} # seconds between active-call drain polls
STOP_TIMEOUT=${STOP_TIMEOUT:-30} # seconds to wait for drained workers to exit after SIGTERM
HEALTH_MAX_ATTEMPTS=${HEALTH_MAX_ATTEMPTS:-30} # per-worker health-check retries
HEALTH_INTERVAL=${HEALTH_INTERVAL:-2} # seconds between health-check retries
@ -54,6 +58,15 @@ log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $*"; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; }
if [[ -z "${DOGRAH_DEVOPS_SECRET:-}" ]]; then
log_error "DOGRAH_DEVOPS_SECRET is not set. Add it to $ENV_FILE before running rolling_update.sh."
exit 1
fi
if [[ "$DOGRAH_DEVOPS_SECRET" == "change-me-dograh-devops-secret" ]]; then
log_error "DOGRAH_DEVOPS_SECRET still has the example placeholder value. Replace it in $ENV_FILE."
exit 1
fi
# Band port calculation: band A = base, band B = base + 100
band_base_port() {
local band=$1
@ -96,6 +109,41 @@ kill_process_tree() {
fi
}
# Active in-progress call count for a single worker, via its health endpoint.
# A worker that is unreachable (already exited) reports 0, so it never blocks the
# drain. Non-200 responses or malformed bodies are hard failures: otherwise an
# auth/configuration error could be mistaken for a fully drained worker.
count_active_calls_on_port() {
local port=$1
local response http_code body n
response=$(curl -sS --max-time 3 \
-H "${DOGRAH_DEVOPS_SECRET_HEADER}: ${DOGRAH_DEVOPS_SECRET}" \
-w $'\n%{http_code}' \
"http://127.0.0.1:${port}${ACTIVE_CALLS_ENDPOINT}" 2>/dev/null || true)
http_code="${response##*$'\n'}"
body="${response%$'\n'*}"
if [[ "$http_code" == "000" ]]; then
printf '0'
return 0
fi
if [[ "$http_code" != "200" ]]; then
log_error "uvicorn_${port} active-calls endpoint returned HTTP ${http_code}. Check DOGRAH_DEVOPS_SECRET in $ENV_FILE."
return 1
fi
n=$(printf '%s' "$body" \
| grep -o '"active_calls"[[:space:]]*:[[:space:]]*[0-9]\+' \
| grep -o '[0-9]\+$' || true)
if [[ -z "$n" ]]; then
log_error "uvicorn_${port} active-calls endpoint returned an invalid response body."
return 1
fi
printf '%s' "$n"
}
###############################################################################
### ROLLBACK
###############################################################################
@ -366,9 +414,49 @@ log_info "nginx reloaded — traffic now routed to band $NEW_BAND"
### PHASE 5: DRAIN OLD WORKERS
###############################################################################
log_info "=== Phase 5: Draining old workers (band $OLD_BAND, timeout ${DRAIN_TIMEOUT}s) ==="
# nginx (Phase 4) already routes new calls to the new band, so the old band only
# holds calls still in progress. Wait for those to finish BEFORE signalling the
# workers: SIGTERM makes uvicorn force-close live call WebSockets (close code
# 1012), cutting calls mid-conversation. So we poll each old worker's in-flight
# call count and only stop once it reaches zero (or DRAIN_TIMEOUT elapses).
# Collect old worker PIDs
log_info "=== Phase 5a: Draining active calls from band $OLD_BAND (timeout ${DRAIN_TIMEOUT}s) ==="
drain_start=$(date +%s)
while true; do
active=0
for ((w = 0; w < FASTAPI_WORKERS; w++)); do
port=$((OLD_BASE + w))
# Only poll workers still alive; an exited worker holds no calls.
pidfile="$RUN_DIR/uvicorn_${port}.pid"
if [[ -f "$pidfile" ]] && kill -0 "$(<"$pidfile")" 2>/dev/null; then
if ! call_count=$(count_active_calls_on_port "$port"); then
exit 1
fi
active=$((active + call_count))
fi
done
if [[ $active -eq 0 ]]; then
log_info "Band $OLD_BAND fully drained — no active calls"
break
fi
elapsed=$(( $(date +%s) - drain_start ))
if [[ $elapsed -ge $DRAIN_TIMEOUT ]]; then
log_warn "Drain timeout reached (${DRAIN_TIMEOUT}s) with $active active call(s) still running — stopping anyway."
break
fi
log_info " Waiting for $active active call(s) to finish... (${elapsed}s / ${DRAIN_TIMEOUT}s)"
sleep "$DRAIN_INTERVAL"
done
log_info "=== Phase 5b: Stopping old workers (band $OLD_BAND, timeout ${STOP_TIMEOUT}s) ==="
# Calls are drained — now signal the workers and reap them. A drained worker
# exits within a second or two of SIGTERM; STOP_TIMEOUT bounds stragglers (e.g.
# a call that outlived DRAIN_TIMEOUT) before we force-kill.
OLD_PIDS=()
for ((w = 0; w < FASTAPI_WORKERS; w++)); do
port=$((OLD_BASE + w))
@ -385,7 +473,7 @@ for ((w = 0; w < FASTAPI_WORKERS; w++)); do
done
if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
start_time=$(date +%s)
stop_start=$(date +%s)
while true; do
all_dead=true
@ -397,13 +485,13 @@ if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
done
if $all_dead; then
log_info "All old workers exited gracefully"
log_info "All old workers exited"
break
fi
elapsed=$(( $(date +%s) - start_time ))
if [[ $elapsed -ge $DRAIN_TIMEOUT ]]; then
log_warn "Drain timeout reached (${DRAIN_TIMEOUT}s). Force-killing remaining old workers."
elapsed=$(( $(date +%s) - stop_start ))
if [[ $elapsed -ge $STOP_TIMEOUT ]]; then
log_warn "Stop timeout reached (${STOP_TIMEOUT}s). Force-killing remaining old workers."
for pid in "${OLD_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill_process_tree "$pid" "-KILL"
@ -414,11 +502,11 @@ if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
break
fi
log_info " Waiting for old workers to drain... (${elapsed}s / ${DRAIN_TIMEOUT}s)"
sleep 5
log_info " Waiting for old workers to exit... (${elapsed}s / ${STOP_TIMEOUT}s)"
sleep 2
done
else
log_warn "No old worker PIDs to drain"
log_warn "No old worker PIDs to stop"
fi
###############################################################################

72
scripts/setup-worktree.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Environment setup for a git worktree: pipecat submodule, isolated venv,
# Python --dev deps, and ui/node_modules. A fresh worktree is just a source
# checkout, so it has none of these; this provisions an ISOLATED environment
# (its own editable pipecat install points at THIS worktree's pipecat, so
# pipecat edits here take effect).
#
# Runs automatically once per worktree via the "folderOpen" task in
# .vscode/tasks.json. A success sentinel (venv/.worktree-setup-complete) makes
# it run-once:
# --if-needed : exit immediately if already provisioned (used by folderOpen)
# (no flag) : always run / re-provision (the manual "force" task)
#
# Heavy (minutes) the first time; instant skip afterwards. uv hardlinks wheels
# from its global cache and npm uses its cache, so even a forced re-run is fast.
set -euo pipefail
IF_NEEDED=0
for arg in "$@"; do
case "$arg" in
--if-needed) IF_NEEDED=1 ;;
*) echo "Unknown argument: $arg" >&2; echo "Usage: $0 [--if-needed]" >&2; exit 1 ;;
esac
done
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
PYVER="${PYVER:-3.13}"
SENTINEL="$ROOT/venv/.worktree-setup-complete"
# Run-once guard: skip instantly when already provisioned. Checked BEFORE the log
# is (re)written so a skip never clobbers the previous run's log. The sentinel
# lives inside venv/, so deleting venv/ (or the worktree) forces a redo; an
# interrupted run never writes it, so the next open self-heals.
if [ "$IF_NEEDED" -eq 1 ] && [ -f "$SENTINEL" ]; then
echo "[setup-worktree] already provisioned ($SENTINEL) — skipping."
exit 0
fi
# Mirror all output to a gitignored, worktree-local log so you can follow
# progress any time this runs (folderOpen task, manual, or background):
# tail -f logs/setup-worktree.log
# (/logs/ is already in .gitignore, and each worktree has its own logs/.)
LOG="$ROOT/logs/setup-worktree.log"
mkdir -p "$ROOT/logs"
exec > >(tee "$LOG") 2>&1
echo "=== setup-worktree $(date '+%Y-%m-%d %H:%M:%S') [$(basename "$ROOT")] ==="
echo "==> [1/4] pipecat submodule (init/update for this worktree)..."
git submodule update --init --recursive
echo "==> [2/4] isolated venv (python $PYVER)..."
if [ -x venv/bin/python ]; then
echo " venv already exists — reusing."
else
uv venv venv --python "$PYVER"
fi
# Activate so setup_requirements.sh / uv install into THIS worktree's venv.
set +u # activate scripts can reference unset vars
# shellcheck disable=SC1091
source venv/bin/activate
set -u
echo "==> [3/4] Python deps (--dev; submodule already inited)..."
./scripts/setup_requirements.sh --dev
echo "==> [4/4] UI node_modules..."
( cd ui && npm install )
# Mark success LAST, so an interrupted run re-provisions on the next open.
touch "$SENTINEL"
echo "✅ Worktree env ready: $(basename "$ROOT") ($(python -V 2>&1))"

View file

@ -43,7 +43,7 @@ if [[ ! -d "dograh" ]]; then
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}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
fi
@ -65,7 +65,7 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}"
echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}"
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 "")"
RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)"
@ -84,22 +84,14 @@ else
echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}"
fi
echo -e "${BLUE}[2/7] Installing Certbot...${NC}"
if command -v apt-get &> /dev/null; then
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 "${BLUE}[2/6] Installing Certbot...${NC}"
dograh_install_certbot || dograh_fail "Could not install certbot. Please install it manually and re-run."
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
DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
DOGRAH_PATH="$(pwd)"
if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then
dograh_download_remote_support_bundle "$(pwd)" "main"
@ -107,113 +99,74 @@ fi
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
if [[ -z "${SERVER_IP:-}" ]]; then
SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)"
fi
[[ -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 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)"
echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}"
echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}"
DOGRAH_PATH="$(pwd)"
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the
# 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
#!/bin/bash
cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt
cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key
chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key
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
[[ "$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
docker compose --profile remote restart nginx 2>/dev/null || true
HOOK_EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh
echo -e "${BLUE}[4/6] Obtaining Let's Encrypt certificate for $DOMAIN_NAME...${NC}"
if ! dograh_issue_letsencrypt_webroot "$(pwd)" "$DOMAIN_NAME" "$EMAIL_ADDRESS"; then
echo -e "${RED}✗ Certificate issuance failed${NC}"
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
echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}"
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
echo ""
echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}"
./remote_up.sh
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Custom Domain Setup Complete! ║${NC}"

View file

@ -102,18 +102,33 @@ Write-Host '[3/4] Python virtual environment' -ForegroundColor Blue
$VenvPath = Join-Path $BaseDir 'venv'
$VenvActivate = Join-Path $VenvPath 'Scripts/Activate.ps1'
if (Test-Path $VenvActivate) {
Write-Host "OK venv already exists at $VenvPath" -ForegroundColor Green
} else {
$py = $null
foreach ($candidate in @('python3.13', 'python', 'python3')) {
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
$py = $candidate
break
function Get-Python313Command {
foreach ($candidate in @('python3.13', 'python3', 'python')) {
if (-not (Get-Command $candidate -ErrorAction SilentlyContinue)) {
continue
}
$version = & $candidate -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null
if ($LASTEXITCODE -eq 0 -and $version -eq '3.13') {
return $candidate
}
}
return $null
}
if (Test-Path $VenvActivate) {
$venvPython = Join-Path $VenvPath 'Scripts/python.exe'
$venvVersion = & $venvPython -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null
if ($LASTEXITCODE -ne 0 -or $venvVersion -ne '3.13') {
Write-Host "Error: existing venv uses Python $venvVersion. Remove $VenvPath and re-run with Python 3.13." -ForegroundColor Red
exit 1
}
Write-Host "OK venv already exists at $VenvPath (Python $venvVersion)" -ForegroundColor Green
} else {
$py = Get-Python313Command
if (-not $py) {
Write-Host 'Error: no python interpreter found on PATH. Install Python 3.13.' -ForegroundColor Red
Write-Host 'Error: no Python 3.13 interpreter found on PATH. Install Python 3.13.' -ForegroundColor Red
exit 1
}
& $py -m venv $VenvPath
@ -128,8 +143,9 @@ Write-Host ''
Write-Host '[4/4] Environment files' -ForegroundColor Blue
$pairs = @(
@{ Src = 'api/.env.example'; Dst = 'api/.env' },
@{ Src = 'ui/.env.example'; Dst = 'ui/.env' }
@{ Src = 'api/.env.example'; Dst = 'api/.env' },
@{ Src = 'api/.env.test.example'; Dst = 'api/.env.test' },
@{ Src = 'ui/.env.example'; Dst = 'ui/.env' }
)
foreach ($p in $pairs) {
if (Test-Path $p.Dst) {

View file

@ -102,18 +102,36 @@ echo ""
echo -e "${BLUE}[3/4] Python virtual environment${NC}"
VENV_PATH="$BASE_DIR/venv"
if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then
echo -e "${GREEN}✓ venv already exists at $VENV_PATH${NC}"
else
PY=""
find_python_313() {
local candidate=""
local version=""
for candidate in python3.13 python3 python; do
if command -v "$candidate" >/dev/null 2>&1; then
PY="$candidate"
break
if ! command -v "$candidate" >/dev/null 2>&1; then
continue
fi
version=$("$candidate" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || true)
if [[ "$version" == "3.13" ]]; then
echo "$candidate"
return 0
fi
done
return 1
}
if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then
VENV_VERSION=$("$VENV_PATH/bin/python" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || true)
if [[ "$VENV_VERSION" != "3.13" ]]; then
echo -e "${RED}Error: existing venv uses Python ${VENV_VERSION:-unknown}. Remove $VENV_PATH and re-run with Python 3.13.${NC}"
exit 1
fi
echo -e "${GREEN}✓ venv already exists at $VENV_PATH (Python $VENV_VERSION)${NC}"
else
PY="$(find_python_313 || true)"
if [[ -z "$PY" ]]; then
echo -e "${RED}Error: no python interpreter found on PATH. Install Python 3.13.${NC}"
echo -e "${RED}Error: no Python 3.13 interpreter found on PATH. Install Python 3.13.${NC}"
exit 1
fi
"$PY" -m venv "$VENV_PATH"
@ -126,7 +144,7 @@ echo ""
###############################################################################
echo -e "${BLUE}[4/4] Environment files${NC}"
for pair in "api/.env.example|api/.env" "ui/.env.example|ui/.env"; do
for pair in "api/.env.example|api/.env" "api/.env.test.example|api/.env.test" "ui/.env.example|ui/.env"; do
src="${pair%|*}"
dst="${pair#*|}"
if [[ -f "$dst" ]]; then

View file

@ -0,0 +1,13 @@
# Devcontainer contributor setup
`setup_local.sh` and `setup_local.ps1` provision the OSS Docker stack for local
deployments. They are not the recommended contributor workflow for this
repository.
For day-to-day development, use the checked-in devcontainer under
`.devcontainer/`. The full contributor instructions live in
`../docs/contribution/setup.mdx`.
The devcontainer flow pins Python 3.13, installs backend and frontend
dependencies in-container, creates a container-specific API env file, and
starts Postgres, Redis, and MinIO automatically.

View file

@ -243,6 +243,10 @@ if ($UseCoturn) {
Write-Info "[2/$TotalSteps] Creating environment file..."
$ossJwtSecret = New-HexSecret 32
$postgresPassword = New-HexSecret 32
$redisPassword = New-HexSecret 32
$minioRootUser = "dograh$((New-HexSecret 6).Substring(0, 12))"
$minioRootPassword = New-HexSecret 32
$envLines = @(
'# Container registry for Dograh images'
@ -251,6 +255,21 @@ $envLines = @(
'# JWT secret for OSS authentication'
"OSS_JWT_SECRET=$ossJwtSecret"
''
'# PostgreSQL password. Used by the postgres container on first init and by'
"# the API's DATABASE_URL. Do not change after the first start — the password"
'# is baked into the postgres data volume when it is first created.'
"POSTGRES_PASSWORD=$postgresPassword"
''
"# Redis password. Used by the redis container's --requirepass and the API's"
'# REDIS_URL. This can be rotated by updating .env and recreating the redis'
'# container.'
"REDIS_PASSWORD=$redisPassword"
''
'# MinIO root credentials. Used by the MinIO container and the API''s'
'# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.'
"MINIO_ROOT_USER=$minioRootUser"
"MINIO_ROOT_PASSWORD=$minioRootPassword"
''
'# Telemetry (set to false to disable)'
"ENABLE_TELEMETRY=$EnableTelemetry"
''
@ -288,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

@ -150,6 +150,10 @@ fi
ENV_STEP=$TOTAL_STEPS
echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
MINIO_ROOT_USER="dograh$(openssl rand -hex 6)"
MINIO_ROOT_PASSWORD=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Container registry for Dograh images
@ -158,6 +162,21 @@ REGISTRY=$REGISTRY
# JWT secret for OSS authentication
OSS_JWT_SECRET=$OSS_JWT_SECRET
# PostgreSQL password. Used by the postgres container on first init and by the
# API's DATABASE_URL. Do not change after the first start — the password is
# baked into the postgres data volume when it is first created.
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
# Redis password. Used by the redis container's --requirepass and the API's
# REDIS_URL. This can be rotated by updating .env and recreating the redis
# container.
REDIS_PASSWORD=$REDIS_PASSWORD
# MinIO root credentials. Used by the MinIO container and the API's
# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.
MINIO_ROOT_USER=$MINIO_ROOT_USER
MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=$ENABLE_TELEMETRY
@ -192,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

@ -20,6 +20,6 @@ pip install -r api/requirements.txt
# Install pipecat from submodule last so it overrides any pipecat-ai pulled in by dependencies
echo "Installing pipecat dependencies..."
pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]
pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp,inworld,smallest]
echo "Setup complete! Pipecat is now available as a git submodule."
echo "Setup complete! Pipecat is now available as a git submodule."

View file

@ -35,9 +35,17 @@ echo "║ Automated HTTPS deployment with TURN server ║"
echo "╚══════════════════════════════════════════════════════════════╝"
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
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
fi
@ -49,6 +57,61 @@ if ! dograh_is_ipv4 "$SERVER_IP"; then
dograh_fail "Invalid IP address format"
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}"
# Get the TURN secret (skip prompt if TURN_SECRET is already set)
@ -135,10 +198,10 @@ if [[ -z "$FASTAPI_WORKERS" ]]; then
if [[ -t 0 ]]; then
echo ""
echo -e "${YELLOW}Number of FastAPI workers (uvicorn processes nginx will load-balance):${NC}"
read -p "[4]: " FASTAPI_WORKERS
FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}"
read -p "[2]: " FASTAPI_WORKERS
FASTAPI_WORKERS="${FASTAPI_WORKERS:-2}"
else
FASTAPI_WORKERS="4"
FASTAPI_WORKERS="2"
fi
fi
@ -185,6 +248,8 @@ fi
echo ""
echo -e "${GREEN}Configuration:${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 " Deploy mode: ${BLUE}$DEPLOY_MODE${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 \\
-out certs/local.crt \\
-days 365 \\
-subj "/CN=$SERVER_IP"
-subj "/CN=$PUBLIC_HOST_VALUE"
CERT_EOF
chmod +x generate_certificate.sh
echo -e "${GREEN}✓ generate_certificate.sh created${NC}"
@ -251,24 +316,25 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}"
echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
MINIO_ROOT_USER="dograh$(openssl rand -hex 6)"
MINIO_ROOT_PASSWORD=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Remote deployments run with production signaling and HTTPS defaults
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
PUBLIC_HOST=$SERVER_IP
PUBLIC_BASE_URL=https://$SERVER_IP
# 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
PUBLIC_HOST=$PUBLIC_HOST_VALUE
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
# TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$SERVER_IP
TURN_SECRET=$TURN_SECRET
# Relay-only ICE candidates for explicit TURN diagnostics
FORCE_TURN_RELAY=$FORCE_TURN_RELAY
@ -276,6 +342,21 @@ FORCE_TURN_RELAY=$FORCE_TURN_RELAY
# JWT secret for OSS authentication
OSS_JWT_SECRET=$OSS_JWT_SECRET
# PostgreSQL password. Used by the postgres container on first init and by the
# API's DATABASE_URL. Do not change after the first start — the password is
# baked into the postgres data volume when it is first created.
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
# Redis password. Used by the redis container's --requirepass and the API's
# REDIS_URL. Unlike postgres, this is not baked into a volume and can be
# rotated by updating .env and recreating the redis container.
REDIS_PASSWORD=$REDIS_PASSWORD
# MinIO root credentials. Used by the MinIO container and the API's
# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.
MINIO_ROOT_USER=$MINIO_ROOT_USER
MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=$ENABLE_TELEMETRY
@ -313,6 +394,46 @@ OVERRIDE_EOF
echo -e "${GREEN}✓ docker-compose.override.yaml created${NC}"
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 -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete! ║${NC}"
@ -331,25 +452,42 @@ echo " - certs/local.crt"
echo " - certs/local.key"
echo " - .env"
echo ""
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}"
if [[ "$CERT_MODE" == "sslip" ]]; then
if [[ "$CERT_RESULT" == "sslip" ]]; then
echo -e "${GREEN}Dograh is running with a trusted certificate at:${NC}"
echo ""
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${GREEN}No browser warning — the certificate renews automatically before expiry.${NC}"
else
echo -e "${YELLOW}Dograh is running (with a temporary self-signed certificate) at:${NC}"
echo ""
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
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
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

@ -18,6 +18,22 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BaseDir = Split-Path -Parent $ScriptDir
Set-Location $BaseDir
# Fail early if the active Python is not 3.12 or 3.13. uv pip installs into
# whichever interpreter resolves here (the active venv, or PATH python), so a
# mismatch surfaces as confusing wheel/build errors much later.
$PythonBin = if ($env:PYTHON) { $env:PYTHON } else { 'python' }
if (-not (Get-Command $PythonBin -ErrorAction SilentlyContinue)) {
Write-Error "'$PythonBin' not found on PATH. Activate the project venv (or set `$env:PYTHON) and retry."
exit 1
}
$PyMajMin = & $PythonBin -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'
if ($PyMajMin -ne '3.12' -and $PyMajMin -ne '3.13') {
$PyPath = (Get-Command $PythonBin).Source
Write-Error "Python 3.12 or 3.13 required, found $PyMajMin at $PyPath. Activate a venv built with python3.12 or python3.13 and retry."
exit 1
}
Write-Host "Setting up pipecat as a git submodule..."
if (-not $Dev) {
@ -25,24 +41,30 @@ if (-not $Dev) {
git submodule update --init --recursive
}
# Use uv (https://github.com/astral-sh/uv) for ~5-10x faster installs.
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
Write-Host "Installing uv..."
Invoke-RestMethod https://astral.sh/uv/install.ps1 | Invoke-Expression
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
}
# Install dograh API requirements first so pipecat's extras win on any
# shared transitive dependencies (matches api/Dockerfile and CI workflow).
Write-Host "Installing dograh API requirements..."
pip install -r api/requirements.txt
uv pip install -r api/requirements.txt
if ($Dev) {
Write-Host "Installing dograh API dev requirements..."
pip install -r api/requirements.dev.txt
uv pip install -r api/requirements.dev.txt
}
# Install pipecat in editable mode with all extras
Write-Host "Installing pipecat dependencies..."
pip install -e './pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]'
uv pip install -e './pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp,inworld,smallest]'
if ($Dev) {
Write-Host "Installing pipecat dev dependencies..."
pip install --upgrade pip
pip install --group pipecat/pyproject.toml:dev
uv pip install --group pipecat/pyproject.toml:dev
}
Write-Host "Setup complete! Requirements are installed."

View file

@ -32,6 +32,26 @@ DOGRAH_DIR="$(dirname "$SCRIPT_DIR")"
cd "$DOGRAH_DIR"
# Fail early if the active Python is not 3.12 or 3.13. uv pip installs into
# whichever interpreter resolves here (the active venv, or PATH python3), so a
# mismatch surfaces as confusing wheel/build errors much later.
PYTHON_BIN="${PYTHON:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "Error: '$PYTHON_BIN' not found on PATH." >&2
echo "Activate the project venv (or set PYTHON=/path/to/python) and retry." >&2
exit 1
fi
PY_MAJ_MIN=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
case "$PY_MAJ_MIN" in
3.12|3.13) ;;
*)
echo "Error: Python 3.12 or 3.13 required, found $PY_MAJ_MIN at $(command -v "$PYTHON_BIN")." >&2
echo "Activate a venv built with python3.12 or python3.13 and retry." >&2
exit 1
;;
esac
echo "Setting up pipecat as a git submodule..."
if [ "$DEV_MODE" -eq 0 ]; then
@ -39,24 +59,32 @@ if [ "$DEV_MODE" -eq 0 ]; then
git submodule update --init --recursive
fi
# Use uv (https://github.com/astral-sh/uv) for ~5-10x faster installs.
# The devcontainer Dockerfile pre-installs uv; this fallback handles CI runners
# and contributor laptops that don't have it yet.
if ! command -v uv >/dev/null 2>&1; then
echo "Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
# Install dograh API requirements first so pipecat's extras win on any
# shared transitive dependencies (matches api/Dockerfile and CI workflow).
echo "Installing dograh API requirements..."
pip install -r api/requirements.txt
uv pip install -r api/requirements.txt
if [ "$DEV_MODE" -eq 1 ]; then
echo "Installing dograh API dev requirements..."
pip install -r api/requirements.dev.txt
uv pip install -r api/requirements.dev.txt
fi
# Install pipecat in editable mode with all extras
echo "Installing pipecat dependencies..."
pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp]
uv pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp,inworld,smallest]
if [ "$DEV_MODE" -eq 1 ]; then
echo "Installing pipecat dev dependencies..."
pip install --upgrade pip
pip install --group pipecat/pyproject.toml:dev
uv pip install --group pipecat/pyproject.toml:dev
fi
echo "Setup complete! Requirements are installed."

227
scripts/start_docker.ps1 Normal file
View file

@ -0,0 +1,227 @@
$ErrorActionPreference = 'Stop'
$EnvFile = '.env'
$Registry = if ([string]::IsNullOrEmpty($env:REGISTRY)) { 'ghcr.io/dograh-hq' } else { $env:REGISTRY }
$EnableTelemetry = if ([string]::IsNullOrEmpty($env:ENABLE_TELEMETRY)) { 'true' } else { $env:ENABLE_TELEMETRY }
$Utf8NoBom = [System.Text.UTF8Encoding]::new($false)
function New-HexSecret {
$bytes = [byte[]]::new(32)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
function New-MinioRootUser {
return "dograh$((New-HexSecret).Substring(0, 12))"
}
function Get-DotEnvValue {
param(
[string]$Path,
[string]$Key
)
if (-not (Test-Path $Path)) {
return $null
}
$resolvedPath = (Resolve-Path $Path).Path
foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) {
if ($line.StartsWith("$Key=")) {
return $line.Substring($Key.Length + 1)
}
}
return $null
}
function Set-DotEnvValue {
param(
[string]$Path,
[string]$Key,
[string]$Value
)
$lines = New-Object System.Collections.Generic.List[string]
$updated = $false
if (Test-Path $Path) {
$resolvedPath = (Resolve-Path $Path).Path
foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) {
if ($line.StartsWith("$Key=")) {
$lines.Add("$Key=$Value")
$updated = $true
} else {
$lines.Add($line)
}
}
}
if (-not $updated) {
$lines.Add("$Key=$Value")
}
[System.IO.File]::WriteAllLines((Join-Path (Get-Location) $Path), $lines, $Utf8NoBom)
}
function Get-PostgresVolumeName {
try {
$configJson = docker compose config --format json 2>$null
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrEmpty($configJson)) {
$config = $configJson | ConvertFrom-Json
$volumeName = $config.volumes.postgres_data.name
if (-not [string]::IsNullOrEmpty($volumeName)) {
return $volumeName
}
}
} catch {
# Fall back to Compose's default project-name convention below.
}
$projectName = if ([string]::IsNullOrEmpty($env:COMPOSE_PROJECT_NAME)) {
(Split-Path -Leaf (Get-Location).Path).ToLowerInvariant() -replace '[^a-z0-9_-]', ''
} else {
$env:COMPOSE_PROJECT_NAME.ToLowerInvariant() -replace '[^a-z0-9_-]', ''
}
return "${projectName}_postgres_data"
}
function Test-DockerVolumeExists {
param([string]$Name)
docker volume inspect $Name *> $null
return $LASTEXITCODE -eq 0
}
function Wait-PostgresReady {
for ($attempt = 0; $attempt -lt 20; $attempt++) {
docker compose exec -T postgres pg_isready -U postgres *> $null
if ($LASTEXITCODE -eq 0) {
return
}
Start-Sleep -Seconds 1
}
Write-Error 'Postgres did not become ready while syncing POSTGRES_PASSWORD.'
exit 1
}
function Sync-PostgresPassword {
param([string]$Password)
if ([string]::IsNullOrEmpty($Password)) {
return
}
$volumeName = Get-PostgresVolumeName
if ([string]::IsNullOrEmpty($volumeName) -or -not (Test-DockerVolumeExists $volumeName)) {
return
}
Write-Host "Existing Postgres volume detected; syncing postgres password from $EnvFile."
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
docker compose up -d postgres
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Wait-PostgresReady
"ALTER USER postgres WITH PASSWORD :'dograh_password';" | docker compose exec -T postgres psql `
-U postgres `
-d postgres `
-v 'ON_ERROR_STOP=1' `
-v "dograh_password=$Password" > $null
if ($LASTEXITCODE -ne 0) {
Write-Error 'Failed to sync POSTGRES_PASSWORD with the existing Postgres volume.'
exit $LASTEXITCODE
}
Write-Host 'Postgres password synced.'
}
if (-not (Test-Path 'docker-compose.yaml')) {
Write-Error 'docker-compose.yaml not found. Download it first, then re-run this script.'
exit 1
}
$envFileExisted = Test-Path $EnvFile
$existingSecret = Get-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET'
if ([string]::IsNullOrEmpty($existingSecret)) {
Set-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' -Value (New-HexSecret)
Write-Host "Created OSS_JWT_SECRET in $EnvFile."
} else {
Write-Host "OSS_JWT_SECRET is already set in $EnvFile."
}
$existingPostgresPassword = Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD'
if ([string]::IsNullOrEmpty($existingPostgresPassword)) {
if (-not $envFileExisted) {
Set-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD' -Value (New-HexSecret)
Write-Host "Created POSTGRES_PASSWORD in $EnvFile."
} else {
Write-Host "POSTGRES_PASSWORD is not set in $EnvFile; keeping the docker-compose fallback for existing local data volumes."
}
} else {
Write-Host "POSTGRES_PASSWORD is already set in $EnvFile."
}
$existingRedisPassword = Get-DotEnvValue -Path $EnvFile -Key 'REDIS_PASSWORD'
if ([string]::IsNullOrEmpty($existingRedisPassword)) {
Set-DotEnvValue -Path $EnvFile -Key 'REDIS_PASSWORD' -Value (New-HexSecret)
Write-Host "Created REDIS_PASSWORD in $EnvFile."
} else {
Write-Host "REDIS_PASSWORD is already set in $EnvFile."
}
$existingMinioRootUser = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER'
if ([string]::IsNullOrEmpty($existingMinioRootUser)) {
$existingMinioAccessKey = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ACCESS_KEY'
if ([string]::IsNullOrEmpty($existingMinioAccessKey)) {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER' -Value (New-MinioRootUser)
Write-Host "Created MINIO_ROOT_USER in $EnvFile."
} else {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER' -Value $existingMinioAccessKey
Write-Host "Created MINIO_ROOT_USER in $EnvFile from existing MINIO_ACCESS_KEY."
}
} else {
Write-Host "MINIO_ROOT_USER is already set in $EnvFile."
}
$existingMinioRootPassword = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD'
if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
$existingMinioSecretKey = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_SECRET_KEY'
if ([string]::IsNullOrEmpty($existingMinioSecretKey)) {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD' -Value (New-HexSecret)
Write-Host "Created MINIO_ROOT_PASSWORD in $EnvFile."
} else {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD' -Value $existingMinioSecretKey
Write-Host "Created MINIO_ROOT_PASSWORD in $EnvFile from existing MINIO_SECRET_KEY."
}
} else {
Write-Host "MINIO_ROOT_PASSWORD is already set in $EnvFile."
}
Write-Host ''
Write-Host "Docker registry: $Registry"
Write-Host ''
Write-Host 'This will run:'
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]'
if ($answer -match '^[Nn]') {
Write-Host 'Dograh was not started.'
exit 0
}
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD')
docker compose --profile tunnel up --pull always
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

224
scripts/start_docker.sh Executable file
View file

@ -0,0 +1,224 @@
#!/usr/bin/env bash
set -e
ENV_FILE=".env"
REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}"
ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}"
fail() {
echo "Error: $*" >&2
exit 1
}
generate_secret() {
if command -v python3 >/dev/null 2>&1 && python3 -c 'import secrets; print(secrets.token_hex(32))'; then
return
fi
if command -v openssl >/dev/null 2>&1 && openssl rand -hex 32; then
return
fi
if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; then
return
fi
fail "Could not generate a secret. Install python3 or openssl, or set secrets manually in .env."
}
generate_minio_root_user() {
printf 'dograh%s\n' "$(generate_secret | cut -c1-12)"
}
dotenv_value() {
local key=$1
local line
[[ -f "$ENV_FILE" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key"=*)
printf '%s\n' "${line#*=}"
return 0
;;
esac
done < "$ENV_FILE"
return 1
}
set_dotenv_value() {
local key=$1
local value=$2
local tmp_file="${ENV_FILE}.tmp.$$"
local line
local updated=false
if [[ -f "$ENV_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
"$key"=*)
printf '%s=%s\n' "$key" "$value"
updated=true
;;
*)
printf '%s\n' "$line"
;;
esac
done < "$ENV_FILE" > "$tmp_file"
if [[ "$updated" != "true" ]]; then
printf '%s=%s\n' "$key" "$value" >> "$tmp_file"
fi
mv "$tmp_file" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$value" > "$ENV_FILE"
fi
}
postgres_volume_name() {
local volume_name=""
local project_name=""
if command -v python3 >/dev/null 2>&1; then
volume_name="$(
docker compose config --format json 2>/dev/null \
| python3 -c 'import json, sys; print(json.load(sys.stdin).get("volumes", {}).get("postgres_data", {}).get("name", ""))' 2>/dev/null \
|| true
)"
if [[ -n "$volume_name" ]]; then
printf '%s\n' "$volume_name"
return
fi
fi
project_name="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
project_name="$(printf '%s' "$project_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')"
printf '%s_postgres_data\n' "$project_name"
}
sync_postgres_password() {
local postgres_password=$1
local volume_name=""
local postgres_ready=false
[[ -n "$postgres_password" ]] || return
volume_name="$(postgres_volume_name)"
if ! docker volume inspect "$volume_name" >/dev/null 2>&1; then
return
fi
echo "Existing Postgres volume detected; syncing postgres password from $ENV_FILE."
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up -d postgres
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if docker compose exec -T postgres pg_isready -U postgres >/dev/null 2>&1; then
postgres_ready=true
break
fi
sleep 1
done
if [[ "$postgres_ready" != "true" ]]; then
fail "Postgres did not become ready while syncing POSTGRES_PASSWORD."
fi
printf '%s\n' "ALTER USER postgres WITH PASSWORD :'dograh_password';" \
| docker compose exec -T postgres psql \
-U postgres \
-d postgres \
-v ON_ERROR_STOP=1 \
-v "dograh_password=$postgres_password" >/dev/null
echo "Postgres password synced."
}
[[ -f docker-compose.yaml ]] || fail "docker-compose.yaml not found. Download it first, then re-run this script."
env_file_existed=false
if [[ -f "$ENV_FILE" ]]; then
env_file_existed=true
fi
existing_secret="$(dotenv_value OSS_JWT_SECRET || true)"
if [[ -z "$existing_secret" ]]; then
set_dotenv_value OSS_JWT_SECRET "$(generate_secret)"
echo "Created OSS_JWT_SECRET in $ENV_FILE."
else
echo "OSS_JWT_SECRET is already set in $ENV_FILE."
fi
existing_postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
if [[ -z "$existing_postgres_password" ]]; then
if [[ "$env_file_existed" == "false" ]]; then
set_dotenv_value POSTGRES_PASSWORD "$(generate_secret)"
echo "Created POSTGRES_PASSWORD in $ENV_FILE."
else
echo "POSTGRES_PASSWORD is not set in $ENV_FILE; keeping the docker-compose fallback for existing local data volumes."
fi
else
echo "POSTGRES_PASSWORD is already set in $ENV_FILE."
fi
existing_redis_password="$(dotenv_value REDIS_PASSWORD || true)"
if [[ -z "$existing_redis_password" ]]; then
set_dotenv_value REDIS_PASSWORD "$(generate_secret)"
echo "Created REDIS_PASSWORD in $ENV_FILE."
else
echo "REDIS_PASSWORD is already set in $ENV_FILE."
fi
existing_minio_root_user="$(dotenv_value MINIO_ROOT_USER || true)"
if [[ -z "$existing_minio_root_user" ]]; then
existing_minio_access_key="$(dotenv_value MINIO_ACCESS_KEY || true)"
if [[ -n "$existing_minio_access_key" ]]; then
set_dotenv_value MINIO_ROOT_USER "$existing_minio_access_key"
echo "Created MINIO_ROOT_USER in $ENV_FILE from existing MINIO_ACCESS_KEY."
else
set_dotenv_value MINIO_ROOT_USER "$(generate_minio_root_user)"
echo "Created MINIO_ROOT_USER in $ENV_FILE."
fi
else
echo "MINIO_ROOT_USER is already set in $ENV_FILE."
fi
existing_minio_root_password="$(dotenv_value MINIO_ROOT_PASSWORD || true)"
if [[ -z "$existing_minio_root_password" ]]; then
existing_minio_secret_key="$(dotenv_value MINIO_SECRET_KEY || true)"
if [[ -n "$existing_minio_secret_key" ]]; then
set_dotenv_value MINIO_ROOT_PASSWORD "$existing_minio_secret_key"
echo "Created MINIO_ROOT_PASSWORD in $ENV_FILE from existing MINIO_SECRET_KEY."
else
set_dotenv_value MINIO_ROOT_PASSWORD "$(generate_secret)"
echo "Created MINIO_ROOT_PASSWORD in $ENV_FILE."
fi
else
echo "MINIO_ROOT_PASSWORD is already set in $ENV_FILE."
fi
echo ""
echo "Docker registry: $REGISTRY"
echo ""
echo "This will run:"
echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose --profile tunnel up --pull always"
echo ""
if [[ ! -t 0 ]]; then
echo "Run the command above from an interactive shell to start Dograh."
exit 0
fi
read -r -p "Start Dograh now? [Y/n]: " answer
case "$answer" in
[Nn]*)
echo "Dograh was not started."
exit 0
;;
esac
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
sync_postgres_password "$postgres_password"
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose --profile tunnel up --pull always

View file

@ -33,6 +33,15 @@ if [[ -f "$ENV_FILE" ]]; then
set -a && . "$ENV_FILE" && set +a
fi
if [[ -z "${DOGRAH_DEVOPS_SECRET:-}" ]]; then
echo "ERROR: DOGRAH_DEVOPS_SECRET is not set. Add it to $ENV_FILE before starting production services."
exit 1
fi
if [[ "$DOGRAH_DEVOPS_SECRET" == "change-me-dograh-devops-secret" ]]; then
echo "ERROR: DOGRAH_DEVOPS_SECRET still has the example placeholder value. Replace it in $ENV_FILE."
exit 1
fi
UVICORN_BASE_PORT=${UVICORN_BASE_PORT:-8000}
CPU_CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
FASTAPI_WORKERS=${FASTAPI_WORKERS:-$CPU_CORES}

View file

@ -21,7 +21,7 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BaseDir = Split-Path -Parent $ScriptDir
Set-Location $BaseDir
$EnvFile = Join-Path $BaseDir 'api/.env'
$EnvFile = if ($env:DOGRAH_ENV_FILE) { $env:DOGRAH_ENV_FILE } else { Join-Path $BaseDir 'api/.env' }
$RunDir = Join-Path $BaseDir 'run'
$LogsRoot = Join-Path $BaseDir 'logs'
$LatestDir = Join-Path $LogsRoot 'latest'
@ -29,6 +29,7 @@ $VenvPath = Join-Path $BaseDir 'venv'
Write-Host "Starting Dograh Services (DEV MODE) in BASE_DIR: $BaseDir"
Write-Host "Auto-reload enabled for api/ directory changes"
Write-Host "Environment file: $EnvFile"
###############################################################################
### 1) Load environment variables

View file

@ -8,7 +8,7 @@ set -e # Exit on error
# Determine BASE_DIR as parent of the scripts directory
BASE_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)"
ENV_FILE="$BASE_DIR/api/.env"
ENV_FILE="${DOGRAH_ENV_FILE:-$BASE_DIR/api/.env}"
RUN_DIR="$BASE_DIR/run" # Where we keep *.pid
BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory
@ -26,6 +26,7 @@ HEALTH_INTERVAL=${HEALTH_INTERVAL:-2}
cd "$BASE_DIR"
echo "Starting Dograh Services (DEV MODE) at $(date) in BASE_DIR: ${BASE_DIR}"
echo "Auto-reload enabled for api/ directory changes"
echo "Environment file: $ENV_FILE"
###############################################################################
### 1) Load environment variables

View file

@ -31,6 +31,26 @@ trap cleanup EXIT
REPO="dograh-hq/dograh"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
generate_secret() {
if command -v python3 >/dev/null 2>&1 && python3 -c 'import secrets; print(secrets.token_hex(32))'; then
return
fi
if command -v openssl >/dev/null 2>&1 && openssl rand -hex 32; then
return
fi
if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; then
return
fi
dograh_fail "Could not generate a secret. Install python3 or openssl, or set missing secrets manually in .env."
}
generate_minio_root_user() {
printf 'dograh%s\n' "$(generate_secret | cut -c1-12)"
}
echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Remote Update ║"
@ -71,10 +91,10 @@ if [[ -z "${FASTAPI_WORKERS:-}" ]]; then
if [[ -t 0 ]]; then
echo ""
echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}"
read -p "[4]: " FASTAPI_WORKERS
FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}"
read -p "[2]: " FASTAPI_WORKERS
FASTAPI_WORKERS="${FASTAPI_WORKERS:-2}"
else
FASTAPI_WORKERS="4"
FASTAPI_WORKERS="2"
fi
fi
@ -96,7 +116,7 @@ if [[ -z "$TARGET_VERSION" ]]; then
if [[ -t 0 ]]; then
echo ""
echo -e "${YELLOW}Target version. Accepted forms: bare semver (1.28.0), v-prefixed (v1.28.0),${NC}"
echo -e "${YELLOW}full git tag (dograh-v1.28.0), or 'main' for bleeding edge.${NC}"
echo -e "${YELLOW}full git tag (dograh-v1.28.0), or 'main' for the latest deployment files.${NC}"
read -p "[$LATEST_TAG]: " TARGET_VERSION
TARGET_VERSION="${TARGET_VERSION:-$LATEST_TAG}"
else
@ -219,6 +239,28 @@ fi
echo -e "${BLUE}[3/3] Synchronizing environment and validating init-based remote config...${NC}"
dograh_set_env_key .env FASTAPI_WORKERS "$FASTAPI_WORKERS"
if [[ -z "${REDIS_PASSWORD:-}" ]]; then
dograh_set_env_key .env REDIS_PASSWORD "$(generate_secret)"
dograh_success "✓ REDIS_PASSWORD created in .env"
fi
if [[ -z "${MINIO_ROOT_USER:-}" ]]; then
if [[ -n "${MINIO_ACCESS_KEY:-}" ]]; then
dograh_set_env_key .env MINIO_ROOT_USER "$MINIO_ACCESS_KEY"
dograh_success "✓ MINIO_ROOT_USER created in .env from existing MINIO_ACCESS_KEY"
else
dograh_set_env_key .env MINIO_ROOT_USER "$(generate_minio_root_user)"
dograh_success "✓ MINIO_ROOT_USER created in .env"
fi
fi
if [[ -z "${MINIO_ROOT_PASSWORD:-}" ]]; then
if [[ -n "${MINIO_SECRET_KEY:-}" ]]; then
dograh_set_env_key .env MINIO_ROOT_PASSWORD "$MINIO_SECRET_KEY"
dograh_success "✓ MINIO_ROOT_PASSWORD created in .env from existing MINIO_SECRET_KEY"
else
dograh_set_env_key .env MINIO_ROOT_PASSWORD "$(generate_secret)"
dograh_success "✓ MINIO_ROOT_PASSWORD created in .env"
fi
fi
dograh_prepare_remote_install "$(pwd)"
docker compose config -q
dograh_success "✓ Remote init configuration validated"

76
scripts/worktree-assign-port.sh Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Assign a unique backend port to this git worktree and rewrite the env files
# that depend on it. Runs automatically as a VS Code "folderOpen" task (see
# .vscode/tasks.json), so it executes once per worktree when you open it.
#
# Scheme:
# - The MAIN worktree is left untouched (backend stays on uvicorn's default 8000).
# - Each linked worktree gets the next free backend port: 8001, 8002, ...
# - api/.env : UVICORN_PORT -> the assigned backend port
# - ui/.env : BACKEND_URL -> http://localhost:<port>
# NEXT_PUBLIC_BACKEND_URL -> http://localhost:<port>
#
# CORS is intentionally NOT touched: local dev runs DEPLOYMENT_MODE="oss", where
# the API forces allow_origins=["*"] and ignores CORS_ALLOWED_ORIGINS entirely.
#
# Idempotent: re-running keeps an already-assigned, non-colliding port. The UI
# dev server is left alone — `npm run dev` auto-selects a free port (3000, 3001…).
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
MAIN="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
[ "$ROOT" = "$MAIN" ] && { echo "[worktree] main worktree -> backend 8000 (untouched)"; exit 0; }
AENV="$ROOT/api/.env"
UENV="$ROOT/ui/.env"
[ -f "$AENV" ] || { echo "[worktree] no api/.env yet; skipping"; exit 0; }
# Echo the UVICORN_PORT value from an env file (empty if unset/missing).
port_of() { { grep -E '^[[:space:]]*UVICORN_PORT=' "$1" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/[[:space:]]//g'; } || true; }
# Ports already in use by OTHER worktrees (main implicitly uses 8000).
used=(8000)
while IFS= read -r line; do
case "$line" in
"worktree "*)
wt="${line#worktree }"
[ "$wt" = "$ROOT" ] && continue
p="$(port_of "$wt/api/.env")"
[ -n "$p" ] && used+=("$p")
;;
esac
done < <(git worktree list --porcelain)
mine="$(port_of "$AENV")"
# Keep my port if it's set and not claimed by another worktree; else take max+1.
reassign=1
if [ -n "$mine" ]; then
reassign=0
for u in "${used[@]}"; do [ "$u" = "$mine" ] && reassign=1; done
fi
if [ "$reassign" -eq 1 ]; then
max=0
for u in "${used[@]}"; do [ "$u" -gt "$max" ] && max="$u"; done
B=$((max + 1))
else
B="$mine"
fi
# Insert or update KEY=VALUE in an env file, preserving everything else.
upsert() {
local key="$1" val="$2" file="$3"
if grep -qE "^[[:space:]]*${key}=" "$file"; then
sed -i.bak -E "s|^[[:space:]]*${key}=.*|${key}=${val}|" "$file" && rm -f "$file.bak"
else
printf '\n%s=%s\n' "$key" "$val" >> "$file"
fi
}
upsert UVICORN_PORT "$B" "$AENV"
if [ -f "$UENV" ]; then
upsert BACKEND_URL "http://localhost:$B" "$UENV"
upsert NEXT_PUBLIC_BACKEND_URL "http://localhost:$B" "$UENV"
fi
echo "[worktree] $(basename "$ROOT"): backend=$B (UI auto-port via 'npm run dev')"