chore: refactor setup scrpts (#288)

* refactor setup scrpts

* update docker compose to use dograh-init

* avoid creating unnecessary conf files

* fix local setup script

* add agents.md
This commit is contained in:
Abhishek 2026-05-14 14:45:34 +05:30 committed by GitHub
parent 4ff1f576f0
commit 87699f2dee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1321 additions and 1178 deletions

View file

@ -0,0 +1,82 @@
__DOGRAH_UPSTREAM_BLOCK__
server {
listen 80;
server_name __DOGRAH_PUBLIC_HOST__;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name __DOGRAH_PUBLIC_HOST__;
ssl_certificate /etc/nginx/certs/local.crt;
ssl_certificate_key /etc/nginx/certs/local.key;
# Basic TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# Backend API and WebSockets - bypass the UI, go straight to the
# api workers via the least_conn upstream defined above.
location /api/v1/ {
proxy_pass http://dograh_api;
proxy_http_version 1.1;
# Retry on a dead/restarting worker
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Long-lived WebSockets (audio streaming, signaling)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Don't buffer streamed responses
proxy_buffering off;
client_max_body_size 100M;
}
location / {
proxy_pass http://ui:3010;
proxy_http_version 1.1;
# Important for WebSockets / hot reload etc.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Rewrite localhost MinIO URLs in API responses to use current domain
sub_filter 'http://localhost:9000/voice-audio/' 'https://$host/voice-audio/';
sub_filter_once off;
sub_filter_types application/json text/html;
}
location /voice-audio/ {
proxy_pass http://minio:9000/voice-audio/;
proxy_http_version 1.1;
# Headers for file downloads from MinIO
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Allow large file downloads
proxy_buffering off;
client_max_body_size 100M;
}
}

View file

@ -0,0 +1,28 @@
# Coturn TURN Server - Docker Configuration
# Auto-generated by Dograh remote config renderer.
# Listener ports
listening-port=3478
tls-listening-port=5349
# Relay port range
min-port=49152
max-port=49200
# Network - external IP / host for NAT traversal
external-ip=__DOGRAH_TURN_EXTERNAL_IP__
# Realm
realm=dograh.com
# Authentication (TURN REST API with time-limited credentials)
use-auth-secret
static-auth-secret=__DOGRAH_TURN_SECRET__
# Security
fingerprint
no-cli
no-multicast-peers
# Logging
log-file=stdout

View file

@ -59,21 +59,69 @@ services:
networks: networks:
- app-network - app-network
dograh-init:
image: bash:5.2
container_name: dograh_init
profiles: ["remote", "local-turn"]
environment:
ENVIRONMENT: "${ENVIRONMENT:-local}"
SERVER_IP: "${SERVER_IP:-}"
PUBLIC_HOST: "${PUBLIC_HOST:-}"
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}"
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}"
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
TURN_HOST: "${TURN_HOST:-}"
TURN_SECRET: "${TURN_SECRET:-}"
FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}"
volumes:
- ./scripts:/workspace/scripts:ro
- ./deploy:/workspace/deploy:ro
- ./certs:/certs:ro
- nginx-generated:/generated/nginx
- coturn-generated:/generated/coturn
command:
- /workspace/scripts/run_dograh_init.sh
nginx: nginx:
image: nginx:alpine image: nginx:alpine
container_name: nginx_https container_name: nginx_https
profiles: ["remote"] profiles: ["remote"]
depends_on: depends_on:
- ui dograh-init:
condition: service_completed_successfully
ui:
condition: service_started
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - nginx-generated:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro - ./certs:/etc/nginx/certs:ro
networks: networks:
- app-network - app-network
coturn:
image: coturn/coturn:4.8.0
container_name: coturn
restart: unless-stopped
profiles: ["remote", "local-turn"]
depends_on:
dograh-init:
condition: service_completed_successfully
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
- "5349:5349/udp"
- "5349:5349/tcp"
- "49152-49200:49152-49200/udp"
volumes:
- coturn-generated:/etc/coturn:ro
command:
- -c
- /etc/coturn/turnserver.conf
networks:
- app-network
api: api:
image: ${REGISTRY:-dograhai}/dograh-api:latest image: ${REGISTRY:-dograhai}/dograh-api:latest
volumes: volumes:
@ -108,8 +156,8 @@ services:
MINIO_SECURE: "false" MINIO_SECURE: "false"
# Number of uvicorn worker processes (each is its own process bound to a # Number of uvicorn worker processes (each is its own process bound to a
# distinct port starting at 8000). nginx load-balances across them with # distinct port starting at 8000). dograh-init renders nginx upstreams
# least_conn — see setup_remote.sh. # from this value and nginx load-balances across them with least_conn.
FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}"
# Langfuse — credentials can be set here or per-organization via the UI # Langfuse — credentials can be set here or per-organization via the UI
@ -195,25 +243,6 @@ services:
networks: networks:
- app-network - app-network
coturn:
image: coturn/coturn:4.8.0
container_name: coturn
restart: unless-stopped
profiles: ["remote", "local-turn"]
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
- "5349:5349/udp"
- "5349:5349/tcp"
- "49152-49200:49152-49200/udp"
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
command:
- -c
- /etc/coturn/turnserver.conf
networks:
- app-network
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
@ -221,6 +250,10 @@ volumes:
driver: local driver: local
shared-tmp: shared-tmp:
driver: local driver: local
nginx-generated:
driver: local
coturn-generated:
driver: local
networks: networks:
app-network: app-network:

View file

@ -75,9 +75,10 @@ It will automatically:
- Verify DNS configuration - Verify DNS configuration
- Install Certbot - Install Certbot
- Generate Let's Encrypt SSL certificates - Generate Let's Encrypt SSL certificates
- Update nginx configuration - Update the canonical public host/base URL settings in `.env`
- Validate the runtime config that `dograh-init` will render from `.env`
- Configure automatic certificate renewal - Configure automatic certificate renewal
- Restart Dograh services - Restart Dograh services through the validated startup wrapper
Once complete, your application will be available at `https://voice.yourcompany.com`. Once complete, your application will be available at `https://voice.yourcompany.com`.
@ -130,7 +131,7 @@ Replace `voice.yourcompany.com` with your actual domain name.
Certbot will: Certbot will:
1. Verify that you control the domain 1. Verify that you control the domain
2. Generate SSL certificates 2. Generate SSL certificates
3. Store them in `/etc/letsencrypt/archive/voice.yourcompany.com/` 3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/`
<Note> <Note>
You'll be prompted to enter an email address for renewal notifications and agree to the terms of service. You'll be prompted to enter an email address for renewal notifications and agree to the terms of service.
@ -142,44 +143,31 @@ Copy the generated certificates to the dograh certs directory:
```bash ```bash
cd dograh cd dograh
sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem certs/local.crt sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt
sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem certs/local.key sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key
sudo chmod 644 certs/local.crt certs/local.key sudo chmod 644 certs/local.crt certs/local.key
``` ```
### Update nginx Configuration ### Update Canonical Public URL Settings
Update the nginx configuration to use your domain name. Open the nginx configuration file: Update `.env` so the canonical remote settings point at your domain:
```bash ```bash
nano dograh/nginx.conf nano dograh/.env
``` ```
Update the `server_name` directive with your domain: ```bash
PUBLIC_HOST=voice.yourcompany.com
```nginx PUBLIC_BASE_URL=https://voice.yourcompany.com
server {
listen 443 ssl;
server_name voice.yourcompany.com;
ssl_certificate /etc/nginx/certs/local.crt;
ssl_certificate_key /etc/nginx/certs/local.key;
# ... rest of the configuration remains the same
}
``` ```
### Add environment variable
Replace `BACKEND_API_ENDPOINT` environment variable the `docker-compose.yaml` with your custom domain with the scheme.
### Start Dograh Services ### Start Dograh Services
Start Dograh with the updated configuration: Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts:
```bash ```bash
cd dograh cd dograh
sudo docker compose --profile remote up -d --pull always ./remote_up.sh
``` ```
### Access Your Application ### Access Your Application
@ -207,8 +195,8 @@ Add the following content (replace paths as needed):
```bash ```bash
#!/bin/bash #!/bin/bash
# Copy renewed certificates to dograh certs directory # Copy renewed certificates to dograh certs directory
cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem /home/ubuntu/dograh/certs/local.crt cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem /home/ubuntu/dograh/certs/local.crt
cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem /home/ubuntu/dograh/certs/local.key cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem /home/ubuntu/dograh/certs/local.key
chmod 644 /home/ubuntu/dograh/certs/local.crt /home/ubuntu/dograh/certs/local.key chmod 644 /home/ubuntu/dograh/certs/local.crt /home/ubuntu/dograh/certs/local.key
# Restart nginx to load new certificates # Restart nginx to load new certificates
@ -243,7 +231,7 @@ If Certbot fails to generate certificates:
If you see SSL errors after setup: If you see SSL errors after setup:
1. Verify the certificates were copied correctly: `ls -la dograh/certs/` 1. Verify the certificates were copied correctly: `ls -la dograh/certs/`
2. Check that `nginx.conf` points to `/etc/nginx/certs/local.crt` and `/etc/nginx/certs/local.key` 2. Run `./remote_up.sh --preflight-only` in `dograh/` to verify the `dograh-init` runtime render matches `.env`
3. Restart the nginx container: `sudo docker compose --profile remote restart nginx` 3. Restart the nginx container: `sudo docker compose --profile remote restart nginx`
### WebRTC Connection Issues ### WebRTC Connection Issues
@ -251,5 +239,4 @@ If you see SSL errors after setup:
If voice calls don't connect after domain setup: If voice calls don't connect after domain setup:
1. Ensure TCP/UDP ports 3478, 5349, and UDP 49152-49200 are still open 1. Ensure TCP/UDP ports 3478, 5349, and UDP 49152-49200 are still open
2. Update the `.env` file with your domain name if needed for TURN server configuration 2. Check that `PUBLIC_HOST` / `PUBLIC_BASE_URL` in `.env` match your domain, then re-run `./remote_up.sh`

View file

@ -66,7 +66,7 @@ The script will prompt you for:
- The host browsers should use to reach TURN (press Enter for `127.0.0.1`; use your LAN IP if testing from another device on the same network) - The host browsers should use to reach TURN (press Enter for `127.0.0.1`; use your LAN IP if testing from another device on the same network)
- A shared secret for the TURN server (press Enter to generate a random one) - A shared secret for the TURN server (press Enter to generate a random one)
It creates `docker-compose.yaml`, `turnserver.conf`, and a `.env` file with TURN credentials. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: It creates `docker-compose.yaml`, a `.env` file with TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services:
```bash ```bash
docker compose --profile local-turn up --pull always docker compose --profile local-turn up --pull always
@ -118,9 +118,10 @@ The script will prompt you for:
It will automatically: It will automatically:
- Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode) - Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode)
- Create and configure nginx.conf with your IP address - Download the validated remote deployment helper bundle
- Generate SSL certificates - Generate SSL certificates
- Create an environment file with TURN server configuration - Create an environment file with TURN server configuration
- Validate the runtime config that `dograh-init` will render from `.env`
- Write a `docker-compose.override.yaml` with build directives (build mode only) - Write a `docker-compose.override.yaml` with build directives (build mode only)
### Start the Application ### Start the Application
@ -134,11 +135,11 @@ After the setup script completes, start Dograh. The script prints the exact comm
<CodeGroup> <CodeGroup>
```bash Prebuilt mode ```bash Prebuilt mode
cd dograh cd dograh
sudo docker compose --profile remote up --pull always ./remote_up.sh
``` ```
```bash Build mode ```bash Build mode
cd dograh cd dograh
sudo docker compose --profile remote up -d --build ./remote_up.sh --build
``` ```
</CodeGroup> </CodeGroup>
@ -174,12 +175,14 @@ The setup script creates the following files in the `dograh/` directory:
|------|---------| |------|---------|
| `docker-compose.yaml` | Main Docker Compose configuration | | `docker-compose.yaml` | Main Docker Compose configuration |
| `docker-compose.override.yaml` | Build directives for `api` and `ui` (**build mode only**) | | `docker-compose.override.yaml` | Build directives for `api` and `ui` (**build mode only**) |
| `turnserver.conf` | Configuration for TURN server | | `remote_up.sh` | Validated startup wrapper for the remote stack |
| `nginx.conf` | nginx reverse proxy configuration with your IP | | `scripts/run_dograh_init.sh` | One-shot init renderer/validator used by Docker Compose |
| `scripts/lib/setup_common.sh` | Shared deployment helper library |
| `deploy/templates/` | nginx and coturn runtime config templates |
| `generate_certificate.sh` | Script to regenerate SSL certificates | | `generate_certificate.sh` | Script to regenerate SSL certificates |
| `certs/local.crt` | Self-signed SSL certificate | | `certs/local.crt` | Self-signed SSL certificate |
| `certs/local.key` | SSL private key | | `certs/local.key` | SSL private key |
| `.env` | Environment variables (TURN secret, JWT secret, FastAPI worker count) | | `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) |
### Building from source ### Building from source

View file

