← Ambasdr Platform Docs
Deployment · DigitalOcean

Deploying Ambasdr to DigitalOcean (dev + prod)

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.

01

Target topology

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.

DigitalOcean Droplet

Self-hosted plane

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

LiveKit Cloud

Voice agent + media SFU

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.

External SaaS

Identity, payments, AI

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

Caddy + Cloudflare instead of ngrok

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.

dev vs prod at a glance

Dimensiondevprod
Droplet sizes-2vcpu-4gbs-4vcpu-8gb, Droplet backups on
Frontend hostnamedev.ambasdr.comapex ambasdr.com (or app.ambasdr.com — see §03 cutover)
API hostnameapi.dev.ambasdr.comapi.ambasdr.com
PostgresIn-box container, bind-mountDigitalOcean Managed Postgres (backups + PITR)
Object storageIn-box MinIODigitalOcean Spaces (ambasdr-prod-documents)
Neo4jIn-box containerIn-box container (same)
Grafana accessgrafana-dev.ambasdr.comgrafana.ambasdr.com
Continuous deploy.github/workflows/deploy-dev.yml (built)Not built yet — clone deploy-dev.yml when prod lands (§17)
02

Two repos: who builds the box, who runs the app

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.

ambasdr-infra

Stands up the box

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.

ambasdr (this repo)

Runs the stack

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.

The hand-off

Outputs → env files

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.

Don't confuse the environments

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.

03

Provision the box (ambasdr-infra)

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.

Prerequisites (once)

ItemWhy / 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 tokenexport TF_VAR_do_token=...
Spaces access key pairUsed 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 tokenZone:DNS:Edit on the ambasdr.com zone — export TF_VAR_cloudflare_api_token=.... ambasdr.com is authoritative on Cloudflare.
SSH key in DigitalOceanUploaded key ambasdr-do-dev (ID 57069717); private key at ~/.ssh/ambasdr_dev_ed25519. Its ID is in each env's tfvars.
State bucketOne-time: cd bootstrap && terraform init && terraform apply creates the shared Spaces state bucket.

Apply

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

DNS is grey-cloud (DNS-only) on purpose

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 apex vs the Firebase marketing site

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.

04

Image plan

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.

ImageSourceNotes
ambasdr-backend backend/Dockerfile — multistage golang:1.23distroless/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/Dockerfilenode: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.
05

Environment variables (three files on the box)

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.

deploy/.env.host (compose-substituted)

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

deploy/.env.backend

Variabledev value (prod difference)
SERVER_PORT9080 (matches the container port Caddy proxies to).
ENVIRONMENT / GIN_MODEdev / release.
DB_HOST / DB_PORTpostgres / 5432 (compose-internal). prod: the Managed Postgres host + port from the Terraform output, sslmode=require.
DB_PASSWORDMatches PG_PASSWORD in .env.host. prod: the Managed PG role password.
AUTH0_DOMAIN / AUTH0_AUDIENCEAuth0 tenant; audience is the logical API identifier https://api.ambasdr.com (an identifier, not a URL — same across environments).
AUTH0_M2M_SECRETShared secret validating the Post-Login Action's HMAC. Rotate per environment; mirror into the Auth0 Action.
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PRICE_IDTest-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_URLhttp://knowledge-graph:8050 (compose hostname).
ALLOWED_ORIGINShttps://dev.ambasdr.com. prod: the prod frontend origin.
LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET / AGENT_API_KEYFrom the LiveKit Cloud project. AGENT_API_KEY must equal AMBASDR_API_KEY in the agent's secrets (§09).
LLM_API_KEY / LLM_BASE_URLOpenAI-compatible; ideally a per-environment project for attributable cost.

deploy/.env.kg

Variabledev value (prod difference)
ANTHROPIC_API_KEYRequired — Graphiti uses Claude for entity extraction.
OPENAI_API_KEYRequired — embeddings + reranking.
POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DBpostgres / 5432 / ambasdr_knowledge_graph. prod: the Managed PG host + the ambasdr_knowledge_graph database created by Terraform.
NEO4J_URI / NEO4J_PASSWORDbolt://neo4j:7687; password matches the compose NEO4J_PASSWORD.
SPACES_ENDPOINT / SPACES_BUCKEThttp://minio:9000 / ambasdr-documents. prod: the Spaces endpoint + ambasdr-prod-documents.

Frontend (build-time only)

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.

Rotate before the first deploy

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.

06

TLS & DNS: Caddy + Cloudflare

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.

Why grey-cloud matters for ACME

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.

07

