End-to-end runbook for hosting the platform on DigitalOcean. Each environment is one Droplet running the full docker compose stack, fronted by a reserved IP, Cloudflare DNS, a cloud firewall, and a Caddy TLS edge (real Let's Encrypt certs). The voice agent runs on LiveKit Cloud. dev keeps its data stores in the box; prod adds Managed Postgres + Spaces. This replaces the earlier temporary GMKtec/ngrok staging host, which has been retired.
Five concerns split across three locations. Every cross-boundary call is either an outbound HTTPS request from the Droplet or an inbound call to a public hostname that Caddy terminates over TLS.
nginx (frontend static build), the Go backend API, the Graphiti knowledge-graph FastAPI, plus the data stores. Everything runs as containers under one docker compose file (deploy/docker-compose.do-dev.yml) at /opt/ambasdr/, with persistent state under /data/ambasdr/. A Caddy container is the only thing listening on the public internet (ports 80/443).
The Python livekit-agent runs as a LiveKit Cloud Agent (deployed via lk cloud agent). It pulls the composed system prompt from the backend at session start and uses Deepgram / ElevenLabs directly. The frontend connects browsers to LiveKit rooms over the public LiveKit Cloud WSS URL.
Auth0 (login + provisioning callback), Stripe (subscription + webhook), the LLM provider (OpenAI-compatible — chat + agent prompt generation), Anthropic (Graphiti entity extraction), Deepgram (STT), ElevenLabs (TTS), Google Gemini (frontend chat preview).
The old staging host bridged the home LAN to the internet with two ngrok tunnels. On DigitalOcean the Droplet has a real public reserved IP, so the two public hostnames (frontend + backend API) are ordinary Cloudflare DNS A records pointing at the box, and the in-box Caddy obtains and renews their Let's Encrypt certificates automatically. Grafana is published separately through a Cloudflare Tunnel + Access (see §12); nothing else is exposed.
| Dimension | dev | prod |
|---|---|---|
| Droplet size | s-2vcpu-4gb | s-4vcpu-8gb, Droplet backups on |
| Frontend hostname | dev.ambasdr.com | apex ambasdr.com (or app.ambasdr.com — see §03 cutover) |
| API hostname | api.dev.ambasdr.com | api.ambasdr.com |
| Postgres | In-box container, bind-mount | DigitalOcean Managed Postgres (backups + PITR) |
| Object storage | In-box MinIO | DigitalOcean Spaces (ambasdr-prod-documents) |
| Neo4j | In-box container | In-box container (same) |
| Grafana access | grafana-dev.ambasdr.com | grafana.ambasdr.com |
| Continuous deploy | .github/workflows/deploy-dev.yml (built) | Not built yet — clone deploy-dev.yml when prod lands (§17) |
Provisioning and deployment are deliberately separated into two repositories. Knowing which one owns a given concern removes most of the ambiguity in the rest of this page.
Terraform + cloud-init. One root config and one reusable environment module; environments are pure data (a .tfvars + an .s3.tfbackend). It creates the Droplet, reserved IP, VPC + cloud firewall, Cloudflare DNS records, and — for prod — Managed Postgres and Spaces. cloud-init installs Docker, creates /opt/ambasdr and /data/ambasdr/*, and stages a GitHub Actions runner.
deploy/ holds the compose file, Caddyfile, Postgres init script, and observability config. The make do-* targets sync those to the box and run the stack. .github/workflows/deploy-dev.yml is the continuous-deploy pipeline.
Infra emits the reserved IP and FQDNs (make output ENV=dev). You point the app repo's deploy at them, run make do-bootstrap (ghcr login + runner registration), place the .env.* secrets on the box, and deploy.
dev = the ambasdr-dev Droplet (in-box data stores) — the always-on shared instance the team builds against. prod = a larger Droplet with Managed Postgres + Spaces, built later by make apply ENV=prod. The old staging GMKtec/ngrok host on the home LAN has been retired; its runbook is preserved only in git history.
From the ambasdr-infra repo. The Makefile wires the matching backend-config + var-file from ENV automatically, so you can't apply one environment's config against another's state.
| Item | Why / how |
|---|---|
| OpenTofu ≥ 1.10 (or Terraform ≥ 1.10) | Native S3 state locking. The Makefile defaults to tofu; override with TF=terraform. (System Terraform 1.5.7 is too old.) |
| DigitalOcean API token | export TF_VAR_do_token=... |
| Spaces access key pair | Used both for the Terraform state backend (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) and, for prod, managing the Spaces bucket (TF_VAR_spaces_access_id / TF_VAR_spaces_secret_key). |
| Cloudflare API token | Zone:DNS:Edit on the ambasdr.com zone — export TF_VAR_cloudflare_api_token=.... ambasdr.com is authoritative on Cloudflare. |
| SSH key in DigitalOcean | Uploaded key ambasdr-do-dev (ID 57069717); private key at ~/.ssh/ambasdr_dev_ed25519. Its ID is in each env's tfvars. |
| State bucket | One-time: cd bootstrap && terraform init && terraform apply creates the shared Spaces state bucket. |
# From ambasdr-infra/. State creds in the env.
export AWS_ACCESS_KEY_ID=<spaces_access_id>
export AWS_SECRET_ACCESS_KEY=<spaces_secret_key>
make plan ENV=dev # preview
make apply ENV=dev # create Droplet + reserved IP + DNS + firewall
make output ENV=dev # reserved_ip, frontend_fqdn, api_fqdn
What an apply creates: a VPC + cloud firewall (rules target by tag), the Droplet + a reserved IP assigned to it, and the Cloudflare A records for the frontend and API hostnames. For prod it additionally creates the Managed Postgres cluster (with the ambasdr and ambasdr_knowledge_graph databases) and the Spaces bucket. The conditional modules are gated by managed_postgres_enabled / spaces_enabled, so dev provisions nothing it doesn't use.
The frontend/API A records are created with cloudflare_proxied = false. They resolve straight to the Droplet's reserved IP rather than through Cloudflare's proxy, which is what lets the in-box Caddy complete the Let's Encrypt HTTP-01 challenge on port 80 and serve its own certs (§06). Grafana is the exception — it is proxied, because it goes through the Cloudflare Tunnel (§12).
prod.tfvars sets subdomain = "", which makes the frontend record the apex ambasdr.com. Today the apex is a proxied CNAME to the Firebase-hosted marketing site (trek-record.web.app). Before make apply ENV=prod, decide the cutover: either move the marketing site off the apex, or serve the prod app from a subdomain (set subdomain = "app" → app.ambasdr.com). Applying as-is would create an apex A record that conflicts with the existing Firebase record.
Four app images plus the off-the-shelf data-store images. All three app Dockerfiles already exist in the repo; CI builds and pushes them to ghcr.io/juelz-ai tagged :dev (plus a SHA-tagged variant for rollback). The Droplet only ever pulls — it never builds.
| Image | Source | Notes |
|---|---|---|
ambasdr-backend |
backend/Dockerfile — multistage golang:1.23 → distroless/static. |
Single binary /api, reads config from environment variables. The build mounts a BuildKit secret (github_token) to clone the private juelz111/gommon dep; keep .env.example tracked so the //go:embed .env* directive resolves. |
ambasdr-frontend |
frontend/Dockerfile — node:20 build → nginx:alpine. Build context is the repo root (needs the packages/ workspace). |
Vite inlines VITE_* at build time, so the image is environment-specific — a dev build cannot be promoted to prod; prod rebuilds with VITE_API_URL=https://api.ambasdr.com. |
ambasdr-knowledge-graph |
knowledge-graph/Dockerfile. |
FastAPI on port 8050. Speaks to Neo4j / Postgres / object storage over the compose network only — never exposed publicly. |
| Data stores (postgres, neo4j, minio) | Docker Hub images, pinned in docker-compose.do-dev.yml. |
dev runs all three in-box. prod drops postgres + minio (uses Managed PG + Spaces) and keeps neo4j. |
ambasdr-voice-agent |
livekit-agent/Dockerfile — but not run on the Droplet. |
Deployed to LiveKit Cloud via the lk CLI (§09). The Droplet never runs voice media. |
Each lives at /opt/ambasdr/.env.*, mode 600, never committed. The compose file reads .env.host via --env-file (for ${…} substitutions) and the per-service files via env_file:. The dev-only values that change for prod are called out.
Datastore credentials and the Caddy/Cloudflare settings the compose file substitutes. Template: deploy/.env.host.example.
REGISTRY=ghcr.io/juelz-ai
IMAGE_TAG=dev
PG_PASSWORD=...rotated... # dev only (prod uses Managed PG creds)
NEO4J_PASSWORD=...rotated...
MINIO_USER=... # dev only (prod uses Spaces keys)
MINIO_PASSWORD=...rotated...
GRAFANA_ADMIN_PASSWORD=...rotated...
# Caddy / TLS
FRONTEND_DOMAIN=dev.ambasdr.com # prod: ambasdr.com (or app.ambasdr.com)
API_DOMAIN=api.dev.ambasdr.com # prod: api.ambasdr.com
ACME_EMAIL=ops@ambasdr.com
# Grafana team access via Cloudflare Tunnel + Access (see §12)
GRAFANA_DOMAIN=grafana-dev.ambasdr.com # prod: grafana.ambasdr.com
CLOUDFLARED_TUNNEL_TOKEN=...secret... # per-box tunnel token
| Variable | dev value (prod difference) |
|---|---|
SERVER_PORT | 9080 (matches the container port Caddy proxies to). |
ENVIRONMENT / GIN_MODE | dev / release. |
DB_HOST / DB_PORT | postgres / 5432 (compose-internal). prod: the Managed Postgres host + port from the Terraform output, sslmode=require. |
DB_PASSWORD | Matches PG_PASSWORD in .env.host. prod: the Managed PG role password. |
AUTH0_DOMAIN / AUTH0_AUDIENCE | Auth0 tenant; audience is the logical API identifier https://api.ambasdr.com (an identifier, not a URL — same across environments). |
AUTH0_M2M_SECRET | Shared secret validating the Post-Login Action's HMAC. Rotate per environment; mirror into the Auth0 Action. |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PRICE_ID | Test-mode for dev. A new whsec_… issued for the dev webhook at https://api.dev.ambasdr.com/webhooks/stripe (§08). prod: live-mode keys + a live webhook. |
DO_SPACES_* | dev points at in-box MinIO: DO_SPACES_ENDPOINT=http://minio:9000, DO_SPACES_PUBLIC_URL_BASE=https://api.dev.ambasdr.com/v1. prod: the Spaces endpoint + bucket + keys. |
KG_SERVICE_URL | http://knowledge-graph:8050 (compose hostname). |
ALLOWED_ORIGINS | https://dev.ambasdr.com. prod: the prod frontend origin. |
LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET / AGENT_API_KEY | From the LiveKit Cloud project. AGENT_API_KEY must equal AMBASDR_API_KEY in the agent's secrets (§09). |
LLM_API_KEY / LLM_BASE_URL | OpenAI-compatible; ideally a per-environment project for attributable cost. |
| Variable | dev value (prod difference) |
|---|---|
ANTHROPIC_API_KEY | Required — Graphiti uses Claude for entity extraction. |
OPENAI_API_KEY | Required — embeddings + reranking. |
POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB | postgres / 5432 / ambasdr_knowledge_graph. prod: the Managed PG host + the ambasdr_knowledge_graph database created by Terraform. |
NEO4J_URI / NEO4J_PASSWORD | bolt://neo4j:7687; password matches the compose NEO4J_PASSWORD. |
SPACES_ENDPOINT / SPACES_BUCKET | http://minio:9000 / ambasdr-documents. prod: the Spaces endpoint + ambasdr-prod-documents. |
Frontend values are baked into the Vite bundle by CI as --build-args sourced from the dev GitHub Environment variables — not from an env file at runtime: VITE_AUTH0_DOMAIN, VITE_AUTH0_CLIENT_ID, VITE_AUTH0_AUDIENCE, VITE_API_URL (=https://api.dev.ambasdr.com), and the optional VITE_GEMINI_API_KEY / VITE_STRIPE_PAYMENT_LINK.
The shared local-dev secrets must not carry into a public box. Generate fresh values for at least PG_PASSWORD, NEO4J_PASSWORD, MINIO_PASSWORD, GRAFANA_ADMIN_PASSWORD, AUTH0_M2M_SECRET, and AGENT_API_KEY. The CLOUDFLARED_TUNNEL_TOKEN is per-box and never reused across environments.
Two public hostnames per environment, both terminated by one Caddy container. Caddy reads the hostnames and ACME contact from the environment and obtains + renews Let's Encrypt certs automatically over HTTP-01, redirecting HTTP → HTTPS.
# deploy/caddy/Caddyfile (domains injected from .env.host)
{
email {$ACME_EMAIL}
}
# Frontend SPA (nginx serves the static bundle on :8080 inside the network).
{$FRONTEND_DOMAIN} {
encode zstd gzip
reverse_proxy frontend:8080
}
# Backend API (Gin on :9080 inside the network).
{$API_DOMAIN} {
encode zstd gzip
reverse_proxy backend:9080
}
How the request flows: a browser resolves dev.ambasdr.com (a grey-cloud Cloudflare A record from §03) directly to the Droplet's reserved IP → Caddy on port 443 terminates TLS → Caddy reverse-proxies over the internal Docker network to frontend:8080 or backend:9080. backend and frontend are bound only to 127.0.0.1 on the box (for on-box health checks); the public path is exclusively through Caddy.
If the A records were proxied (orange cloud), Cloudflare would answer port 80 itself and Caddy's HTTP-01 challenge would never reach the origin. Keeping them DNS-only lets Caddy own its certificates. This is the inverse of Grafana (§12), which is proxied precisely because it has no origin port open.
A per-environment SPA Application (or tenant) is cleanest. Four settings must land before the frontend can complete a login round-trip. Substitute the environment's real frontend/API hostnames below (dev.ambasdr.com / api.dev.ambasdr.com for dev).
| Auth0 setting | Value |
|---|---|
| SPA → Allowed Callback URLs | https://dev.ambasdr.com and https://dev.ambasdr.com/callback. |
| SPA → Allowed Logout URLs | https://dev.ambasdr.com. |
| SPA → Allowed Web Origins / CORS | https://dev.ambasdr.com. |
Post-Login Action → Secret PROVISION_URL | https://api.dev.ambasdr.com/internal/users/provision. The Action POSTs here on first login. |
Post-Login Action → Secret M2M_SHARED_SECRET | The same rotated value as the backend's AUTH0_M2M_SECRET. |
The Auth0 CLI scripts this faster than the dashboard:
auth0 apps update <client_id> \
--callbacks "https://dev.ambasdr.com,https://dev.ambasdr.com/callback" \
--logout-urls "https://dev.ambasdr.com" \
--origins "https://dev.ambasdr.com"
auth0 actions update <action_id> \
--secret PROVISION_URL=https://api.dev.ambasdr.com/internal/users/provision \
--secret M2M_SHARED_SECRET=<new_secret>
auth0 actions deploy <action_id>
dev stays in Stripe test mode. A webhook endpoint is needed at the dev API hostname:
stripe webhook_endpoints create \
--url=https://api.dev.ambasdr.com/webhooks/stripe \
--enabled-events=customer.subscription.created \
--enabled-events=customer.subscription.updated \
--enabled-events=customer.subscription.deleted \
--enabled-events=invoice.payment_succeeded \
--enabled-events=invoice.payment_failed \
--description="Ambasdr dev (DigitalOcean)"
# The response's `secret` (whsec_...) is STRIPE_WEBHOOK_SECRET for .env.backend.
prod: switch to live-mode keys and create a fresh live webhook at https://api.ambasdr.com/webhooks/stripe. The signing secret survives URL changes (stripe webhook_endpoints update <we_…> --url=…), but live and test are separate endpoints with separate secrets.
The Python agent in livekit-agent/ runs on LiveKit Cloud, independent of the Droplet. It needs the LiveKit project API key/secret (injected by LiveKit), the public backend URL (to fetch voice prompts), and the AGENT_API_KEY (to authenticate to the backend). Use a separate LiveKit Cloud project per environment.
brew install livekit-cli, then lk cloud auth binds the CLI to your LiveKit Cloud project via a browser flow.
From livekit-agent/: lk cloud agent create. Name it per environment, e.g. ambasdr-voice-dev.
lk cloud agent secrets set ambasdr-voice-dev \
AMBASDR_SERVICE_URL=https://api.dev.ambasdr.com \
AMBASDR_API_KEY=<same value as backend AGENT_API_KEY> \
DEEPGRAM_API_KEY=<...> \
ELEVEN_API_KEY=<...>
Do not set LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET — LiveKit Cloud injects those automatically.
lk cloud agent deploy ambasdr-voice-dev builds in LiveKit's farm and rolls out. Tail with lk cloud agent logs ambasdr-voice-dev -f.
The agent calls GET /v1/agent/sessions/{page_id}/voice-prompt, POST /v1/agent/knowledge/search, and POST /v1/agent/sessions/{page_id}/finalize, all authenticated by the shared AGENT_API_KEY. If the agent's AMBASDR_API_KEY doesn't equal the backend's AGENT_API_KEY, the session fails to start and the frontend sees an empty voice room. The agent deploy is manual — there is no CI job for it.
Order matters: provision, finish the box, place secrets, then bring the stack up. The make do-* targets reach the box over SSH — set DO_HOST to the box (e.g. the ambasdr-dev SSH alias, or root@<reserved-ip>).
make apply ENV=dev, then make output ENV=dev for the reserved IP + FQDNs. cloud-init has already installed Docker (pinned to the overlay2 storage driver), created /opt/ambasdr + /data/ambasdr/*, and staged the Actions runner binary.
make do-bootstrap DO_HOST=root@<reserved-ip> \
GHCR_USER=<user> GHCR_PAT=<pat> RUNNER_TOKEN=<repo-runner-token>
This logs Docker into ghcr.io and registers the self-hosted runner with the do-dev label.
scp deploy/.env.host root@<ip>:/opt/ambasdr/.env.host
scp deploy/.env.backend root@<ip>:/opt/ambasdr/.env.backend
scp deploy/.env.kg root@<ip>:/opt/ambasdr/.env.kg
Built from the examples and rotated per §05. Never committed.
make do-deploy DO_HOST=root@<reserved-ip> rsyncs deploy/ to the box, pulls the :dev images, and runs docker compose up -d. Caddy obtains the Let's Encrypt certs on first start; give it a few seconds.
The continuous-deploy pipeline runs migrations automatically (§11). For the very first bring-up, run the same one-shot from the box (or let the first push to main do it):
ssh root@<ip> 'docker run --rm \
-v /opt/ambasdr/migrations:/migrations \
--network ambasdr_internal migrate/migrate \
-path /migrations \
-database "postgres://ambasdr:$PG_PASSWORD@postgres:5432/ambasdr?sslmode=disable" up'
lk cloud agent deploy ambasdr-voice-dev (§09). Then open https://dev.ambasdr.com in a private window: sign up → Auth0 completes → onboarding → create + publish a page → visit as a visitor → voice mode responds. See the verification matrix in §13.
After the first deploy, pushes to main deploy automatically. .github/workflows/deploy-dev.yml detects which service trees changed, builds + pushes only those images to ghcr.io/juelz-ai, applies migrations when needed, then pulls + recreates the matching containers on the box via the self-hosted do-dev runner, and finally smokes the public URLs.
| Job | Runner | What it does |
|---|---|---|
changes | cloud | dorny/paths-filter flags backend / frontend / knowledge_graph / migrations from the touched paths (packages/** triggers both backend and frontend). |
build-backend | cloud | build-push-action → :dev + :dev-<sha>. Stages GOMMON_PAT as a BuildKit secret file for the private gommon dep; GHA layer cache. |
build-frontend | cloud | Context = repo root. Reads VITE_* from the dev Environment variables as build args (baked into the bundle). |
build-knowledge-graph | cloud | Standard build from ./knowledge-graph. |
migrate | self-hosted (do-dev) | rsyncs backend/db/migrations/ to /opt/ambasdr/migrations/, runs the migrate/migrate container against Postgres on ambasdr_internal. Triggers when backend or migrations changed; uses the DEV_PG_PASSWORD secret. |
deploy-backend / -frontend / -knowledge-graph | self-hosted (do-dev) | docker compose pull && up -d <service>, then a loopback health probe. deploy-backend waits for migrate. |
smoke | cloud | curls https://api.dev.ambasdr.com/health and https://dev.ambasdr.com/. |
Force-build specific services without a code change:
gh workflow run deploy-dev.yml -R Juelz-AI/ambasdr --ref main -f services=backend
gh workflow run deploy-dev.yml -R Juelz-AI/ambasdr --ref main -f services=frontend,backend
Every push also tags :dev-<commit-sha>. Pin a known-good SHA on the box — no compose edit needed:
ssh ambasdr-dev
cd /opt/ambasdr
docker images ghcr.io/juelz-ai/ambasdr-backend # list SHA tags
IMAGE_TAG=dev-<old-sha> docker compose \
-f docker-compose.do-dev.yml --env-file .env.host up -d backend
If CI is down, make do-deploy DO_HOST=ambasdr-dev syncs and rolls the whole stack; make do-logs DO_HOST=ambasdr-dev SVC=backend tails one service. Migrations have to be additive (expand/contract) because the backend image rolls right after the migration.
Loki + Promtail (logs), cAdvisor + Prometheus (container metrics), and Grafana run alongside the app. Promtail discovers every container through the Docker socket and ships its stdout/stderr to Loki with labels attached automatically; cAdvisor reads host cgroups for per-container CPU/memory/network and Prometheus scrapes it.
| Surface | How to reach it |
|---|---|
| Grafana (team — logs + dashboards) | https://grafana-dev.ambasdr.com (prod: grafana.ambasdr.com). Published by an outbound-only cloudflared tunnel and gated by Cloudflare Access — log in with the email one-time PIN sent to any @juelz.ai address. No SSH. |
| Grafana (admin / break-glass) | make do-grafana DO_HOST=ambasdr-dev → http://localhost:3001, log in as admin with GRAFANA_ADMIN_PASSWORD. For outages or local-account changes only. |
| Prometheus (raw metrics / scrape targets) | make do-prometheus DO_HOST=ambasdr-dev → http://localhost:9090. Loopback-only; not routed through the tunnel. |
| Loki HTTP API | Internal on the compose network at http://loki:3100. Query through Grafana's Explore (the Loki datasource is the default). |
# All backend logs
{compose_service="backend"}
# Backend errors only
{compose_service="backend"} | json | level="error"
# A single request flow (paste a request_id)
{compose_service="backend"} | json | request_id="<uuid>"
# All ambasdr services
{compose_project="ambasdr"}
# Knowledge graph errors and exceptions
{compose_service="knowledge-graph"} |~ "(?i)error|exception"
The dedicated container-monitoring page covers the cAdvisor → Prometheus → Grafana pipeline, the "Containers (cAdvisor)" dashboard, the Cloudflare Tunnel + Access setup, and the overlay2 storage-driver requirement in depth.
| Check | How / expected result |
|---|---|
| Backend liveness (public) | curl -sf https://api.dev.ambasdr.com/health → 200 JSON {"status":"ok"}. |
| Frontend reachable (public) | curl -sfI https://dev.ambasdr.com/ → 200. |
| TLS certs issued | curl -sv https://dev.ambasdr.com/ 2>&1 | grep issuer shows a Let's Encrypt issuer. If it fails, check make do-logs SVC=caddy for an ACME/HTTP-01 error (usually a proxied DNS record). |
| Backend → DB | After a signup: docker compose exec postgres psql -U ambasdr -d ambasdr -c "SELECT count(*) FROM users;" returns ≥1 (dev). prod: query the Managed PG. |
| Backend → Graphiti | docker exec ambasdr-backend-1 wget -qO- http://knowledge-graph:8050/health → ok. |
| Auth0 provisioning | Auth0 → Monitoring → Logs after a fresh signup: a successful Action entry means the M2M secret matched and the backend created the user. |
| LiveKit Cloud → backend | lk cloud agent logs ambasdr-voice-dev -f shows "fetched voice prompt"; backend logs show GET /v1/agent/sessions/.../voice-prompt 200. |
| Stripe webhook | Stripe → Webhooks → the dev endpoint → "Send test webhook" (customer.subscription.created): backend logs the processed event; the dashboard shows a 200. |
dev keeps its data in-box, so a small daily cron is the safety net. prod leans on managed backups: Managed Postgres has automated daily backups + point-in-time recovery, and Droplet backups are enabled in prod.tfvars; only Neo4j (still in-box on prod) needs the cron dump.
# /etc/cron.daily/ambasdr-backup (dev box)
#!/bin/bash
set -e
DEST=/data/ambasdr/backups/$(date +%Y%m%d)
mkdir -p $DEST
# Postgres logical dump (both ambasdr and ambasdr_knowledge_graph).
docker compose -f /opt/ambasdr/docker-compose.do-dev.yml \
exec -T postgres pg_dumpall -U ambasdr | gzip > $DEST/postgres.sql.gz
# Neo4j online dump.
docker compose -f /opt/ambasdr/docker-compose.do-dev.yml \
exec -T neo4j neo4j-admin database dump neo4j --to-stdout | gzip > $DEST/neo4j.dump.gz
# MinIO bucket mirror (dev only; prod documents live in Spaces).
docker compose -f /opt/ambasdr/docker-compose.do-dev.yml \
exec -T minio mc mirror --overwrite local/ambasdr-documents $DEST/minio/
# Retention: keep last 14 days.
find /data/ambasdr/backups -maxdepth 1 -mindepth 1 -type d -mtime +14 -exec rm -rf {} +
Day-to-day operation is hands-off. If the Droplet reboots, the full stack comes back automatically: every service declares restart: unless-stopped, Docker's systemd unit is enabled, and cloud-init re-asserts data-dir ownership on each boot.
| Component | Auto-restart? | Mechanism |
|---|---|---|
| Docker daemon | Yes | Ubuntu's docker.service (enabled by cloud-init). |
| All app + data + observability containers | Yes | restart: unless-stopped on every service in docker-compose.do-dev.yml. |
| Caddy (TLS edge) | Yes | Same policy. Certs persist on the caddy_data volume, so no re-issue on reboot. |
cloudflared (Grafana tunnel) | Yes | Same policy; dials out and reclaims the tunnel automatically. |
| Data-dir ownership | Yes | ambasdr-data-perms.service (from cloud-init) re-chowns the Postgres/Loki/Grafana/Neo4j bind-mounts before Docker starts, so a bare reboot can't break startup with a permission error. |
| LiveKit Cloud agent | N/A | Runs on LiveKit infrastructure, independent of the Droplet. |
cAdvisor cannot read per-container metrics under Docker's newer overlayfs (containerd-snapshotter) image store — the default on Docker 28/29 — so the container dashboard would read empty. cloud-init pins the box to overlay2 via /etc/docker/daemon.json. If container panels are empty across all containers, check docker info | grep Storage first. Details on the monitoring page.
ssh ambasdr-dev
sudo systemctl status docker # is Docker up?
cd /opt/ambasdr
docker compose -f docker-compose.do-dev.yml --env-file .env.host up -d # idempotent
docker compose -f docker-compose.do-dev.yml --env-file .env.host ps
| Symptom | Likely cause / fix |
|---|---|
Browser shows a TLS error on dev.ambasdr.com | Caddy couldn't complete ACME. Most often the A record is proxied (orange cloud) — flip it to DNS-only — or port 80 is blocked by the cloud firewall. Check make do-logs SVC=caddy. |
| Frontend loads but every API call is 401 | Auth0 audience mismatch or the SPA Client ID points at the wrong API. Verify the built VITE_AUTH0_AUDIENCE equals the backend's AUTH0_AUDIENCE. |
| Login succeeds, dashboard shows "user not found" | The Auth0 Action's PROVISION_URL points at the wrong host, or M2M_SHARED_SECRET doesn't match the backend's AUTH0_M2M_SECRET. |
| Voice mode joins but no agent/audio | (a) AGENT_API_KEY mismatch; (b) the agent's AMBASDR_SERVICE_URL is stale; (c) Deepgram/ElevenLabs key invalid. Check lk cloud agent logs -f. |
| Stripe webhook returns 400 "invalid signature" | STRIPE_WEBHOOK_SECRET doesn't match the endpoint's secret. Copy the right whsec_… into .env.backend and restart the backend. |
| Knowledge-graph crash-loops on first boot | Usually a missing ANTHROPIC_API_KEY / OPENAI_API_KEY. make do-logs SVC=knowledge-graph names the missing var. |
| Postgres works, then fails after a reboot (dev) | Bind-mount ownership. ambasdr-data-perms.service handles this, but if it's disabled: chown -R 999:999 /data/ambasdr/postgres. |
| Grafana container dashboard reads "No data" for all containers | The box reverted to the overlayfs storage driver (e.g. a rebuild that lost the cloud-init daemon.json). Re-pin to overlay2, restart Docker, re-pull images. |
Backend build fails with could not read Username for 'https://github.com' | The private juelz111/gommon dep needs the GOMMON_PAT BuildKit secret. CI stages it automatically; for a local build mount it with --secret id=github_token,.... |
Frontend build fails with Cannot find module '@ambasdr/...' | The build context must be the repo root: docker buildx build -f frontend/Dockerfile . — building with ./frontend can't see the packages/ workspace. |
docker push ghcr.io/... returns insufficient_scope | The PAT lacks write:packages. gh auth refresh -h github.com -s write:packages, then re-login Docker. |
prod is built later by cloning dev. The infra is ready (prod.tfvars flips on Managed Postgres + Spaces and uses the apex domain); the app side needs a prod compose variant and a prod deploy workflow. Below is the gap list, stated plainly so nothing is assumed done.
| Piece | Status / what's needed |
|---|---|
| Droplet + network + DNS | Ready. make apply ENV=prod creates the larger Droplet, reserved IP, firewall, and DNS — after resolving the apex/Firebase cutover (§03). |
| Managed Postgres + Spaces | Ready. Created by the same apply (managed_postgres_enabled / spaces_enabled), with the ambasdr + ambasdr_knowledge_graph databases and the ambasdr-prod-documents bucket. Point the backend's DB_* / DO_SPACES_* and the KG's POSTGRES_* / SPACES_* at the Terraform outputs. |
docker-compose.do-prod.yml | Not yet authored. Clone docker-compose.do-dev.yml and drop the in-box postgres + minio + minio-init services (the backend reads the managed connection string and Spaces SDK instead). Keep Neo4j, Caddy, the observability stack, and cloudflared. |
| Cloudflare Tunnel + Access (Grafana) | Pre-created. The ambasdr-prod tunnel, the grafana.ambasdr.com DNS, and the email-PIN Access app already exist; they activate once the prod box runs cloudflared with the prod tunnel token in its .env.host. |
| Continuous deploy | Not yet built. Clone deploy-dev.yml → deploy-prod.yml: target the prod runner label, tag :prod, use a prod GitHub Environment, and gate it (manual approval or a release tag rather than every push to main). |
| Auth0 / Stripe / LiveKit | A prod SPA (apex callback URLs), Stripe live keys + a live webhook at https://api.ambasdr.com/webhooks/stripe, and a separate LiveKit Cloud project + agent. |
The cAdvisor → Prometheus → Grafana pipeline, the Cloudflare-tunnel Grafana access, and the overlay2 requirement in depth.
How the layers deployed above fit together end-to-end.
Every endpoint the deployed backend exposes, including the agent/webhook surfaces referenced in §08 and §09.