@ -8,7 +8,7 @@ By default, the Dograh API container runs a single uvicorn worker. For productio
This page covers how the multi-worker setup works, how to choose a worker count at install time, and how to change it on a running stack. This page covers how the multi-worker setup works, how to choose a worker count at install time, and how to change it on a running stack.
<Warning> <Warning>
Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and ship a different `setup_remote.sh` / `start_services_docker.sh` / `nginx.conf` layout — the steps below will not work on them. If your stack is older, [update first](/deployment/update) and then come back to this page. Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and a different remote deployment layout. If your stack is older, [update first](/deployment/update) and then come back to this page.
</Warning> </Warning>
## How it works ## How it works
@ -58,23 +58,11 @@ Press Enter for the default (`4`) or enter a different positive integer. Non-int
SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
``` ```
The script wires the value into two places: The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned.
- **`.env`** — sets `FASTAPI_WORKERS=N`, which `docker-compose.yaml` substitutes into the API container's environment.
- **`nginx.conf`** — generates an `upstream dograh_api` block with one `server api:800X` entry per worker.
Both must agree, which is why the script generates them together.
## Changing the worker count on a running stack ## Changing the worker count on a running stack
Once Dograh is running, increasing or decreasing the worker count is a two-file edit plus a restart. You'll touch: Once Dograh is running, increasing or decreasing the worker count is a one-file edit plus a restart. Change `.env`, then start through `./remote_up.sh` so `dograh-init` regenerates nginx runtime config before Docker starts the stack.
1. **`.env`** — controls how many uvicorn processes the API container spawns.
2. **`nginx.conf`** — controls which worker ports nginx forwards to.
<Warning>
Both files must stay in sync. If `.env` says `FASTAPI_WORKERS=8` but `nginx.conf` only lists 4 upstream servers, half your workers will be idle. If `nginx.conf` lists more upstreams than there are workers, those upstreams will throw connection errors and trip the `proxy_next_upstream` fallback.
</Warning>
### Steps ### Steps
@ -90,41 +78,21 @@ FASTAPI_WORKERS=4
FASTAPI_WORKERS=8 FASTAPI_WORKERS=8
``` ```
**2. Edit `nginx.conf`** and update the `upstream dograh_api` block so it has exactly one `server api:800X` line per worker, with ports starting at `8000`: **2. Recreate the stack through the validated wrapper.** The simplest path — brief downtime, no surprises:
```nginx
upstream dograh_api {
least_conn;
server api:8000 max_fails=3 fail_timeout=10s;
server api:8001 max_fails=3 fail_timeout=10s;
server api:8002 max_fails=3 fail_timeout=10s;
server api:8003 max_fails=3 fail_timeout=10s;
server api:8004 max_fails=3 fail_timeout=10s; # ← new
server api:8005 max_fails=3 fail_timeout=10s; # ← new
server api:8006 max_fails=3 fail_timeout=10s; # ← new
server api:8007 max_fails=3 fail_timeout=10s; # ← new
keepalive 32;
}
```
To **scale down**, remove the trailing `server` lines so the list matches the new `FASTAPI_WORKERS` value.
**3. Recreate the affected containers.** The simplest path — brief downtime, no surprises:
```bash ```bash
sudo docker compose --profile remote down ./remote_up.sh
sudo docker compose --profile remote up -d
``` ```
If you want to avoid downtime and your stack is healthy, you can recreate only the `api` and `nginx` containers: If you want to avoid downtime and your stack is healthy, you can recreate only the `api` and `nginx` containers:
```bash ```bash
sudo docker compose --profile remote up -d --force-recreate api nginx ./remote_up.sh -- api nginx
``` ```
`--force-recreate` ensures the api container picks up the new `FASTAPI_WORKERS` value and nginx re-reads the updated `nginx.conf` (which is mounted read-only from disk). `remote_up.sh` validates `.env`, runs the same `dograh-init` render that Compose will use at startup, runs `docker compose config -q`, and then starts the requested services.
**4. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead: **3. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead:
```bash ```bash
sudo docker compose --profile remote top api | grep uvicorn sudo docker compose --profile remote top api | grep uvicorn

View file

@ -37,8 +37,8 @@ Always update **`dograh-api`** and **`dograh-ui`** to the **same tag**. The two
- Asks for a target version (defaults to the latest release tag on GitHub). - Asks for a target version (defaults to the latest release tag on GitHub).
- Pulls `docker-compose.yaml` at that version and pins both `api` and `ui` images to it. - Pulls `docker-compose.yaml` at that version and pins both `api` and `ui` images to it.
- Regenerates `nginx.conf` and `turnserver.conf` from the upstream templates, so newer features (like [multi-worker scaling](/deployment/scaling)) are wired up correctly without manual editing. - Refreshes the remote helper bundle (`remote_up.sh` plus shared templates/helpers).
- Reads your existing `.env` and appends any new required keys with safe defaults — your `OSS_JWT_SECRET`, `TURN_SECRET`, and other values are never touched. - Synchronizes the canonical remote keys in `.env` and validates the runtime config that `dograh-init` will render from it.
- Backs up every file it changes with a `.bak.<timestamp>` suffix. - Backs up every file it changes with a `.bak.<timestamp>` suffix.
From your install directory: From your install directory:
@ -55,15 +55,14 @@ You'll be prompted for the target version, defaulting to the most recent release
TARGET_VERSION=1.28.0 DOGRAH_UPDATE_YES=1 bash update_remote.sh TARGET_VERSION=1.28.0 DOGRAH_UPDATE_YES=1 bash update_remote.sh
``` ```
After the script finishes, apply the update by recreating the stack: After the script finishes, apply the update through the validated startup wrapper:
```bash ```bash
sudo docker compose --profile remote down ./remote_up.sh
sudo docker compose --profile remote up -d --pull always
``` ```
<Note> <Note>
The script overwrites `docker-compose.yaml`, `nginx.conf`, and `turnserver.conf` from upstream templates. If you've made local edits to any of these (extra environment variables, custom ports, modified nginx routes), check the `.bak.<timestamp>` files after the update and re-apply your edits. The script overwrites `docker-compose.yaml` and the remote helper bundle (`remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`) from the shared upstream deployment bundle. If you've made local edits to any of these, check the `.bak.<timestamp>` files after the update and re-apply your edits.
</Note> </Note>
## Local deployment ## Local deployment
@ -100,11 +99,10 @@ curl http://localhost:8000/api/v1/health # local
```bash ```bash
cd dograh cd dograh
for f in docker-compose.yaml nginx.conf turnserver.conf .env; do for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do
[[ -f "$f.bak.<timestamp>" ]] && cp "$f.bak.<timestamp>" "$f" [[ -f "$f.bak.<timestamp>" ]] && cp "$f.bak.<timestamp>" "$f"
done done
sudo docker compose --profile remote down ./remote_up.sh
sudo docker compose --profile remote up -d
``` ```
Your Postgres data volume persists across `down`/`up` cycles, so agents and call history are preserved. Your Postgres data volume persists across `down`/`up` cycles, so agents and call history are preserved.
@ -138,6 +136,6 @@ sudo docker compose --profile remote up -d
If you update the `pipecat` submodule, you **must** run `git submodule update --init --recursive` before rebuilding, or the Docker build will not pick up `pipecat` changes. If you update the `pipecat` submodule, you **must** run `git submodule update --init --recursive` before rebuilding, or the Docker build will not pick up `pipecat` changes.
</Warning> </Warning>
If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `nginx.conf`, `turnserver.conf`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth. If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `deploy/templates/*`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth.
The same migration warning above applies: rolling back across a schema change can leave the DB in a state the older API can't read. The same migration warning above applies: rolling back across a schema change can leave the DB in a state the older API can't read.

72
remote_up.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_PATH="$SCRIPT_DIR/scripts/lib/setup_common.sh"
BOOTSTRAP_LIB=""
if [[ ! -f "$LIB_PATH" ]]; then
BOOTSTRAP_LIB="$(mktemp)"
curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh"
LIB_PATH="$BOOTSTRAP_LIB"
fi
cleanup() {
if [[ -n "$BOOTSTRAP_LIB" ]]; then
rm -f "$BOOTSTRAP_LIB"
fi
}
trap cleanup EXIT
# shellcheck disable=SC1090
. "$LIB_PATH"
DOGRAH_DEPLOY_PROJECT_DIR="$SCRIPT_DIR"
VALIDATE_ONLY=0
MODE="pull"
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--build)
MODE="build"
;;
--preflight-only|--validate-only)
VALIDATE_ONLY=1
;;
--)
shift
EXTRA_ARGS=("$@")
break
;;
*)
EXTRA_ARGS+=("$1")
;;
esac
shift
done
cd "$SCRIPT_DIR"
dograh_info "Running Dograh remote preflight..."
dograh_prepare_remote_install "$SCRIPT_DIR"
docker compose config -q
dograh_success "✓ dograh-init preflight validated"
if [[ "$VALIDATE_ONLY" == "1" ]]; then
exit 0
fi
if [[ $EUID -eq 0 ]] || ! command -v sudo >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
else
COMPOSE_CMD=(sudo docker compose)
fi
if [[ "$MODE" == "build" ]]; then
exec "${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate "${EXTRA_ARGS[@]}"
else
exec "${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate "${EXTRA_ARGS[@]}"
fi

55
scripts/AGENTS.md Normal file
View file

@ -0,0 +1,55 @@
# scripts/
## Bash ↔ PowerShell parity — keep them in sync
Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`.
Current pairs:
- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files)
- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install
- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait)
- `stop_services.{sh,ps1}`
- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers
Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors):
- `start_services.sh` — VM production
- `start_services_docker.sh` — Docker image CMD
- `rolling_update.sh` — zero-downtime VM redeploy
- `setup_local.sh` / `setup_remote.sh` — OSS Docker-compose setup
- `format.sh` / `lint.sh` / `pre_commit.sh`
- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py`
## Deployment Memory — current OSS Docker state
This directory now has a shared deployment model for OSS Docker installs. If you touch any of the scripts below, assume they are coupled and review them together:
- `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`.
- `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`.
- `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`.
- `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` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config.
- `setup_local.sh` must remain safe under unset env vars; use `${VAR:-}` guards for optional inputs like `ENABLE_COTURN`, `TURN_HOST`, `TURN_SECRET`, `DOGRAH_SKIP_DOWNLOAD`.
- `run_dograh_init.sh` is an executable entrypoint, not a library. Compose runs it directly. If it ever gets refactored, keep the distinction between sourced helper logic (`lib/`) and executable entrypoints.
- `dograh_prepare_remote_install` in `setup_common.sh` currently does three things: sync canonical `.env` keys, reject legacy compose layouts that do not use `dograh-init`, and preflight the init render in a temp directory.
- `dograh_uses_init_compose_layout` / `dograh_require_init_compose_layout` are the guardrails for old installs. If a remote install still bind-mounts host `nginx.conf` / `turnserver.conf`, the intended fix path is `./update_remote.sh`.
- Templates live under `deploy/templates/`. `nginx.remote.conf.template` contains the static shape and `dograh_render_remote_nginx_conf` expands the multi-worker upstream block dynamically. `turnserver.remote.conf.template` is also rendered from env.
- If you rename/move any of these deployment files, update all of: bootstrap curl URLs inside scripts, helper-bundle download paths in `setup_common.sh`, backup lists in `update_remote.sh`, docs under `docs/deployment/`, and any existence checks in `setup_local.sh` / `setup_custom_domain.sh`.
## The three "start" scripts — pick the right one
| Script | Where it runs | Key behavior |
| -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. |
| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. |
| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. |
If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file.

View file

@ -1,30 +1 @@
# scripts/ @AGENTS.md
## Bash ↔ PowerShell parity — keep them in sync
Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`.
Current pairs:
- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files)
- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install
- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait)
- `stop_services.{sh,ps1}`
- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers
Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors):
- `start_services.sh` — VM production
- `start_services_docker.sh` — Docker image CMD
- `rolling_update.sh` — zero-downtime VM redeploy
- `setup_local.sh` / `setup_remote.sh` — OSS Docker-compose setup
- `format.sh` / `lint.sh` / `pre_commit.sh`
- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py`
## The three "start" scripts — pick the right one
| Script | Where it runs | Key behavior |
| ----------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. |
| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. |
| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. |
If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file.

425
scripts/lib/setup_common.sh Normal file
View file