Auth0 reconfiguration

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 settingValue
SPA → Allowed Callback URLshttps://dev.ambasdr.com and https://dev.ambasdr.com/callback.
SPA → Allowed Logout URLshttps://dev.ambasdr.com.
SPA → Allowed Web Origins / CORShttps://dev.ambasdr.com.
Post-Login Action → Secret PROVISION_URLhttps://api.dev.ambasdr.com/internal/users/provision. The Action POSTs here on first login.
Post-Login Action → Secret M2M_SHARED_SECRETThe 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>
08

Stripe reconfiguration

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.

09

Deploy the voice agent to LiveKit Cloud

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.

Install the LiveKit CLI

brew install livekit-cli, then lk cloud auth binds the CLI to your LiveKit Cloud project via a browser flow.

Create the agent record

From livekit-agent/: lk cloud agent create. Name it per environment, e.g. ambasdr-voice-dev.

Set agent secrets

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.

Deploy

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.

Agent ↔ backend contract

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.

10

First-deploy runbook

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

Provision (ambasdr-infra)

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.

Finish the box

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.

Place the three env files (mode 600)

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.

Bring up the stack

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.

Apply schema migrations

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'

Deploy the voice agent + smoke test

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.

11

Continuous deploys and rollbacks

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.

JobRunnerWhat it does
changesclouddorny/paths-filter flags backend / frontend / knowledge_graph / migrations from the touched paths (packages/** triggers both backend and frontend).
build-backendcloudbuild-push-action:dev + :dev-<sha>. Stages GOMMON_PAT as a BuildKit secret file for the private gommon dep; GHA layer cache.
build-frontendcloudContext = repo root. Reads VITE_* from the dev Environment variables as build args (baked into the bundle).
build-knowledge-graphcloudStandard build from ./knowledge-graph.
migrateself-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-graphself-hosted (do-dev)docker compose pull && up -d <service>, then a loopback health probe. deploy-backend waits for migrate.
smokecloudcurls https://api.dev.ambasdr.com/health and https://dev.ambasdr.com/.

Manual dispatch

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

Rollback

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

Manual (fallback) deploy

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.

12

Logs, metrics, and Grafana access

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.

SurfaceHow 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-devhttp://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-devhttp://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).

LogQL cheat sheet

# 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"
Full monitoring guide

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.

13

Health verification matrix

CheckHow / expected result
Backend liveness (public)curl -sf https://api.dev.ambasdr.com/health200 JSON {"status":"ok"}.
Frontend reachable (public)curl -sfI https://dev.ambasdr.com/200.
TLS certs issuedcurl -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 → DBAfter 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 → Graphitidocker exec ambasdr-backend-1 wget -qO- http://knowledge-graph:8050/health → ok.
Auth0 provisioningAuth0 → Monitoring → Logs after a fresh signup: a successful Action entry means the M2M secret matched and the backend created the user.
LiveKit Cloud → backendlk cloud agent logs ambasdr-voice-dev -f shows "fetched voice prompt"; backend logs show GET /v1/agent/sessions/.../voice-prompt 200.
Stripe webhookStripe → Webhooks → the dev endpoint → "Send test webhook" (customer.subscription.created): backend logs the processed event; the dashboard shows a 200.
14

Backups

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 {} +
15

Restart behavior and recovery

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.

ComponentAuto-restart?Mechanism
Docker daemonYesUbuntu's docker.service (enabled by cloud-init).
All app + data + observability containersYesrestart: unless-stopped on every service in docker-compose.do-dev.yml.
Caddy (TLS edge)YesSame policy. Certs persist on the caddy_data volume, so no re-issue on reboot.
cloudflared (Grafana tunnel)YesSame policy; dials out and reclaims the tunnel automatically.
Data-dir ownershipYesambasdr-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 agentN/ARuns on LiveKit infrastructure, independent of the Droplet.
The box must stay on Docker's overlay2 storage driver

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.

Manual recovery

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
16

Common failures and fixes

SymptomLikely cause / fix
Browser shows a TLS error on dev.ambasdr.comCaddy 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 401Auth0 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 bootUsually 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 containersThe 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_scopeThe PAT lacks write:packages. gh auth refresh -h github.com -s write:packages, then re-login Docker.
17

Promoting dev → prod

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.

PieceStatus / what's needed
Droplet + network + DNSReady. make apply ENV=prod creates the larger Droplet, reserved IP, firewall, and DNS — after resolving the apex/Firebase cutover (§03).
Managed Postgres + SpacesReady. 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.ymlNot 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 deployNot yet built. Clone deploy-dev.ymldeploy-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 / LiveKitA 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.