@ -0,0 +1,425 @@
#!/usr/bin/env bash
DOGRAH_DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOGRAH_DEPLOY_REPO_ROOT="$(cd "$DOGRAH_DEPLOY_LIB_DIR/../.." 2>/dev/null && pwd || true)"
: "${RED:=\033[0;31m}"
: "${GREEN:=\033[0;32m}"
: "${YELLOW:=\033[1;33m}"
: "${BLUE:=\033[0;34m}"
: "${NC:=\033[0m}"
dograh_info() {
echo -e "${BLUE}$*${NC}"
}
dograh_success() {
echo -e "${GREEN}$*${NC}"
}
dograh_warn() {
echo -e "${YELLOW}$*${NC}"
}
dograh_fail() {
echo -e "${RED}Error: $*${NC}" >&2
exit 1
}
dograh_project_dir() {
if [[ -n "${DOGRAH_DEPLOY_PROJECT_DIR:-}" ]]; then
printf '%s\n' "$DOGRAH_DEPLOY_PROJECT_DIR"
else
pwd
fi
}
dograh_template_path() {
local template_name=$1
local candidate=""
local project_dir
project_dir="$(dograh_project_dir)"
for candidate in \
"$project_dir/deploy/templates/$template_name" \
"$DOGRAH_DEPLOY_REPO_ROOT/deploy/templates/$template_name"
do
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
dograh_fail "Template '$template_name' not found"
}
dograh_init_script_path() {
local candidate=""
local project_dir
project_dir="$(dograh_project_dir)"
for candidate in \
"$project_dir/scripts/run_dograh_init.sh" \
"$DOGRAH_DEPLOY_REPO_ROOT/scripts/run_dograh_init.sh"
do
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
dograh_fail "run_dograh_init.sh not found"
}
dograh_load_env_file() {
local env_file=${1:-.env}
[[ -f "$env_file" ]] || dograh_fail "$env_file not found"
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
}
dograh_host_from_url() {
local url=$1
url="${url#https://}"
url="${url#http://}"
url="${url%%/*}"
printf '%s\n' "$url"
}
dograh_is_ipv4() {
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
}
dograh_infer_server_ip() {
local project_dir=${1:-$(dograh_project_dir)}
local turn_conf="$project_dir/turnserver.conf"
local ip=""
if [[ -n "${SERVER_IP:-}" ]]; then
printf '%s\n' "$SERVER_IP"
return 0
fi
if [[ -f "$turn_conf" ]]; then
ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)"
if [[ -n "$ip" ]]; then
printf '%s\n' "$ip"
return 0
fi
fi
if [[ -n "${TURN_HOST:-}" ]] && dograh_is_ipv4 "$TURN_HOST"; then
printf '%s\n' "$TURN_HOST"
return 0
fi
if [[ -n "${PUBLIC_HOST:-}" ]] && dograh_is_ipv4 "$PUBLIC_HOST"; then
printf '%s\n' "$PUBLIC_HOST"
return 0
fi
return 1
}
dograh_infer_public_base_url() {
if [[ -n "${PUBLIC_BASE_URL:-}" ]]; then
printf '%s\n' "${PUBLIC_BASE_URL%/}"
return 0
fi
if [[ -n "${BACKEND_API_ENDPOINT:-}" ]]; then
printf '%s\n' "${BACKEND_API_ENDPOINT%/}"
return 0
fi
if [[ -n "${PUBLIC_HOST:-}" ]]; then
printf 'https://%s\n' "$PUBLIC_HOST"
return 0
fi
if [[ -n "${SERVER_IP:-}" ]]; then
printf 'https://%s\n' "$SERVER_IP"
return 0
fi
return 1
}
dograh_infer_public_host() {
local public_base_url=""
if [[ -n "${PUBLIC_HOST:-}" ]]; then
printf '%s\n' "$PUBLIC_HOST"
return 0
fi
public_base_url="$(dograh_infer_public_base_url 2>/dev/null || true)"
if [[ -n "$public_base_url" ]]; then
dograh_host_from_url "$public_base_url"
return 0
fi
if [[ -n "${TURN_HOST:-}" ]]; then
printf '%s\n' "$TURN_HOST"
return 0
fi
return 1
}
dograh_set_env_key() {
local env_file=$1
local key=$2
local value=$3
local tmp_file="${env_file}.tmp.$$"
awk -v key="$key" -v value="$value" '
BEGIN { updated = 0 }
$0 ~ "^" key "=" {
print key "=" value
updated = 1
next
}
{ print }
END {
if (!updated) {
print key "=" value
}
}
' "$env_file" > "$tmp_file"
mv "$tmp_file" "$env_file"
}
dograh_delete_env_key() {
local env_file=$1
local key=$2
local tmp_file="${env_file}.tmp.$$"
awk -v key="$key" '$0 !~ "^" key "=" { print }' "$env_file" > "$tmp_file"
mv "$tmp_file" "$env_file"
}
dograh_sync_remote_env_file() {
local env_file=${1:-.env}
local project_dir
local public_base_url=""
local public_host=""
local server_ip=""
project_dir="$(cd "$(dirname "$env_file")" && pwd)"
dograh_load_env_file "$env_file"
public_base_url="$(dograh_infer_public_base_url)" || dograh_fail "Could not determine PUBLIC_BASE_URL"
public_base_url="${public_base_url%/}"
public_host="$(dograh_infer_public_host)" || dograh_fail "Could not determine PUBLIC_HOST"
server_ip="$(dograh_infer_server_ip "$project_dir")" || dograh_fail "Could not determine SERVER_IP"
[[ "$public_base_url" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://"
dograh_is_ipv4 "$server_ip" || dograh_fail "SERVER_IP must be an IPv4 address (got: $server_ip)"
dograh_set_env_key "$env_file" ENVIRONMENT "${ENVIRONMENT:-production}"
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"
}
dograh_validate_remote_runtime_env() {
[[ "${FASTAPI_WORKERS:-}" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer"
[[ -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"
}
dograh_uses_init_compose_layout() {
local project_dir=${1:-$(dograh_project_dir)}
local compose_file="$project_dir/docker-compose.yaml"
[[ -f "$compose_file" ]] || return 1
grep -q "dograh-init:" "$compose_file" \
&& grep -q "nginx-generated:/etc/nginx/conf.d:ro" "$compose_file" \
&& grep -q "coturn-generated:/etc/coturn:ro" "$compose_file"
}
dograh_require_init_compose_layout() {
local project_dir=${1:-$(dograh_project_dir)}
if ! dograh_uses_init_compose_layout "$project_dir"; then
dograh_fail "This install uses the legacy remote compose layout. Run ./update_remote.sh first so Docker uses dograh-init generated config."
fi
}
dograh_render_remote_nginx_conf() {
local project_dir=${1:-$(dograh_project_dir)}
local destination=${2:-"$project_dir/nginx.conf"}
local template=""
local tmp_upstream=""
template="$(dograh_template_path "nginx.remote.conf.template")"
tmp_upstream="$(mktemp)"
{
echo "# Backend API workers - one uvicorn process per port, balanced by least_conn."
echo "# Auto-generated by Dograh remote config renderer. Do not edit manually."
echo "upstream dograh_api {"
echo " least_conn;"
for ((i=0; i<FASTAPI_WORKERS; i++)); do
printf ' server api:%d max_fails=3 fail_timeout=10s;\n' "$((8000 + i))"
done
echo " keepalive 32;"
echo "}"
} > "$tmp_upstream"
awk -v public_host="$PUBLIC_HOST" -v upstream_file="$tmp_upstream" '
BEGIN {
while ((getline line < upstream_file) > 0) {
upstream = upstream line ORS
}
close(upstream_file)
}
{
gsub(/__DOGRAH_PUBLIC_HOST__/, public_host)
if ($0 == "__DOGRAH_UPSTREAM_BLOCK__") {
printf "%s", upstream
} else {
print
}
}
' "$template" > "$destination"
rm -f "$tmp_upstream"
}
dograh_render_remote_turn_conf() {
local project_dir=${1:-$(dograh_project_dir)}
local destination=${2:-"$project_dir/turnserver.conf"}
local template=""
local external_ip="${TURN_EXTERNAL_IP:-${SERVER_IP:-}}"
template="$(dograh_template_path "turnserver.remote.conf.template")"
[[ -n "$external_ip" ]] || dograh_fail "TURN external IP/host is missing"
awk \
-v external_ip="$external_ip" \
-v turn_secret="$TURN_SECRET" \
'
{
gsub(/__DOGRAH_TURN_EXTERNAL_IP__/, external_ip)
gsub(/__DOGRAH_TURN_SECRET__/, turn_secret)
print
}
' "$template" > "$destination"
}
dograh_preflight_remote_init_render() {
local project_dir=${1:-$(dograh_project_dir)}
local env_file="$project_dir/.env"
local cert_dir="$project_dir/certs"
local init_script=""
local tmp_root=""
local nginx_conf=""
local turn_conf=""
local nginx_workers=0
local rendered_secret=""
local rendered_ip=""
local rendered_server_name=""
dograh_load_env_file "$env_file"
dograh_validate_remote_runtime_env
[[ -f "$cert_dir/local.crt" ]] || dograh_fail "certs/local.crt not found"
[[ -f "$cert_dir/local.key" ]] || dograh_fail "certs/local.key not found"
init_script="$(dograh_init_script_path)"
tmp_root="$(mktemp -d)"
nginx_conf="$tmp_root/nginx/default.conf"
turn_conf="$tmp_root/coturn/turnserver.conf"
(
export ENVIRONMENT SERVER_IP PUBLIC_HOST PUBLIC_BASE_URL BACKEND_API_ENDPOINT MINIO_PUBLIC_ENDPOINT TURN_HOST TURN_SECRET FASTAPI_WORKERS
export DOGRAH_INIT_WORKSPACE_DIR="$project_dir"
export DOGRAH_INIT_OUTPUT_ROOT="$tmp_root"
export DOGRAH_INIT_CERTS_DIR="$cert_dir"
bash "$init_script" >/dev/null
)
[[ -f "$nginx_conf" ]] || dograh_fail "dograh-init did not render nginx config"
[[ -f "$turn_conf" ]] || dograh_fail "dograh-init did not render coturn config"
nginx_workers=$(awk '/^[[:space:]]*server api:[0-9]+/ { count += 1 } END { print count + 0 }' "$nginx_conf")
[[ "$nginx_workers" -eq "$FASTAPI_WORKERS" ]] || dograh_fail "FASTAPI_WORKERS=$FASTAPI_WORKERS but nginx.conf has $nginx_workers upstream servers"
rendered_server_name="$(awk '/^[[:space:]]*server_name / { print $2; exit }' "$nginx_conf" | sed 's/;$//')"
[[ "$rendered_server_name" == "$PUBLIC_HOST" ]] || dograh_fail "nginx.conf server_name ($rendered_server_name) does not match PUBLIC_HOST ($PUBLIC_HOST)"
rendered_secret="$(sed -n 's/^static-auth-secret=//p' "$turn_conf" | head -1)"
[[ "$rendered_secret" == "$TURN_SECRET" ]] || dograh_fail "TURN_SECRET in .env does not match turnserver.conf"
rendered_ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)"
[[ "$rendered_ip" == "$SERVER_IP" ]] || dograh_fail "SERVER_IP in .env does not match turnserver.conf"
rm -rf "$tmp_root"
}
dograh_prepare_remote_install() {
local project_dir=${1:-$(dograh_project_dir)}
local env_file="$project_dir/.env"
dograh_sync_remote_env_file "$env_file"
dograh_require_init_compose_layout "$project_dir"
dograh_preflight_remote_init_render "$project_dir"
}
dograh_download_bundle_file_for_ref() {
local destination=$1
local remote_path=$2
local ref=${3:-main}
local raw_base="https://raw.githubusercontent.com/dograh-hq/dograh/$ref"
local fallback_base="https://raw.githubusercontent.com/dograh-hq/dograh/main"
if ! curl -fsSL -o "$destination" "$raw_base/$remote_path"; then
dograh_warn "Warning: '$remote_path' not found at '$ref' - falling back to main"
curl -fsSL -o "$destination" "$fallback_base/$remote_path"
fi
}
dograh_download_init_support_bundle() {
local project_dir=$1
local ref=${2:-main}
mkdir -p "$project_dir/scripts/lib" "$project_dir/deploy/templates"
mkdir -p "$project_dir/scripts"
dograh_download_bundle_file_for_ref "$project_dir/scripts/lib/setup_common.sh" "scripts/lib/setup_common.sh" "$ref"
dograh_download_bundle_file_for_ref "$project_dir/scripts/run_dograh_init.sh" "scripts/run_dograh_init.sh" "$ref"
chmod +x "$project_dir/scripts/run_dograh_init.sh"
dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/nginx.remote.conf.template" "deploy/templates/nginx.remote.conf.template" "$ref"
dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/turnserver.remote.conf.template" "deploy/templates/turnserver.remote.conf.template" "$ref"
}
dograh_download_remote_support_bundle() {
local project_dir=$1
local ref=${2:-main}
dograh_download_bundle_file_for_ref "$project_dir/remote_up.sh" "remote_up.sh" "$ref"
chmod +x "$project_dir/remote_up.sh"
dograh_download_init_support_bundle "$project_dir" "$ref"
}

38
scripts/run_dograh_init.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_DIR="${DOGRAH_INIT_WORKSPACE_DIR:-/workspace}"
OUTPUT_ROOT="${DOGRAH_INIT_OUTPUT_ROOT:-/generated}"
NGINX_OUTPUT_DIR="$OUTPUT_ROOT/nginx"
COTURN_OUTPUT_DIR="$OUTPUT_ROOT/coturn"
CERTS_DIR="${DOGRAH_INIT_CERTS_DIR:-/certs}"
# shellcheck disable=SC1091
. "$SCRIPT_DIR/lib/setup_common.sh"
DOGRAH_DEPLOY_PROJECT_DIR="$WORKSPACE_DIR"
mkdir -p "$NGINX_OUTPUT_DIR" "$COTURN_OUTPUT_DIR"
if [[ "${ENVIRONMENT:-local}" == "production" ]]; then
dograh_validate_remote_runtime_env
[[ -f "$CERTS_DIR/local.crt" ]] || dograh_fail "certs/local.crt not found"
[[ -f "$CERTS_DIR/local.key" ]] || dograh_fail "certs/local.key not found"
export TURN_EXTERNAL_IP="$SERVER_IP"
dograh_render_remote_nginx_conf "$WORKSPACE_DIR" "$NGINX_OUTPUT_DIR/default.conf"
dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf"
dograh_success "✓ dograh-init rendered remote nginx and coturn config"
exit 0
fi
if [[ -n "${TURN_SECRET:-}" && -n "${TURN_HOST:-}" ]]; then
export TURN_EXTERNAL_IP="$TURN_HOST"
dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf"
dograh_success "✓ dograh-init rendered local TURN config"
exit 0
fi
dograh_success "✓ dograh-init no-op for current profile"

View file

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -8,6 +8,26 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh"
BOOTSTRAP_LIB=""
if [[ ! -f "$LIB_PATH" ]]; then
BOOTSTRAP_LIB="$(mktemp)"
curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh"
LIB_PATH="$BOOTSTRAP_LIB"
fi
cleanup() {
if [[ -n "$BOOTSTRAP_LIB" ]]; then
rm -f "$BOOTSTRAP_LIB"
fi
}
trap cleanup EXIT
# shellcheck disable=SC1090
. "$LIB_PATH"
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗" echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Custom Domain Setup ║" echo "║ Dograh Custom Domain Setup ║"
@ -15,13 +35,10 @@ echo "║ Automated Let's Encrypt SSL certificate setup ║"
echo "╚══════════════════════════════════════════════════════════════╝" echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}" echo -e "${NC}"
# Check if running as root or with sudo
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo -e "${RED}Error: This script must be run as root or with sudo${NC}" dograh_fail "This script must be run as root or with sudo"
exit 1
fi fi
# Check if dograh directory exists
if [[ ! -d "dograh" ]]; then if [[ ! -d "dograh" ]]; then
echo -e "${RED}Error: 'dograh' directory not found.${NC}" echo -e "${RED}Error: 'dograh' directory not found.${NC}"
echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}" echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}"
@ -30,29 +47,17 @@ if [[ ! -d "dograh" ]]; then
exit 1 exit 1
fi fi
# Get the domain name
echo -e "${YELLOW}Enter your domain name (e.g., voice.yourcompany.com):${NC}" echo -e "${YELLOW}Enter your domain name (e.g., voice.yourcompany.com):${NC}"
read -p "> " DOMAIN_NAME read -p "> " DOMAIN_NAME
[[ -n "$DOMAIN_NAME" ]] || dograh_fail "Domain name cannot be empty"
if [[ -z "$DOMAIN_NAME" ]]; then
echo -e "${RED}Error: Domain name cannot be empty${NC}"
exit 1
fi
# Basic domain validation
if ! [[ "$DOMAIN_NAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$ ]]; then if ! [[ "$DOMAIN_NAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$ ]]; then
echo -e "${RED}Error: Invalid domain name format${NC}" dograh_fail "Invalid domain name format"
exit 1
fi fi
# Get email for Let's Encrypt notifications
echo -e "${YELLOW}Enter your email address for SSL certificate notifications:${NC}" echo -e "${YELLOW}Enter your email address for SSL certificate notifications:${NC}"
read -p "> " EMAIL_ADDRESS read -p "> " EMAIL_ADDRESS
[[ -n "$EMAIL_ADDRESS" ]] || dograh_fail "Email address cannot be empty (required by Let's Encrypt)"
if [[ -z "$EMAIL_ADDRESS" ]]; then
echo -e "${RED}Error: Email address cannot be empty (required by Let's Encrypt)${NC}"
exit 1
fi
echo "" echo ""
echo -e "${GREEN}Configuration:${NC}" echo -e "${GREEN}Configuration:${NC}"
@ -60,13 +65,12 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}"
echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}" echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}"
echo "" echo ""
# Verify DNS is pointing to this server
echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}" echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}"
SERVER_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")"
RESOLVED_IP=$(dig +short "$DOMAIN_NAME" | tail -1) RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)"
if [[ -z "$SERVER_IP" ]]; then if [[ -z "$SERVER_IP" ]]; then
echo -e "${YELLOW}Warning: Could not detect server's public IP${NC}" dograh_warn "Warning: Could not detect server's public IP"
elif [[ "$RESOLVED_IP" != "$SERVER_IP" ]]; then elif [[ "$RESOLVED_IP" != "$SERVER_IP" ]]; then
echo -e "${YELLOW}Warning: Domain '$DOMAIN_NAME' resolves to '$RESOLVED_IP' but this server's IP is '$SERVER_IP'${NC}" echo -e "${YELLOW}Warning: Domain '$DOMAIN_NAME' resolves to '$RESOLVED_IP' but this server's IP is '$SERVER_IP'${NC}"
echo -e "${YELLOW}Make sure your DNS A record points to this server before proceeding.${NC}" echo -e "${YELLOW}Make sure your DNS A record points to this server before proceeding.${NC}"
@ -80,7 +84,6 @@ else
echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}" echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}"
fi fi
# Detect package manager and install certbot
echo -e "${BLUE}[2/7] Installing Certbot...${NC}" echo -e "${BLUE}[2/7] Installing Certbot...${NC}"
if command -v apt-get &> /dev/null; then if command -v apt-get &> /dev/null; then
apt-get update -qq apt-get update -qq
@ -90,14 +93,20 @@ elif command -v yum &> /dev/null; then
elif command -v dnf &> /dev/null; then elif command -v dnf &> /dev/null; then
dnf install -y -q certbot dnf install -y -q certbot
else else
echo -e "${RED}Error: Could not detect package manager. Please install certbot manually.${NC}" dograh_fail "Could not detect package manager. Please install certbot manually."
exit 1
fi fi
echo -e "${GREEN}✓ Certbot installed${NC}" echo -e "${GREEN}✓ Certbot installed${NC}"
# Stop Dograh services to free port 80
echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}" echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}"
cd dograh cd dograh
DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then
dograh_download_remote_support_bundle "$(pwd)" "main"
fi
dograh_require_init_compose_layout "$(pwd)"
if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then
docker compose --profile remote down docker compose --profile remote down
echo -e "${GREEN}✓ Dograh services stopped${NC}" echo -e "${GREEN}✓ Dograh services stopped${NC}"
@ -105,7 +114,6 @@ else
echo -e "${YELLOW}⚠ No running services found${NC}" echo -e "${YELLOW}⚠ No running services found${NC}"
fi fi
# Generate SSL certificate
echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}" echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}"
CERTBOT_OUTPUT=$(certbot certonly --standalone \ CERTBOT_OUTPUT=$(certbot certonly --standalone \
--non-interactive \ --non-interactive \
@ -115,7 +123,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \
echo -e "${RED}✗ Certificate generation failed${NC}" echo -e "${RED}✗ Certificate generation failed${NC}"
echo "" echo ""
# Check for common errors and provide helpful hints
if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}" echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}"
@ -123,14 +130,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \
echo "" echo ""
echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership." echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership."
echo "" echo ""
echo -e "${BLUE}If using AWS EC2:${NC}"
echo " 1. Go to AWS Console → EC2 → Security Groups"
echo " 2. Find the security group for your instance"
echo " 3. Add inbound rule: HTTP | TCP | Port 80 | 0.0.0.0/0"
echo ""
echo -e "${BLUE}If using another cloud provider:${NC}"
echo " • Ensure port 80 (TCP) is open for inbound traffic from all sources"
echo ""
elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}" echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}"
@ -160,161 +159,60 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \
} }
echo -e "${GREEN}✓ SSL certificate generated${NC}" echo -e "${GREEN}✓ SSL certificate generated${NC}"
# Verify and display certificate location
CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME" CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME"
echo "" echo ""
echo -e "${BLUE}Certificate location:${NC}" echo -e "${BLUE}Certificate location:${NC}"
echo -e " ${CERT_PATH}/" echo -e " ${CERT_PATH}/"
if [[ -f "$CERT_PATH/fullchain.pem" ]]; then [[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}${NC} fullchain.pem exists" || echo -e " ${RED}${NC} fullchain.pem NOT FOUND"
echo -e " ${GREEN}${NC} fullchain.pem exists" [[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}${NC} privkey.pem exists" || echo -e " ${RED}${NC} privkey.pem NOT FOUND"
else
echo -e " ${RED}${NC} fullchain.pem NOT FOUND"
fi
if [[ -f "$CERT_PATH/privkey.pem" ]]; then
echo -e " ${GREEN}${NC} privkey.pem exists"
else
echo -e " ${RED}${NC} privkey.pem NOT FOUND"
fi
echo "" echo ""
# Copy certificates to dograh/certs directory mkdir -p certs
cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem certs/local.crt cp "$CERT_PATH/fullchain.pem" certs/local.crt
cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem certs/local.key cp "$CERT_PATH/privkey.pem" certs/local.key
chmod 644 certs/local.crt certs/local.key chmod 644 certs/local.crt certs/local.key
echo -e "${GREEN}${NC} Certificates copied to certs/ directory" echo -e "${GREEN}${NC} Certificates copied to certs/ directory"
echo "" echo ""
# Update nginx.conf echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}"
echo -e "${BLUE}[5/7] Updating nginx configuration...${NC}" dograh_load_env_file .env
cat > nginx.conf << NGINX_EOF
server {
listen 80;
server_name $DOMAIN_NAME;
# Redirect all HTTP to HTTPS if [[ -z "${SERVER_IP:-}" ]]; then
return 301 https://\$host\$request_uri; SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)"
}
server {
listen 443 ssl;
server_name $DOMAIN_NAME;
ssl_certificate /etc/nginx/certs/local.crt;
ssl_certificate_key /etc/nginx/certs/local.key;
# TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# Backend API and WebSockets — bypass the UI, go straight to api:8000
location /api/v1/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Long-lived WebSockets (audio streaming, signaling)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Don't buffer streamed responses
proxy_buffering off;
client_max_body_size 100M;
}
location / {
proxy_pass http://ui:3010;
proxy_http_version 1.1;
# Important for WebSockets / hot reload etc.
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Rewrite localhost MinIO URLs in API responses to use current domain
sub_filter 'http://localhost:9000/voice-audio/' 'https://\$host/voice-audio/';
sub_filter_once off;
sub_filter_types application/json text/html;
}
location /voice-audio/ {
proxy_pass http://minio:9000/voice-audio/;
proxy_http_version 1.1;
# Headers for file downloads from MinIO
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Allow large file downloads
proxy_buffering off;
client_max_body_size 100M;
}
}
NGINX_EOF
echo -e "${GREEN}✓ nginx.conf updated${NC}"
# Update .env file with domain name
echo -e "${BLUE}[6/8] Updating environment variables...${NC}"
if [[ -f ".env" ]]; then
# Update BACKEND_API_ENDPOINT to use domain (public URL the backend advertises)
sed -i.bak "s|^BACKEND_API_ENDPOINT=.*|BACKEND_API_ENDPOINT=https://$DOMAIN_NAME|" .env
# Drop any stale BACKEND_URL override — the ui container should use the
# internal Docker URL (http://api:8000) from docker-compose defaults.
sed -i.bak "/^BACKEND_URL=/d" .env
sed -i.bak "/^# Backend URL for UI$/d" .env
# Update TURN_HOST to use domain
sed -i.bak "s|^TURN_HOST=.*|TURN_HOST=$DOMAIN_NAME|" .env
# Update MINIO_PUBLIC_ENDPOINT to use domain (browsers fetch /voice-audio/* here)
if grep -q "^MINIO_PUBLIC_ENDPOINT=" .env; then
sed -i.bak "s|^MINIO_PUBLIC_ENDPOINT=.*|MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME|" .env
else
echo "MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME" >> .env
fi
rm -f .env.bak
echo -e "${GREEN}✓ .env updated with domain name${NC}"
else
echo -e "${YELLOW}⚠ .env file not found - skipping environment update${NC}"
fi fi
# Setup auto-renewal [[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install"
echo -e "${BLUE}[7/8] Setting up automatic certificate renewal...${NC}"
DOGRAH_PATH=$(pwd) 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
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)"
# Create renewal hook script that copies new certificates and restarts nginx
cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF
#!/bin/bash #!/bin/bash
# Copy renewed certificates to dograh certs directory cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt
cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem $DOGRAH_PATH/certs/local.crt cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key
cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem $DOGRAH_PATH/certs/local.key
chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key
# Restart nginx to load new certificates
cd $DOGRAH_PATH cd $DOGRAH_PATH
docker compose --profile remote restart nginx 2>/dev/null || true docker compose --profile remote restart nginx 2>/dev/null || true
HOOK_EOF HOOK_EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh
# Test renewal if certbot renew --dry-run --quiet; then
certbot renew --dry-run --quiet && echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" || echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}"
else
echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}"
fi
# Start Dograh services
echo "" echo ""
echo -e "${BLUE}[8/8] Starting Dograh services...${NC}" echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}"
docker compose --profile remote up -d --pull always ./remote_up.sh
echo "" echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
@ -331,8 +229,7 @@ echo -e " Private Key: $DOGRAH_PATH/certs/local.key"
echo -e " Auto-renewal: Enabled (certificates renew automatically)" echo -e " Auto-renewal: Enabled (certificates renew automatically)"
echo "" echo ""
echo -e "${YELLOW}Files modified:${NC}" echo -e "${YELLOW}Files modified:${NC}"
echo " - dograh/nginx.conf (updated with domain name)" echo " - dograh/.env (canonical public host/base URL updated)"
echo " - dograh/.env (BACKEND_API_ENDPOINT and TURN_HOST updated)"
echo " - dograh/certs/local.crt (SSL certificate)" echo " - dograh/certs/local.crt (SSL certificate)"
echo " - dograh/certs/local.key (SSL private key)" echo " - dograh/certs/local.key (SSL private key)"
echo " - /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh (renewal hook)" echo " - /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh (renewal hook)"

View file

@ -8,6 +8,26 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh"
BOOTSTRAP_LIB=""
if [[ ! -f "$LIB_PATH" ]]; then
BOOTSTRAP_LIB="$(mktemp)"
curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh"
LIB_PATH="$BOOTSTRAP_LIB"
fi
cleanup() {
if [[ -n "$BOOTSTRAP_LIB" ]]; then
rm -f "$BOOTSTRAP_LIB"
fi
}
trap cleanup EXIT
# shellcheck disable=SC1090
. "$LIB_PATH"
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗" echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Local Setup ║" echo "║ Dograh Local Setup ║"
@ -16,7 +36,7 @@ echo "╚═══════════════════════
echo -e "${NC}" echo -e "${NC}"
# Ask whether to enable coturn (skip prompt if ENABLE_COTURN is already set) # Ask whether to enable coturn (skip prompt if ENABLE_COTURN is already set)
if [[ -z "$ENABLE_COTURN" ]]; then if [[ -z "${ENABLE_COTURN:-}" ]]; then
echo -e "${YELLOW}Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:${NC}" echo -e "${YELLOW}Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:${NC}"
read -p "> " ENABLE_COTURN_INPUT read -p "> " ENABLE_COTURN_INPUT
if [[ "$ENABLE_COTURN_INPUT" =~ ^[Yy] ]]; then if [[ "$ENABLE_COTURN_INPUT" =~ ^[Yy] ]]; then
@ -26,7 +46,7 @@ if [[ -z "$ENABLE_COTURN" ]]; then
fi fi
fi fi
if [[ "$ENABLE_COTURN" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
# Pick a TURN_HOST that's reachable from BOTH the browser (running on the # Pick a TURN_HOST that's reachable from BOTH the browser (running on the
# host) and the API container (running in docker). 127.0.0.1 is tempting # host) and the API container (running in docker). 127.0.0.1 is tempting
# but doesn't work for the api container — its own loopback isn't where # but doesn't work for the api container — its own loopback isn't where
@ -54,7 +74,7 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then
DEFAULT_TURN_HOST="${DEFAULT_TURN_HOST:-127.0.0.1}" DEFAULT_TURN_HOST="${DEFAULT_TURN_HOST:-127.0.0.1}"
# Get the host browsers/peers will use to reach the TURN server # Get the host browsers/peers will use to reach the TURN server
if [[ -z "$TURN_HOST" ]]; then if [[ -z "${TURN_HOST:-}" ]]; then
echo -e "${YELLOW}Enter the host browsers AND the API container will use to reach TURN${NC}" echo -e "${YELLOW}Enter the host browsers AND the API container will use to reach TURN${NC}"
echo -e "${YELLOW}(press Enter for ${DEFAULT_TURN_HOST}):${NC}" echo -e "${YELLOW}(press Enter for ${DEFAULT_TURN_HOST}):${NC}"
read -p "> " TURN_HOST read -p "> " TURN_HOST
@ -68,13 +88,13 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then
fi fi
# Get the TURN secret (skip prompt if TURN_SECRET is already set) # Get the TURN secret (skip prompt if TURN_SECRET is already set)
if [[ -z "$TURN_SECRET" ]]; then if [[ -z "${TURN_SECRET:-}" ]]; then
echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}"
read -sp "> " TURN_SECRET read -sp "> " TURN_SECRET
echo "" echo ""
fi fi
if [[ -z "$TURN_SECRET" ]]; then if [[ -z "${TURN_SECRET:-}" ]]; then
TURN_SECRET=$(openssl rand -hex 32) TURN_SECRET=$(openssl rand -hex 32)
echo -e "${BLUE}Generated random TURN secret${NC}" echo -e "${BLUE}Generated random TURN secret${NC}"
fi fi
@ -88,8 +108,8 @@ REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}"
echo "" echo ""
echo -e "${GREEN}Configuration:${NC}" echo -e "${GREEN}Configuration:${NC}"
echo -e " Coturn: ${BLUE}$ENABLE_COTURN${NC}" echo -e " Coturn: ${BLUE}${ENABLE_COTURN:-false}${NC}"
if [[ "$ENABLE_COTURN" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e " TURN Host: ${BLUE}$TURN_HOST${NC}" echo -e " TURN Host: ${BLUE}$TURN_HOST${NC}"
echo -e " TURN Secret: ${BLUE}********${NC}" echo -e " TURN Secret: ${BLUE}********${NC}"
fi fi
@ -99,52 +119,26 @@ echo ""
# Download compose file (skip when DOGRAH_SKIP_DOWNLOAD=1 — e.g. local repo testing). # Download compose file (skip when DOGRAH_SKIP_DOWNLOAD=1 — e.g. local repo testing).
TOTAL_STEPS=2 TOTAL_STEPS=2
if [[ "$ENABLE_COTURN" == "true" ]]; then
TOTAL_STEPS=3
fi
if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then
echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}" if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml and TURN helper bundle...${NC}"
else
echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}"
fi
curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml
echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
dograh_download_init_support_bundle "$(pwd)" "main"
fi
echo -e "${GREEN}✓ Deployment files downloaded${NC}"
else else
echo -e "${BLUE}[1/$TOTAL_STEPS] Using docker-compose.yaml in current directory${NC}" echo -e "${BLUE}[1/$TOTAL_STEPS] Using docker-compose.yaml in current directory${NC}"
fi fi
# Generate turnserver.conf if coturn is enabled if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
if [[ "$ENABLE_COTURN" == "true" ]]; then [[ -f scripts/run_dograh_init.sh ]] || dograh_fail "scripts/run_dograh_init.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout."
echo -e "${BLUE}[2/$TOTAL_STEPS] Creating TURN server configuration...${NC}" [[ -f scripts/lib/setup_common.sh ]] || dograh_fail "scripts/lib/setup_common.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout."
cat > turnserver.conf << TURN_EOF [[ -f deploy/templates/turnserver.remote.conf.template ]] || dograh_fail "deploy/templates/turnserver.remote.conf.template not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout."
# Coturn TURN Server - Docker Configuration (local)
# Auto-generated by setup_local.sh
# Listener ports
listening-port=3478
tls-listening-port=5349
# Relay port range
min-port=49152
max-port=49200
# Network - external IP for NAT traversal
external-ip=$TURN_HOST
# Realm
realm=dograh.com
# Authentication (TURN REST API with time-limited credentials)
use-auth-secret
static-auth-secret=$TURN_SECRET
# Security
fingerprint
no-cli
no-multicast-peers
# Logging
log-file=stdout
TURN_EOF
echo -e "${GREEN}✓ turnserver.conf created${NC}"
fi fi
# Generate .env # Generate .env
@ -163,7 +157,7 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET
ENABLE_TELEMETRY=$ENABLE_TELEMETRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY
ENV_EOF ENV_EOF
if [[ "$ENABLE_COTURN" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
cat >> .env << ENV_EOF cat >> .env << ENV_EOF
# TURN Server Configuration (time-limited credentials via TURN REST API) # TURN Server Configuration (time-limited credentials via TURN REST API)
@ -181,11 +175,13 @@ echo ""
echo -e "Files created in ${BLUE}$(pwd)${NC}:" echo -e "Files created in ${BLUE}$(pwd)${NC}:"
echo " - docker-compose.yaml" echo " - docker-compose.yaml"
echo " - .env" echo " - .env"
if [[ "$ENABLE_COTURN" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo " - turnserver.conf" echo " - scripts/run_dograh_init.sh"
echo " - scripts/lib/setup_common.sh"
echo " - deploy/templates/"
fi fi
echo "" echo ""
if [[ "$ENABLE_COTURN" == "true" ]]; then if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}" echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
echo "" echo ""
echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}" echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}"

View file

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -8,6 +8,26 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh"
BOOTSTRAP_LIB=""
if [[ ! -f "$LIB_PATH" ]]; then
BOOTSTRAP_LIB="$(mktemp)"
curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh"
LIB_PATH="$BOOTSTRAP_LIB"
fi
cleanup() {
if [[ -n "$BOOTSTRAP_LIB" ]]; then
rm -f "$BOOTSTRAP_LIB"
fi
}
trap cleanup EXIT
# shellcheck disable=SC1090
. "$LIB_PATH"
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗" echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Remote Setup ║" echo "║ Dograh Remote Setup ║"
@ -16,24 +36,21 @@ echo "╚═══════════════════════
echo -e "${NC}" echo -e "${NC}"
# Get the public IP address (skip prompt if SERVER_IP is already set) # Get the public IP address (skip prompt if SERVER_IP is already set)
if [[ -z "$SERVER_IP" ]]; then if [[ -z "${SERVER_IP:-}" ]]; then
echo -e "${YELLOW}Enter your server's public IP address:${NC}" echo -e "${YELLOW}Enter your server's public IP address:${NC}"
read -p "> " SERVER_IP read -p "> " SERVER_IP
fi fi
if [[ -z "$SERVER_IP" ]]; then if [[ -z "$SERVER_IP" ]]; then
echo -e "${RED}Error: IP address cannot be empty${NC}" dograh_fail "IP address cannot be empty"
exit 1
fi fi
# Validate IP address format (basic validation) if ! dograh_is_ipv4 "$SERVER_IP"; then
if ! [[ "$SERVER_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then dograh_fail "Invalid IP address format"
echo -e "${RED}Error: Invalid IP address format${NC}"
exit 1
fi fi
# Get the TURN secret (skip prompt if TURN_SECRET is already set) # Get the TURN secret (skip prompt if TURN_SECRET is already set)
if [[ -z "$TURN_SECRET" ]]; then if [[ -z "${TURN_SECRET:-}" ]]; then
echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}"
read -sp "> " TURN_SECRET read -sp "> " TURN_SECRET
echo "" echo ""
@ -45,10 +62,8 @@ if [[ -z "$TURN_SECRET" ]]; then
fi fi
# Deployment mode. Skip prompt if DEPLOY_MODE is already set. Non-interactive # Deployment mode. Skip prompt if DEPLOY_MODE is already set. Non-interactive
# callers (cloud-init, CI, terraform) without a TTY default to "prebuilt" so # callers without a TTY default to "prebuilt" to keep automation stable.
# existing automation keeps working without changes - explicitly set if [[ -z "${DEPLOY_MODE:-}" ]]; then
# DEPLOY_MODE=build to opt into source builds from a non-interactive context.
if [[ -z "$DEPLOY_MODE" ]]; then
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo "" echo ""
echo -e "${YELLOW}Deployment mode:${NC}" echo -e "${YELLOW}Deployment mode:${NC}"
@ -58,19 +73,16 @@ if [[ -z "$DEPLOY_MODE" ]]; then
mode_choice="${mode_choice:-1}" mode_choice="${mode_choice:-1}"
case "$mode_choice" in case "$mode_choice" in
1|prebuilt) DEPLOY_MODE="prebuilt" ;; 1|prebuilt) DEPLOY_MODE="prebuilt" ;;
2|build) DEPLOY_MODE="build" ;; 2|build) DEPLOY_MODE="build" ;;
*) echo -e "${RED}Error: invalid choice '$mode_choice'${NC}"; exit 1 ;; *) dograh_fail "invalid choice '$mode_choice'" ;;
esac esac
else else
DEPLOY_MODE="prebuilt" DEPLOY_MODE="prebuilt"
fi fi
fi fi
# Build mode needs source code - either use existing repo or clone fresh.
# Same TTY rule: prompt interactively, otherwise pick sensible defaults so
# automation that sets DEPLOY_MODE=build doesn't need to spell everything out.
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
if [[ -z "$REPO_SOURCE" ]]; then if [[ -z "${REPO_SOURCE:-}" ]]; then
if [[ -d ".git" ]] && [[ -f "docker-compose.yaml" ]]; then if [[ -d ".git" ]] && [[ -f "docker-compose.yaml" ]]; then
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo "" echo ""
@ -91,7 +103,7 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then
fi fi
if [[ "$REPO_SOURCE" == "clone" ]]; then if [[ "$REPO_SOURCE" == "clone" ]]; then
if [[ -z "$FORK_REPO" ]]; then if [[ -z "${FORK_REPO:-}" ]]; then
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo "" echo ""
echo -e "${YELLOW}GitHub repo to clone (format: owner/name):${NC}" echo -e "${YELLOW}GitHub repo to clone (format: owner/name):${NC}"
@ -101,7 +113,8 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then
FORK_REPO="dograh-hq/dograh" FORK_REPO="dograh-hq/dograh"
fi fi
fi fi
if [[ -z "$BRANCH" ]]; then
if [[ -z "${BRANCH:-}" ]]; then
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo -e "${YELLOW}Branch:${NC}" echo -e "${YELLOW}Branch:${NC}"
read -p "[main]: " BRANCH read -p "[main]: " BRANCH
@ -113,13 +126,9 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then
fi fi
fi fi
# Telemetry opt-out (default: true)
ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}"
FASTAPI_WORKERS="${FASTAPI_WORKERS:-}"
# Number of uvicorn worker processes. Each runs as its own process on a
# distinct port (8000, 8001, ...) and nginx balances across them with
# least_conn. Better than uvicorn --workers for long-lived WebSocket
# connections, which would otherwise stick to whichever worker accepted them.
if [[ -z "$FASTAPI_WORKERS" ]]; then if [[ -z "$FASTAPI_WORKERS" ]]; then
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo "" echo ""
@ -131,26 +140,15 @@ if [[ -z "$FASTAPI_WORKERS" ]]; then
fi fi
fi fi
if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)"
echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}"
exit 1
fi
# Where setup artifacts (.env, certs, nginx.conf, etc.) will land. Build mode if [[ "$DEPLOY_MODE" == "build" && "${REPO_SOURCE:-}" == "existing" ]]; then
# with an existing repo writes them next to docker-compose.yaml in cwd;
# everything else writes into a fresh dograh/ subdirectory.
if [[ "$DEPLOY_MODE" == "build" && "$REPO_SOURCE" == "existing" ]]; then
TARGET_DIR="." TARGET_DIR="."
else else
TARGET_DIR="dograh" TARGET_DIR="dograh"
fi fi
# Refuse to overwrite an existing install - re-running this script would if [[ "${DOGRAH_FORCE_OVERWRITE:-}" != "1" && "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then
# regenerate OSS_JWT_SECRET (invalidating logged-in sessions), reset the
# TURN secret (breaking WebRTC auth), and overwrite nginx.conf customizations.
# Set DOGRAH_FORCE_OVERWRITE=1 to bypass; DOGRAH_SKIP_DOWNLOAD=1 (used by e2e)
# also bypasses since those flows manage state themselves.
if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then
if [[ -f "$TARGET_DIR/.env" ]]; then if [[ -f "$TARGET_DIR/.env" ]]; then
if [[ "$TARGET_DIR" == "." ]]; then if [[ "$TARGET_DIR" == "." ]]; then
existing_path="$(pwd)/.env" existing_path="$(pwd)/.env"
@ -164,7 +162,7 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the
echo -e "${RED}Refusing to continue - re-running setup would:${NC}" echo -e "${RED}Refusing to continue - re-running setup would:${NC}"
echo -e "${RED} - overwrite .env (invalidates sessions, breaks TURN auth)${NC}" echo -e "${RED} - overwrite .env (invalidates sessions, breaks TURN auth)${NC}"
echo -e "${RED} - regenerate SSL certificates${NC}" echo -e "${RED} - regenerate SSL certificates${NC}"
echo -e "${RED} - reset nginx.conf and turnserver.conf customizations${NC}" echo -e "${RED} - replace the validated remote deployment bundle${NC}"
echo "" echo ""
echo -e "${BLUE}To upgrade an existing install, follow:${NC}" echo -e "${BLUE}To upgrade an existing install, follow:${NC}"
echo -e " ${BLUE}https://docs.dograh.com/deployment/update${NC}" echo -e " ${BLUE}https://docs.dograh.com/deployment/update${NC}"
@ -176,11 +174,10 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the
fi fi
fi fi
# Total step count depends on mode (build adds the override-file step)
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
TOTAL=7
else
TOTAL=6 TOTAL=6
else
TOTAL=5
fi fi
echo "" echo ""
@ -190,24 +187,20 @@ echo -e " TURN Secret: ${BLUE}********${NC}"
echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}" echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}"
echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))"
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
if [[ "$REPO_SOURCE" == "clone" ]]; then if [[ "${REPO_SOURCE:-}" == "clone" ]]; then
echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}" echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}"
else else
echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}" echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}"
fi fi
fi fi
echo "" echo ""
# Step 1: get the source - either the standalone compose file (prebuilt mode)
# or the full repo (build mode). Skip the download/clone when
# DOGRAH_SKIP_DOWNLOAD=1 (e.g. e2e tests that already have everything in place).
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
if [[ "$DOGRAH_SKIP_DOWNLOAD" == "1" ]]; then if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" == "1" ]]; then
echo -e "${BLUE}[1/$TOTAL] Using existing repo in current directory${NC}" echo -e "${BLUE}[1/$TOTAL] Using existing repo in current directory${NC}"
elif [[ "$REPO_SOURCE" == "clone" ]]; then elif [[ "${REPO_SOURCE:-}" == "clone" ]]; then
if [[ -e "dograh" ]]; then if [[ -e "dograh" ]]; then
echo -e "${RED}Error: 'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it.${NC}" dograh_fail "'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it."
exit 1
fi fi
echo -e "${BLUE}[1/$TOTAL] Cloning $FORK_REPO (branch: $BRANCH)...${NC}" echo -e "${BLUE}[1/$TOTAL] Cloning $FORK_REPO (branch: $BRANCH)...${NC}"
git clone --branch "$BRANCH" --recurse-submodules "https://github.com/$FORK_REPO.git" dograh git clone --branch "$BRANCH" --recurse-submodules "https://github.com/$FORK_REPO.git" dograh
@ -217,123 +210,26 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e "${BLUE}[1/$TOTAL] Using existing repo at $(pwd)${NC}" echo -e "${BLUE}[1/$TOTAL] Using existing repo at $(pwd)${NC}"
fi fi
else else
if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then
mkdir -p dograh 2>/dev/null || true mkdir -p dograh 2>/dev/null || true
cd dograh cd dograh
echo -e "${BLUE}[1/$TOTAL] Downloading docker-compose.yaml...${NC}" echo -e "${BLUE}[1/$TOTAL] Downloading deployment bundle...${NC}"
curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml curl -fsSL -o docker-compose.yaml "https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml"
echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" dograh_download_remote_support_bundle "$(pwd)" "main"
echo -e "${GREEN}✓ Deployment bundle downloaded${NC}"
else else
echo -e "${BLUE}[1/$TOTAL] Using docker-compose.yaml in current directory${NC}" echo -e "${BLUE}[1/$TOTAL] Using deployment files in current directory${NC}"
fi fi
fi fi
echo -e "${BLUE}[2/$TOTAL] Creating nginx.conf...${NC}" DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
# Build the upstream block first (needs shell interpolation for the server
# lines), then append the static server blocks via a quoted heredoc. The
# SERVER_IP_PLACEHOLDER gets replaced by sed below.
{
echo "# Backend API workers — one uvicorn process per port, balanced by least_conn."
echo "# Generated by setup_remote.sh; regenerate to change worker count."
echo "upstream dograh_api {"
echo " least_conn;"
for ((i=0; i<FASTAPI_WORKERS; i++)); do
port=$((8000 + i))
echo " server api:$port max_fails=3 fail_timeout=10s;"
done
echo " keepalive 32;"
echo "}"
echo ""
cat << 'NGINX_EOF'
server {
listen 80;
server_name SERVER_IP_PLACEHOLDER;
# Redirect all HTTP to HTTPS if [[ "$DEPLOY_MODE" != "prebuilt" ]]; then
return 301 https://$host$request_uri; chmod +x remote_up.sh
} fi
server { echo -e "${BLUE}[2/$TOTAL] Creating SSL certificate generation script...${NC}"
listen 443 ssl;
server_name SERVER_IP_PLACEHOLDER;
ssl_certificate /etc/nginx/certs/local.crt;
ssl_certificate_key /etc/nginx/certs/local.key;
# Basic TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# Backend API and WebSockets - bypass the UI, go straight to the
# api workers via the least_conn upstream defined above.
location /api/v1/ {
proxy_pass http://dograh_api;
proxy_http_version 1.1;
# Retry on a dead/restarting worker
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Long-lived WebSockets (audio streaming, signaling)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Don't buffer streamed responses
proxy_buffering off;
client_max_body_size 100M;
}
location / {
proxy_pass http://ui:3010;
proxy_http_version 1.1;
# Important for WebSockets / hot reload etc.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Rewrite localhost MinIO URLs in API responses to use current domain
sub_filter 'http://localhost:9000/voice-audio/' 'https://$host/voice-audio/';
sub_filter_once off;
sub_filter_types application/json text/html;
}
location /voice-audio/ {
proxy_pass http://minio:9000/voice-audio/;
proxy_http_version 1.1;
# Headers for file downloads from MinIO
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Allow large file downloads
proxy_buffering off;
client_max_body_size 100M;
}
}
NGINX_EOF
} > nginx.conf
# Replace placeholder with actual IP
sed -i.bak "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.bak
echo -e "${GREEN}✓ nginx.conf created${NC}"
echo -e "${BLUE}[3/$TOTAL] Creating SSL certificate generation script...${NC}"
cat > generate_certificate.sh << CERT_EOF cat > generate_certificate.sh << CERT_EOF
#!/bin/bash #!/bin/bash
mkdir -p certs mkdir -p certs
@ -346,50 +242,22 @@ CERT_EOF
chmod +x generate_certificate.sh chmod +x generate_certificate.sh
echo -e "${GREEN}✓ generate_certificate.sh created${NC}" echo -e "${GREEN}✓ generate_certificate.sh created${NC}"
echo -e "${BLUE}[4/$TOTAL] Generating SSL certificates...${NC}" echo -e "${BLUE}[3/$TOTAL] Generating SSL certificates...${NC}"
./generate_certificate.sh ./generate_certificate.sh
echo -e "${GREEN}✓ SSL certificates generated${NC}" echo -e "${GREEN}✓ SSL certificates generated${NC}"
echo -e "${BLUE}[5/$TOTAL] Creating TURN server configuration...${NC}" echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}"
cat > turnserver.conf << TURN_EOF
# Coturn TURN Server - Docker Configuration
# Auto-generated by setup_remote.sh
# Listener ports
listening-port=3478
tls-listening-port=5349
# Relay port range
min-port=49152
max-port=49200
# Network - external IP for NAT traversal
external-ip=$SERVER_IP
# Realm
realm=dograh.com
# Authentication (TURN REST API with time-limited credentials)
use-auth-secret
static-auth-secret=$TURN_SECRET
# Security
fingerprint
no-cli
no-multicast-peers
# Logging
log-file=stdout
TURN_EOF
echo -e "${GREEN}✓ turnserver.conf created${NC}"
echo -e "${BLUE}[6/$TOTAL] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32) OSS_JWT_SECRET=$(openssl rand -hex 32)
cat > .env << ENV_EOF cat > .env << ENV_EOF
# Change environment from local to production so that coturn filters local IPs # Change environment from local to production so that coturn filters local IPs
ENVIRONMENT=production ENVIRONMENT=production
# Canonical public host/base URL for this install.
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 (public URL the backend uses to build webhook/embed links)
BACKEND_API_ENDPOINT=https://$SERVER_IP BACKEND_API_ENDPOINT=https://$SERVER_IP
@ -407,18 +275,16 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET
ENABLE_TELEMETRY=$ENABLE_TELEMETRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY
# Number of uvicorn worker processes; nginx load-balances across them # Number of uvicorn worker processes; nginx load-balances across them
# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn.
# Must match the upstream block in nginx.conf — re-run setup_remote.sh
# (with DOGRAH_FORCE_OVERWRITE=1) to change.
FASTAPI_WORKERS=$FASTAPI_WORKERS FASTAPI_WORKERS=$FASTAPI_WORKERS
ENV_EOF ENV_EOF
echo -e "${GREEN}✓ .env file created${NC}" echo -e "${GREEN}✓ .env file created${NC}"
# In build mode, write the override file that swaps prebuilt images for echo -e "${BLUE}[5/$TOTAL] Validating remote init configuration...${NC}"
# local builds. Compose auto-loads docker-compose.override.yaml, so no -f flag dograh_prepare_remote_install "$(pwd)"
# is needed at runtime. echo -e "${GREEN}✓ Remote init configuration validated${NC}"
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e "${BLUE}[7/$TOTAL] Creating docker-compose.override.yaml...${NC}" echo -e "${BLUE}[6/$TOTAL] Creating docker-compose.override.yaml...${NC}"
cat > docker-compose.override.yaml << 'OVERRIDE_EOF' cat > docker-compose.override.yaml << 'OVERRIDE_EOF'
# Auto-generated by setup_remote.sh (build mode). # Auto-generated by setup_remote.sh (build mode).
# Overrides docker-compose.yaml to build api and ui images from local source # Overrides docker-compose.yaml to build api and ui images from local source
@ -452,8 +318,9 @@ echo " - docker-compose.yaml"
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
echo " - docker-compose.override.yaml (build directives)" echo " - docker-compose.override.yaml (build directives)"
fi fi
echo " - nginx.conf" echo " - remote_up.sh"
echo " - turnserver.conf" echo " - scripts/run_dograh_init.sh"
echo " - deploy/templates/"
echo " - generate_certificate.sh" echo " - generate_certificate.sh"
echo " - certs/local.crt" echo " - certs/local.crt"
echo " - certs/local.key" echo " - certs/local.key"
@ -461,28 +328,17 @@ echo " - .env"
echo "" echo ""
echo -e "${YELLOW}To start Dograh, run:${NC}" echo -e "${YELLOW}To start Dograh, run:${NC}"
echo "" echo ""
# The script's own cd into dograh/ doesn't persist to the user's shell, so if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
# remind them to cd themselves — except when they're already there (build mode
# with REPO_SOURCE=existing, which writes into cwd).
if [[ "$DEPLOY_MODE" != "build" || "$REPO_SOURCE" != "existing" ]]; then
echo -e " ${BLUE}cd $(pwd)${NC}" echo -e " ${BLUE}cd $(pwd)${NC}"
fi fi
if [[ "$DEPLOY_MODE" == "build" ]]; then if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e " ${BLUE}sudo docker compose --profile remote up -d --build${NC}" echo -e " ${BLUE}./remote_up.sh --build${NC}"
echo "" echo ""
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}" 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}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}" echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
echo ""
echo -e "${YELLOW}The first build can take several minutes${NC}"
echo -e "${YELLOW}(downloading base images, installing dependencies).${NC}"
echo -e "${YELLOW}If you know how to speed this up, we would love a pull request.${NC}"
echo ""
echo -e "${YELLOW}To rebuild after editing api/ or ui/ code:${NC}"
echo ""
echo -e " ${BLUE}sudo docker compose --profile remote build && sudo docker compose --profile remote up -d${NC}"
else else
echo -e " ${BLUE}sudo docker compose --profile remote up --pull always${NC}" echo -e " ${BLUE}./remote_up.sh${NC}"
fi fi
echo "" echo ""
echo -e "${YELLOW}Your application will be available at:${NC}" echo -e "${YELLOW}Your application will be available at:${NC}"

View file

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -8,34 +8,39 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh"
BOOTSTRAP_LIB=""
if [[ ! -f "$LIB_PATH" ]]; then
BOOTSTRAP_LIB="$(mktemp)"
curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh"
LIB_PATH="$BOOTSTRAP_LIB"
fi
cleanup() {
if [[ -n "$BOOTSTRAP_LIB" ]]; then
rm -f "$BOOTSTRAP_LIB"
fi
}
trap cleanup EXIT
# shellcheck disable=SC1090
. "$LIB_PATH"
REPO="dograh-hq/dograh" REPO="dograh-hq/dograh"
TIMESTAMP=$(date +%Y%m%d-%H%M%S) TIMESTAMP=$(date +%Y%m%d-%H%M%S)
echo -e "${BLUE}" echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗" echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Remote Update ║" echo "║ Dograh Remote Update ║"
echo "║ Refresh host-side configs and pin api/ui image versions ║" echo "║ Refresh deployment files and validate runtime config ║"
echo "╚══════════════════════════════════════════════════════════════╝" echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}" echo -e "${NC}"
# Refuse outside an install — nothing to update if these aren't here. [[ -f docker-compose.yaml ]] || dograh_fail "docker-compose.yaml not found in $(pwd)"
if [[ ! -f docker-compose.yaml ]]; then [[ -f .env ]] || dograh_fail ".env not found in $(pwd)"
echo -e "${RED}Error: docker-compose.yaml not found in $(pwd)${NC}"
echo -e "${RED}Run this script from your Dograh install directory${NC}"
echo -e "${RED}(the 'dograh/' folder created by setup_remote.sh).${NC}"
exit 1
fi
if [[ ! -f .env ]]; then
echo -e "${RED}Error: .env not found in $(pwd)${NC}"
echo -e "${RED}This script updates an existing install — there is nothing here to update.${NC}"
echo -e "${RED}For a fresh install, see https://docs.dograh.com/deployment/docker${NC}"
exit 1
fi
# Build-mode installs update via git, not via this script. The presence of an
# override file is the definitive marker (created by setup_remote.sh in build
# mode and not in prebuilt mode).
if [[ -f docker-compose.override.yaml ]]; then if [[ -f docker-compose.override.yaml ]]; then
echo -e "${YELLOW}Build-mode install detected (docker-compose.override.yaml present).${NC}" echo -e "${YELLOW}Build-mode install detected (docker-compose.override.yaml present).${NC}"
echo "" echo ""
@ -44,61 +49,47 @@ if [[ -f docker-compose.override.yaml ]]; then
echo -e " ${BLUE}git fetch${NC}" echo -e " ${BLUE}git fetch${NC}"
echo -e " ${BLUE}git checkout <tag> # or: git pull${NC}" echo -e " ${BLUE}git checkout <tag> # or: git pull${NC}"
echo -e " ${BLUE}git submodule update --init --recursive${NC}" echo -e " ${BLUE}git submodule update --init --recursive${NC}"
echo -e " ${BLUE}sudo docker compose --profile remote build${NC}" echo -e " ${BLUE}./remote_up.sh --build${NC}"
echo -e " ${BLUE}sudo docker compose --profile remote up -d${NC}"
echo "" echo ""
echo -e "${YELLOW}See https://docs.dograh.com/deployment/update#updating-a-source-build${NC}" echo -e "${YELLOW}See https://docs.dograh.com/deployment/update#updating-a-source-build${NC}"
exit 1 exit 1
fi fi
############################################################################### _caller_FASTAPI_WORKERS="${FASTAPI_WORKERS:-}"
### Discover existing config from .env _caller_TARGET_VERSION="${TARGET_VERSION:-}"
###############################################################################
# Save anything the caller exported before we overwrite from .env. DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
_caller_FASTAPI_WORKERS="$FASTAPI_WORKERS" dograh_load_env_file .env
_caller_TARGET_VERSION="$TARGET_VERSION"
set -a [[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET not found in .env"
# shellcheck disable=SC1091
. ./.env
set +a
# SERVER_IP isn't a literal key in .env — derive it from BACKEND_API_ENDPOINT. if [[ -n "$_caller_FASTAPI_WORKERS" ]]; then
if [[ -z "$SERVER_IP" ]]; then FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS"
if [[ -n "$BACKEND_API_ENDPOINT" ]]; then fi
SERVER_IP="${BACKEND_API_ENDPOINT#https://}"
SERVER_IP="${SERVER_IP#http://}" 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}"
else
FASTAPI_WORKERS="4"
fi fi
fi fi
if [[ -z "$SERVER_IP" ]]; then [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)"
echo -e "${RED}Error: could not determine SERVER_IP from .env${NC}"
echo -e "${RED}Expected BACKEND_API_ENDPOINT=https://<ip> in .env${NC}"
exit 1
fi
if [[ -z "$TURN_SECRET" ]]; then TARGET_VERSION="${_caller_TARGET_VERSION:-${TARGET_VERSION:-}}"
echo -e "${RED}Error: TURN_SECRET not found in .env${NC}"
exit 1
fi
# Reapply caller overrides on top of sourced .env so e.g. FASTAPI_WORKERS=8 ./update_remote.sh works.
[[ -n "$_caller_FASTAPI_WORKERS" ]] && FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS"
[[ -n "$_caller_TARGET_VERSION" ]] && TARGET_VERSION="$_caller_TARGET_VERSION"
###############################################################################
### Determine target version
###############################################################################
if [[ -z "$TARGET_VERSION" ]]; then if [[ -z "$TARGET_VERSION" ]]; then
echo -e "${BLUE}Fetching latest release tag from GitHub...${NC}" dograh_info "Fetching latest release tag from GitHub..."
LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \
| grep -E '"tag_name":' | head -1 \ | grep -E '"tag_name":' | head -1 \
| sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -z "$LATEST_TAG" ]]; then if [[ -z "$LATEST_TAG" ]]; then
echo -e "${YELLOW}Could not auto-discover latest tag — defaulting to 'main'.${NC}" dograh_warn "Could not auto-discover latest tag - defaulting to 'main'."
LATEST_TAG="main" LATEST_TAG="main"
fi fi
@ -113,28 +104,19 @@ if [[ -z "$TARGET_VERSION" ]]; then
fi fi
fi fi
# "latest" isn't a real ref on GitHub — treat it as "latest release".
if [[ "$TARGET_VERSION" == "latest" ]]; then if [[ "$TARGET_VERSION" == "latest" ]]; then
TARGET_VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ TARGET_VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \
| grep -E '"tag_name":' | head -1 \ | grep -E '"tag_name":' | head -1 \
| sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -z "$TARGET_VERSION" ]]; then [[ -n "$TARGET_VERSION" ]] || dograh_fail "could not resolve 'latest' to a release tag"
echo -e "${RED}Error: could not resolve 'latest' to a release tag${NC}"
exit 1
fi
fi fi
# GitHub release tags use a 'dograh-v' prefix (e.g. dograh-v1.28.0); Docker
# image tags on Docker Hub drop both the prefix and the 'v' (e.g. ':1.28.0').
# Users commonly type shortcuts like '1.28.0' or 'v1.28.0' — try all reasonable
# variants so the script accepts any of those forms.
TRY_TAGS=("$TARGET_VERSION") TRY_TAGS=("$TARGET_VERSION")
case "$TARGET_VERSION" in case "$TARGET_VERSION" in
main|HEAD) main|HEAD)
;; # branch refs — leave as-is ;;
dograh-*) dograh-*)
;; # already in the full tag form ;;
v*) v*)
TRY_TAGS+=("dograh-$TARGET_VERSION") TRY_TAGS+=("dograh-$TARGET_VERSION")
;; ;;
@ -143,7 +125,7 @@ case "$TARGET_VERSION" in
;; ;;
esac esac
echo -e "${BLUE}Validating target version: $TARGET_VERSION...${NC}" dograh_info "Validating target version: $TARGET_VERSION..."
RESOLVED_TAG="" RESOLVED_TAG=""
for tag in "${TRY_TAGS[@]}"; do for tag in "${TRY_TAGS[@]}"; do
if curl -fsI "https://raw.githubusercontent.com/$REPO/$tag/docker-compose.yaml" >/dev/null 2>&1; then if curl -fsI "https://raw.githubusercontent.com/$REPO/$tag/docker-compose.yaml" >/dev/null 2>&1; then
@ -152,81 +134,49 @@ for tag in "${TRY_TAGS[@]}"; do
fi fi
done done
if [[ -z "$RESOLVED_TAG" ]]; then [[ -n "$RESOLVED_TAG" ]] || dograh_fail "could not find a git tag matching '$TARGET_VERSION'"
echo -e "${RED}Error: could not find a git tag matching '$TARGET_VERSION'${NC}"
echo -e "${RED}Tried: ${TRY_TAGS[*]}${NC}"
echo -e "${RED}See available releases at: https://github.com/$REPO/releases${NC}"
exit 1
fi
if [[ "$RESOLVED_TAG" != "$TARGET_VERSION" ]]; then if [[ "$RESOLVED_TAG" != "$TARGET_VERSION" ]]; then
echo -e "${GREEN}✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'${NC}" dograh_success "✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'"
fi fi
TARGET_VERSION="$RESOLVED_TAG" TARGET_VERSION="$RESOLVED_TAG"
RAW_BASE="https://raw.githubusercontent.com/$REPO/$TARGET_VERSION" RAW_BASE="https://raw.githubusercontent.com/$REPO/$TARGET_VERSION"
# Derive the Docker image tag from the git tag. Tags on Docker Hub use bare
# semver — strip the 'dograh-' prefix and the leading 'v'.
IMAGE_TAG="" IMAGE_TAG=""
case "$TARGET_VERSION" in case "$TARGET_VERSION" in
dograh-v*) IMAGE_TAG="${TARGET_VERSION#dograh-v}" ;; dograh-v*) IMAGE_TAG="${TARGET_VERSION#dograh-v}" ;;
v*) IMAGE_TAG="${TARGET_VERSION#v}" ;; v*) IMAGE_TAG="${TARGET_VERSION#v}" ;;
main|HEAD) IMAGE_TAG="" ;; main|HEAD) IMAGE_TAG="" ;;
*) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;; *) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;;
esac esac
# Verify the image tag actually exists on Docker Hub. If not (e.g. CI hasn't
# published yet), fall back to ':latest' rather than pinning to a missing tag.
if [[ -n "$IMAGE_TAG" ]]; then if [[ -n "$IMAGE_TAG" ]]; then
if curl -fsI "https://hub.docker.com/v2/repositories/dograhai/dograh-api/tags/$IMAGE_TAG/" >/dev/null 2>&1; then if curl -fsI "https://hub.docker.com/v2/repositories/dograhai/dograh-api/tags/$IMAGE_TAG/" >/dev/null 2>&1; then
echo -e "${GREEN}✓ Image tag :$IMAGE_TAG found on Docker Hub${NC}" dograh_success "✓ Image tag :$IMAGE_TAG found on Docker Hub"
else else
echo -e "${YELLOW}Warning: image tag :$IMAGE_TAG not found on Docker Hub — leaving images at :latest${NC}" dograh_warn "Warning: image tag :$IMAGE_TAG not found on Docker Hub - leaving images at :latest"
IMAGE_TAG="" IMAGE_TAG=""
fi fi
fi fi
###############################################################################
### Reconcile required keys that may be missing on older installs
###############################################################################
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}"
else
FASTAPI_WORKERS="4"
fi
fi
if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then
echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}"
exit 1
fi
###############################################################################
### Summary + confirmation
###############################################################################
echo "" echo ""
echo -e "${GREEN}Update plan:${NC}" echo -e "${GREEN}Update plan:${NC}"
echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" echo -e " Server IP: ${BLUE}$(dograh_infer_server_ip "$(pwd)" || echo "unknown")${NC}"
echo -e " Target version: ${BLUE}$TARGET_VERSION${NC}" echo -e " Target version: ${BLUE}$TARGET_VERSION${NC}"
echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))"
echo "" echo ""
echo -e "${YELLOW}Files that will be replaced (backups saved with suffix .bak.$TIMESTAMP):${NC}" echo -e "${YELLOW}Files that will be replaced (backups saved with suffix .bak.$TIMESTAMP):${NC}"
echo " - docker-compose.yaml (pulled from GitHub at $TARGET_VERSION)" echo " - docker-compose.yaml (pulled from GitHub at $TARGET_VERSION)"
echo " - nginx.conf (regenerated from this script's template)" echo " - remote_up.sh (startup wrapper / preflight)"
echo " - turnserver.conf (regenerated from this script's template)" echo " - scripts/run_dograh_init.sh"
echo " - .env (existing values preserved; missing keys appended)" echo " - scripts/lib/setup_common.sh"
echo "" echo " - deploy/templates/*.template"
echo -e "${YELLOW}Any local customizations to these files will be overwritten — check the backup${NC}" echo " - .env (canonical remote keys synchronized)"
echo -e "${YELLOW}files if you need to re-apply edits afterwards.${NC}" echo " - legacy nginx.conf / turnserver.conf backups will be kept if those files still exist"
echo "" echo ""
if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then if [[ -t 0 && "${DOGRAH_UPDATE_YES:-}" != "1" ]]; then
read -p "Proceed? [y/N]: " confirm read -p "Proceed? [y/N]: " confirm
if ! [[ "$confirm" =~ ^[Yy] ]]; then if ! [[ "$confirm" =~ ^[Yy] ]]; then
echo -e "${RED}Aborted.${NC}" echo -e "${RED}Aborted.${NC}"
@ -234,198 +184,44 @@ if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then
fi fi
fi fi
###############################################################################
### Step 1 — backups
###############################################################################
echo "" echo ""
echo -e "${BLUE}[1/5] Backing up existing files...${NC}" echo -e "${BLUE}[1/3] Backing up existing files...${NC}"
for f in docker-compose.yaml nginx.conf turnserver.conf .env; do for f in \
docker-compose.yaml \
nginx.conf \
turnserver.conf \
.env \
remote_up.sh \
scripts/run_dograh_init.sh \
scripts/lib/setup_common.sh \
deploy/templates/nginx.remote.conf.template \
deploy/templates/turnserver.remote.conf.template
do
if [[ -f "$f" ]]; then if [[ -f "$f" ]]; then
mkdir -p "$(dirname "$f")"
cp -p "$f" "$f.bak.$TIMESTAMP" cp -p "$f" "$f.bak.$TIMESTAMP"
echo -e " ${GREEN}$f$f.bak.$TIMESTAMP${NC}" echo -e " ${GREEN}$f$f.bak.$TIMESTAMP${NC}"
fi fi
done done
############################################################################### echo -e "${BLUE}[2/3] Downloading deployment bundle at $TARGET_VERSION...${NC}"
### Step 2 — docker-compose.yaml (download + pin image tags)
###############################################################################
echo -e "${BLUE}[2/5] Downloading docker-compose.yaml at $TARGET_VERSION...${NC}"
curl -fsSL -o docker-compose.yaml "$RAW_BASE/docker-compose.yaml" curl -fsSL -o docker-compose.yaml "$RAW_BASE/docker-compose.yaml"
dograh_download_remote_support_bundle "$(pwd)" "$TARGET_VERSION"
rm -f nginx.conf turnserver.conf
# Pin api/ui image tags when we resolved one. For branch refs (main) IMAGE_TAG
# is empty, so the images stay at ':latest' and `up --pull always` grabs the
# newest build of that branch.
if [[ -n "$IMAGE_TAG" ]]; then if [[ -n "$IMAGE_TAG" ]]; then
sed -i.tmp -E "s#(dograh-(api|ui)):latest#\1:$IMAGE_TAG#g" docker-compose.yaml sed -i.tmp -E "s#(dograh-(api|ui)):latest#\1:$IMAGE_TAG#g" docker-compose.yaml
rm -f docker-compose.yaml.tmp rm -f docker-compose.yaml.tmp
echo -e "${GREEN}✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG${NC}" dograh_success "✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG"
else else
echo -e "${GREEN}✓ docker-compose.yaml updated (image tags left at :latest)${NC}" dograh_success "✓ docker-compose.yaml updated (image tags left at :latest)"
fi fi
############################################################################### echo -e "${BLUE}[3/3] Synchronizing environment and validating init-based remote config...${NC}"
### Step 3 — nginx.conf (regenerate from embedded template) dograh_set_env_key .env FASTAPI_WORKERS "$FASTAPI_WORKERS"
############################################################################### dograh_prepare_remote_install "$(pwd)"
docker compose config -q
echo -e "${BLUE}[3/5] Regenerating nginx.conf...${NC}" dograh_success "✓ Remote init configuration validated"
{
echo "# Backend API workers — one uvicorn process per port, balanced by least_conn."
echo "# Generated by update_remote.sh; regenerate to change worker count."
echo "upstream dograh_api {"
echo " least_conn;"
for ((i=0; i<FASTAPI_WORKERS; i++)); do
port=$((8000 + i))
echo " server api:$port max_fails=3 fail_timeout=10s;"
done
echo " keepalive 32;"
echo "}"
echo ""
cat << 'NGINX_EOF'
server {
listen 80;
server_name SERVER_IP_PLACEHOLDER;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name SERVER_IP_PLACEHOLDER;
ssl_certificate /etc/nginx/certs/local.crt;
ssl_certificate_key /etc/nginx/certs/local.key;
# Basic TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# Backend API and WebSockets - bypass the UI, go straight to the
# api workers via the least_conn upstream defined above.
location /api/v1/ {
proxy_pass http://dograh_api;
proxy_http_version 1.1;
# Retry on a dead/restarting worker
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Long-lived WebSockets (audio streaming, signaling)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Don't buffer streamed responses
proxy_buffering off;
client_max_body_size 100M;
}
location / {
proxy_pass http://ui:3010;
proxy_http_version 1.1;
# Important for WebSockets / hot reload etc.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Rewrite localhost MinIO URLs in API responses to use current domain
sub_filter 'http://localhost:9000/voice-audio/' 'https://$host/voice-audio/';
sub_filter_once off;
sub_filter_types application/json text/html;
}
location /voice-audio/ {
proxy_pass http://minio:9000/voice-audio/;
proxy_http_version 1.1;
# Headers for file downloads from MinIO
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Allow large file downloads
proxy_buffering off;
client_max_body_size 100M;
}
}
NGINX_EOF
} > nginx.conf
sed -i.tmp "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.tmp
echo -e "${GREEN}✓ nginx.conf regenerated${NC}"
###############################################################################
### Step 4 — turnserver.conf (regenerate from embedded template)
###############################################################################
echo -e "${BLUE}[4/5] Regenerating turnserver.conf...${NC}"
cat > turnserver.conf << TURN_EOF
# Coturn TURN Server - Docker Configuration
# Auto-generated by update_remote.sh
# Listener ports
listening-port=3478
tls-listening-port=5349
# Relay port range
min-port=49152
max-port=49200
# Network - external IP for NAT traversal
external-ip=$SERVER_IP
# Realm
realm=dograh.com
# Authentication (TURN REST API with time-limited credentials)
use-auth-secret
static-auth-secret=$TURN_SECRET
# Security
fingerprint
no-cli
no-multicast-peers
# Logging
log-file=stdout
TURN_EOF
echo -e "${GREEN}✓ turnserver.conf regenerated${NC}"
###############################################################################
### Step 5 — reconcile .env (append missing keys; never overwrite existing)
###############################################################################
echo -e "${BLUE}[5/5] Reconciling .env...${NC}"
if ! grep -q "^FASTAPI_WORKERS=" .env; then
{
echo ""
echo "# Number of uvicorn worker processes; nginx load-balances across them"
echo "# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn."
echo "FASTAPI_WORKERS=$FASTAPI_WORKERS"
} >> .env
echo -e "${GREEN}✓ Added FASTAPI_WORKERS=$FASTAPI_WORKERS to .env${NC}"
else
echo -e "${GREEN}✓ .env already has FASTAPI_WORKERS — left unchanged${NC}"
fi
###############################################################################
### Done — print restart + rollback instructions
###############################################################################
echo "" echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
@ -434,15 +230,14 @@ echo -e "${GREEN}╚════════════════════
echo "" echo ""
echo -e "Backups: ${BLUE}*.bak.$TIMESTAMP${NC}" echo -e "Backups: ${BLUE}*.bak.$TIMESTAMP${NC}"
echo "" echo ""
echo -e "${YELLOW}To apply, recreate the stack:${NC}" echo -e "${YELLOW}To apply, restart through the validated wrapper:${NC}"
echo "" echo ""
echo -e " ${BLUE}sudo docker compose --profile remote down${NC}" echo -e " ${BLUE}./remote_up.sh${NC}"
echo -e " ${BLUE}sudo docker compose --profile remote up -d --pull always${NC}"
echo "" echo ""
echo -e "${YELLOW}To roll back, restore the backups and recreate:${NC}" echo -e "${YELLOW}To roll back, restore the backups and re-run the wrapper:${NC}"
echo "" echo ""
echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env; do${NC}" echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do${NC}"
echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}" echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}"
echo -e " ${BLUE}done${NC}" echo -e " ${BLUE}done${NC}"
echo -e " ${BLUE}sudo docker compose --profile remote down && sudo docker compose --profile remote up -d${NC}" echo -e " ${BLUE}./remote_up.sh${NC}"
echo "" echo ""

View file

@ -62,15 +62,13 @@ const AppLayout: React.FC<AppLayoutProps> = ({
// Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes // Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth"); const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth");
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
// Only match the exact editor page /workflow/<id>, not sub-routes like /workflow/<id>/runs // Only match the exact editor page /workflow/<id>, not sub-routes like /workflow/<id>/runs
const isWorkflowEditor = /^\/workflow\/\d+$/.test(pathname); const isWorkflowEditor = /^\/workflow\/\d+$/.test(pathname);
const isSuperadmin = pathname.startsWith("/superadmin");
// Always render SidebarProvider to keep the component tree shape consistent // Always render SidebarProvider to keep the component tree shape consistent
// across route changes (avoids React hooks ordering violations during navigation). // across route changes (avoids React hooks ordering violations during navigation).
return ( return (
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}> <SidebarProvider defaultOpen>
{shouldShowSidebar ? ( {shouldShowSidebar ? (
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full">
<AppSidebar /> <AppSidebar />

View file

@ -14,6 +14,7 @@ import {
Home, Home,
Key, Key,
LogOut, LogOut,
type LucideIcon,
Megaphone, Megaphone,
Phone, Phone,
Settings, Settings,
@ -49,12 +50,7 @@ import {
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppConfig } from "@/context/AppConfigContext"; import { useAppConfig } from "@/context/AppConfigContext";
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext"; import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion"; import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion";
@ -62,6 +58,94 @@ import type { LocalUser } from "@/lib/auth";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type SidebarNavItem = {
title: string;
url: string;
icon: LucideIcon;
showsTelephonyWarning?: boolean;
};
type SidebarNavSection = {
label?: string;
items: SidebarNavItem[];
};
const TELEPHONY_WARNING_DEADLINE = "15 May 2026";
const TELEPHONY_WARNING_COPY = `Action required before ${TELEPHONY_WARNING_DEADLINE}`;
const NAV_SECTIONS: SidebarNavSection[] = [
{
items: [
{
title: "Overview",
url: "/overview",
icon: Home,
},
],
},
{
label: "BUILD",
items: [
{
title: "Voice Agents",
url: "/workflow",
icon: Workflow,
},
{
title: "Campaigns",
url: "/campaigns",
icon: Megaphone,
},
{
title: "Models",
url: "/model-configurations",
icon: Brain,
},
{
title: "Telephony",
url: "/telephony-configurations",
icon: Phone,
showsTelephonyWarning: true,
},
{
title: "Tools",
url: "/tools",
icon: Wrench,
},
{
title: "Files",
url: "/files",
icon: Database,
},
{
title: "Recordings",
url: "/recordings",
icon: AudioLines,
},
{
title: "Developers",
url: "/api-keys",
icon: Key,
},
],
},
{
label: "OBSERVE",
items: [
{
title: "Agent Runs",
url: "/usage",
icon: TrendingUp,
},
{
title: "Reports",
url: "/reports",
icon: FileText,
},
],
},
];
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context // Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() => const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({ import("@stackframe/stack").then((mod) => ({
@ -77,10 +161,7 @@ export function AppSidebar() {
const { config } = useAppConfig(); const { config } = useAppConfig();
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings(); const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0; const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0;
const isCollapsed = !isMobile && state === "collapsed";
// On mobile the sidebar renders as a full-width sheet overlay, so treat it
// as always "expanded" regardless of the desktop collapsed/expanded state.
const effectiveState = isMobile ? "expanded" : state;
// Get selected team for Stack auth (cast to Team type from Stack) // Get selected team for Stack auth (cast to Team type from Stack)
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes, // Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
@ -101,90 +182,7 @@ export function AppSidebar() {
{ enabled: config?.deploymentMode === "oss" }, { enabled: config?.deploymentMode === "oss" },
); );
const isActive = (path: string) => { const isActive = (path: string) => pathname.startsWith(path);
return pathname.startsWith(path);
};
// Organize navigation into sections
const overviewSection = [
{
title: "Overview",
url: "/overview",
icon: Home,
},
];
const buildSection = [
{
title: "Voice Agents",
url: "/workflow",
icon: Workflow,
},
{
title: "Campaigns",
url: "/campaigns",
icon: Megaphone,
},
// {
// title: "Automation",
// url: "/automation",
// icon: Zap,
// },
{
title: "Models",
url: "/model-configurations",
icon: Brain,
},
{
title: "Telephony",
url: "/telephony-configurations",
icon: Phone,
},
{
title: "Tools",
url: "/tools",
icon: Wrench,
},
{
title: "Files",
url: "/files",
icon: Database,
},
{
title: "Recordings",
url: "/recordings",
icon: AudioLines,
},
// {
// title: "Integrations",
// url: "/integrations",
// icon: Plug,
// },
{
title: "Developers",
url: "/api-keys",
icon: Key,
},
];
const observeSection = [
{
title: "Agent Runs",
url: "/usage",
icon: TrendingUp,
},
{
title: "Reports",
url: "/reports",
icon: FileText,
},
// {
// title: "LoopTalk",
// url: "/looptalk",
// icon: MessageSquare,
// },
];
const handleMobileNavClick = () => { const handleMobileNavClick = () => {
if (isMobile) { if (isMobile) {
@ -192,79 +190,65 @@ export function AppSidebar() {
} }
}; };
const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => { const SidebarLink = ({ item }: { item: SidebarNavItem }) => {
const isItemActive = isActive(item.url); const isItemActive = isActive(item.url);
const Icon = item.icon; const Icon = item.icon;
const showWarningDot = const showWarningDot = item.showsTelephonyWarning && hasTelephonyWarning;
item.url === "/telephony-configurations" && hasTelephonyWarning; const tooltip = {
children: (
if (effectiveState === "collapsed") { <div className="notranslate" translate="no">
return ( <p>{item.title}</p>
<TooltipProvider delayDuration={0}> {showWarningDot && (
<Tooltip> <p className="text-amber-600 dark:text-amber-400">{TELEPHONY_WARNING_COPY}</p>
<TooltipTrigger asChild> )}
<SidebarMenuButton </div>
asChild ),
className={cn( };
"hover:bg-accent hover:text-accent-foreground", const warningIndicator = (
isItemActive && "bg-accent text-accent-foreground" <AlertTriangle
)} aria-label={`Action required on a telephony configuration before ${TELEPHONY_WARNING_DEADLINE}`}
> className={cn(
<Link href={item.url} onClick={handleMobileNavClick} className="relative"> "text-amber-500",
<Icon className="h-4 w-4" /> isCollapsed ? "absolute -right-0.5 -top-0.5 h-3 w-3" : "ml-auto h-3.5 w-3.5"
{showWarningDot && ( )}
<AlertTriangle />
aria-hidden );
className="absolute -right-0.5 -top-0.5 h-3 w-3 text-amber-500"
/>
)}
<span className="sr-only">
{item.title}
{showWarningDot && " — action required before 15 May 2026"}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>
{item.title}
{showWarningDot && (
<span className="block text-amber-600 dark:text-amber-400">
Action required before 15 May 2026
</span>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return ( return (
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={tooltip}
className={cn( className={cn(
"hover:bg-accent hover:text-accent-foreground", "hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground" isItemActive && "bg-accent text-accent-foreground"
)} )}
> >
<Link href={item.url} onClick={handleMobileNavClick}> <Link
<Icon className="h-4 w-4" /> href={item.url}
<span>{item.title}</span> onClick={handleMobileNavClick}
className={cn("relative", isCollapsed && "justify-center")}
translate="no"
>
<Icon className="h-4 w-4 shrink-0" />
<span
className={cn("notranslate min-w-0 flex-1 truncate", isCollapsed && "sr-only")}
translate="no"
>
{item.title}
</span>
{showWarningDot && ( {showWarningDot && (
<TooltipProvider delayDuration={0}> isCollapsed ? (
warningIndicator
) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<AlertTriangle {warningIndicator}
aria-label="Action required on a telephony configuration before 15 May 2026"
className="ml-auto h-3.5 w-3.5 text-amber-500"
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Action required before 15 May 2026</p> <p>{TELEPHONY_WARNING_COPY}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> )
)} )}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
@ -273,77 +257,70 @@ export function AppSidebar() {
return ( return (
<Sidebar collapsible="icon" className="border-r"> <Sidebar collapsible="icon" className="border-r">
<SidebarHeader className="border-b px-2 py-3"> <SidebarHeader className="border-b px-2 py-3 notranslate" translate="no">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Logo - only show when expanded */} <div className={cn("flex items-center gap-2", isCollapsed && "hidden")}>
{effectiveState === "expanded" && ( <Link
<div className="flex items-center gap-2"> href="/"
<Link className="notranslate flex items-center gap-2 px-2 text-xl font-bold"
href="/" translate="no"
className="flex items-center gap-2 px-2 text-xl font-bold" >
> Dograh
Dograh {versionInfo && (
{versionInfo && ( <span
<span className="text-xs font-normal text-muted-foreground"> className="notranslate text-xs font-normal text-muted-foreground"
v{versionInfo.ui} translate="no"
>
v{versionInfo.ui}
</span>
)}
</Link>
{isBehind && latestRelease && (
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://docs.dograh.com/deployment/update"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-900 transition-opacity hover:opacity-80 dark:bg-amber-950 dark:text-amber-200"
>
<ArrowUpCircle className="h-3 w-3" />
Update
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Latest: {latestRelease} click to see the update guide</p>
</TooltipContent>
</Tooltip>
)}
{isLatest && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded-md border bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">
Latest
</span> </span>
)} </TooltipTrigger>
</Link> <TooltipContent side="bottom">
{isBehind && latestRelease && ( <p>You&apos;re running the latest release</p>
<TooltipProvider delayDuration={0}> </TooltipContent>
<Tooltip> </Tooltip>
<TooltipTrigger asChild> )}
<a </div>
href="https://docs.dograh.com/deployment/update"
target="_blank" <SidebarTrigger className={cn("hover:bg-accent", isCollapsed && "mx-auto")}>
rel="noopener noreferrer" {isCollapsed ? (
className="inline-flex items-center gap-1 rounded-md border bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-900 transition-opacity hover:opacity-80 dark:bg-amber-950 dark:text-amber-200"
>
<ArrowUpCircle className="h-3 w-3" />
Update
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Latest: {latestRelease} click to see the update guide</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLatest && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded-md border bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">
Latest
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>You&apos;re running the latest release</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{/* Toggle button - center it when collapsed */}
<SidebarTrigger className={cn(
"hover:bg-accent",
effectiveState === "collapsed" && "mx-auto"
)}>
{effectiveState === "expanded" ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)} )}
</SidebarTrigger> </SidebarTrigger>
</div> </div>
{/* Team Switcher for Stack Auth - at the top */} {provider === "stack" && (
{provider === "stack" && effectiveState === "expanded" && ( <div className={cn("mt-3 notranslate", isCollapsed && "hidden")} translate="no">
<div className="mt-3">
<React.Suspense <React.Suspense
fallback={ fallback={
<div className="h-9 w-full animate-pulse bg-muted rounded" /> <div className="h-9 w-full animate-pulse rounded bg-muted" />
} }
> >
<StackTeamSwitcher <StackTeamSwitcher
@ -355,73 +332,46 @@ export function AppSidebar() {
</React.Suspense> </React.Suspense>
</div> </div>
)} )}
</SidebarHeader> </SidebarHeader>
<SidebarContent className={cn( <SidebarContent className={cn("notranslate", isCollapsed && "px-0")} translate="no">
effectiveState === "collapsed" && "px-0" {NAV_SECTIONS.map((section, index) => (
)}> <SidebarGroup
{/* Overview Section */} key={section.label ?? "overview"}
<SidebarGroup className="mt-2"> className={index === 0 ? "mt-2" : "mt-6"}
<SidebarMenu> >
{overviewSection.map((item) => ( {section.label && (
<SidebarMenuItem key={item.title}> <SidebarGroupLabel
<SidebarLink item={item} /> className={cn(
</SidebarMenuItem> "notranslate text-xs font-semibold uppercase tracking-wider text-muted-foreground",
))} isCollapsed && "hidden"
</SidebarMenu> )}
</SidebarGroup> translate="no"
>
{/* BUILD Section */} {section.label}
{buildSection.length > 0 && (
<SidebarGroup className="mt-6">
{effectiveState === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
BUILD
</SidebarGroupLabel> </SidebarGroupLabel>
)} )}
<SidebarMenu> <SidebarMenu>
{buildSection.map((item) => ( {section.items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarLink item={item} /> <SidebarLink item={item} />
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
)} ))}
{/* OBSERVE Section */}
<SidebarGroup className="mt-6">
{effectiveState === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
OBSERVE
</SidebarGroupLabel>
)}
<SidebarMenu>
{observeSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className={cn( <SidebarFooter
"border-t p-4", className={cn("border-t p-4 notranslate", isCollapsed && "p-2")}
effectiveState === "collapsed" && "p-2" translate="no"
)}> >
{/* Bottom Actions */}
<div className="space-y-2"> <div className="space-y-2">
{/* User Button - for local/OSS mode */}
{provider !== "stack" && ( {provider !== "stack" && (
<div className={cn( <div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
"flex",
effectiveState === "collapsed" ? "justify-center" : "justify-start"
)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer"> <Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{(user?.displayName || (user as LocalUser | undefined)?.email || "") {(user?.displayName || (user as LocalUser | undefined)?.email || "")
.split(/[\s@]/) .split(/[\s@]/)
@ -455,15 +405,11 @@ export function AppSidebar() {
</div> </div>
)} )}
{/* User Button - for Stack auth */}
{provider === "stack" && ( {provider === "stack" && (
<div className={cn( <div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
"flex",
effectiveState === "collapsed" ? "justify-center" : "justify-start"
)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer"> <Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "") {(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "")
.split(/[\s@]/) .split(/[\s@]/)
@ -508,35 +454,30 @@ export function AppSidebar() {
</div> </div>
)} )}
{/* Theme Toggle - at the very bottom */} <div className={cn("mt-2 border-t pt-2", isCollapsed && "flex justify-center")}>
<div className={cn( {isCollapsed ? (
"mt-2 pt-2 border-t", <Tooltip>
effectiveState === "collapsed" ? "flex justify-center" : "" <TooltipTrigger asChild>
)}> <div className="notranslate" translate="no">
{effectiveState === "collapsed" ? ( <ThemeToggle
<TooltipProvider delayDuration={0}> showLabel={false}
<Tooltip> className="hover:bg-accent hover:text-accent-foreground"
<TooltipTrigger asChild> />
<div> </div>
<ThemeToggle </TooltipTrigger>
showLabel={false} <TooltipContent side="right">
className="hover:bg-accent hover:text-accent-foreground" <p>Toggle theme</p>
/> </TooltipContent>
</div> </Tooltip>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : ( ) : (
<ThemeToggle <div className="notranslate" translate="no">
showLabel={true} <ThemeToggle
className="hover:bg-accent hover:text-accent-foreground" showLabel={true}
/> className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
)} )}
</div> </div>
</div> </div>
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />