Operators¶
Guide for running a shared RealtyPad stack (multiple tenants, one database).
Production (DOKS + Managed Postgres + Spaces + DOCR)¶
Production is on DigitalOcean Kubernetes (realtypad-prod) with Managed PostgreSQL 16 (realtypad-prod-pg), Spaces (realtypad-prod-attachments, nyc3), and images from DOCR (registry.digitalocean.com/realtypad/…) in nyc3. Manifests live under deploy/k8s/.
| Host | Target |
|---|---|
app.realtypad.ai |
Ingress → API + web |
realtypad.ai / www |
Ingress → marketing |
api-docs.realtypad.ai |
Ingress → MkDocs (user/tenant docs) |
wiki.realtypad.ai |
Wiki.js Droplet (realtypad-wikijs) |
docs.realtypad.ai |
Wiki.js alias until Site URL is changed in admin |
stage.realtypad.ai |
Stage Droplet (Compose, pulls DOCR) |
dev.realtypad.ai |
Optional Droplet / unused |
Deploy: tag v* → GitHub Actions deploy-doks (repo + env DIGITALOCEAN_ACCESS_TOKEN, env DOKS_CLUSTER_ID). Secrets via deploy/k8s/scripts/apply-secrets.sh (private DB host, port 25060, sslmode=require, plus SPACES_ACCESS_KEY / SPACES_SECRET_KEY). API runs 2 replicas with STORAGE_BACKEND=spaces (no attachments PVC).
Rollback: point A records for app/@/www/api-docs back to the old Droplet IP and start Compose against Managed Postgres (avoid resurrecting the Droplet Postgres after cutover). wiki / docs are Wiki.js — leave them on realtypad-wikijs unless you intend to take the wiki down.
Health: GET /health and GET /api/healthz (unauthenticated).
Wiki.js (wiki.realtypad.ai)¶
Standalone 1-Click Droplet, not on DOKS.
| Resource | Value |
|---|---|
| Droplet | realtypad-wikijs (s-2vcpu-4gb, nyc3, backups + monitoring) |
| Public IP | 159.89.43.213 |
| Firewall | realtypad-wikijs (TCP 22/80/443) |
| Image | Marketplace wikijs-18-04 (Wiki.js 2.5 + Docker + Postgres) |
TLS is terminated by a Caddy container on wikinet (Let's Encrypt for wiki.realtypad.ai and docs.realtypad.ai); Wiki.js listens on :3000 only. Canonical host is wiki. Keep docs as an alias until the Wiki.js admin Site URL is set to https://wiki.realtypad.ai, then docs can be dropped or redirected.
Product MkDocs stays on https://api-docs.realtypad.ai.
Deploy checklist (local / generic)¶
- Copy
.env.example→.envand set secrets (see below). - Build the apex marketing site (Caddy serves
marketing/dist):bash (cd marketing && npm ci && npm run build) docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --builddocker compose exec api alembic upgrade head- Create tenants (bootstrap env or
POST /api/auth/tenantswithALLOW_TENANT_CREATE=true). - Put TLS in front of API and web (Caddy is included in the prod Compose file). Do not expose Postgres publicly.
- Host map (Caddy on a Droplet — used for stage / local prod-like):
https://<SITE_ADDRESS>/www→ marketing SSG (marketing/dist)https://app.<SITE_ADDRESS>→ production SPA + API + MCP (MCP on a dedicated process)https://stage.<SITE_ADDRESS>/https://dev.<SITE_ADDRESS>→ nonprod stackshttps://api-docs.<SITE_ADDRESS>/https://docs.<SITE_ADDRESS>→ MkDocs Material (Compose). Prodwiki./docs.is Wiki.js on its own Droplet.
Marketing is a Vite + React SSG (marketing/). Local: npm run dev (port 5174) / npm run build / npm run preview. Build emits prerendered routes plus robots.txt, sitemap.xml, and llm.txt / llms.txt.
DigitalOcean pilot Droplet (legacy)¶
Previous production shape: one Droplet + Block Storage + on-box Postgres + Caddy. Prod has moved to DOKS + Managed Postgres; keep this section for stage/dev patterns and emergency rollback.
Resources¶
| Resource | Spec | Notes |
|---|---|---|
| Droplet | s-2vcpu-4gb, nyc3, Ubuntu 22.04 + Docker Engine |
Enable backups + monitoring; tag housemaxxing-pilot. Prefer plain Ubuntu over the Docker 1-Click image (SSH was unreliable on the 1-Click). |
| Volume | 25 GB Block Storage, same region | Name housemaxxing-data → mount /mnt/housemaxxing-data |
| Firewall | TCP 22, 80, 443 inbound | Apply to tag housemaxxing-pilot |
| Hostname | https://<dotted-ip>.sslip.io |
Temporary until you buy a domain |
Rough cost: ~$24 Droplet + ~$5 backups + ~$2.50 volume ≈ $31–32/mo.
First boot¶
# On the Droplet (as root)
sudo ./deploy/mount-data-volume.sh
cp .env.example .env
# Edit .env: strong POSTGRES_PASSWORD, SESSION_SECRET, JWT_SECRET,
# SITE_ADDRESS=realtypad.ai
# PUBLIC_BASE_URL=https://app.realtypad.ai
# WEB_BASE_URL=https://app.realtypad.ai
# CORS_ORIGINS=https://app.realtypad.ai
# BOOTSTRAP_* for the first owner
chmod +x deploy/pg_dump_nightly.sh deploy/mount-data-volume.sh
(cd marketing && npm ci && npm run build)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec api alembic upgrade head
# Nightly logical dumps (14-day retention on the volume)
echo "15 2 * * * root cd /opt/housemaxxing && ./deploy/pg_dump_nightly.sh >> /var/log/housemaxxing-pgdump.log 2>&1" \
>/etc/cron.d/housemaxxing-pgdump
Never run docker compose down -v in production — that deletes volumes.
Prod Compose binds:
/mnt/housemaxxing-data/postgres→ Postgres data/mnt/housemaxxing-data/attachments→ attachments/mnt/housemaxxing-data/dumps→ nightlypg_dump -Fcfiles
What survives what¶
| Event | Data outcome |
|---|---|
| Container crash / compose restart | Safe (data on Block Volume) |
| Droplet reboot | Safe |
compose down (no -v) |
Safe |
| Droplet destroy keeping the volume | Remount volume on a new Droplet |
Volume delete / down -v |
Destroyed — restore from Droplet backup or dump |
Restore from a nightly dump¶
# Stop writers first (or restore onto a fresh Postgres data dir)
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T db \
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \
</mnt/housemaxxing-data/dumps/housemaxxing-YYYYMMDDThhmmssZ.dump
Moving to a real domain¶
- Create DNS
Arecords for@,www,app,stage,dev,api-docs→ Droplet public IP (or point registrar NS at DigitalOcean and manage there). Prodwiki/docspoint at the Wiki.js Droplet. - Set:
SITE_ADDRESS=realtypad.ai(apex only — no scheme)PUBLIC_BASE_URL/WEB_BASE_URL/CORS_ORIGINS=https://app.realtypad.aidocker compose … up -dso Caddy issues certs for apex,www,app,stage,dev,docs, andapi-docs.
Apex serves the marketing SSG from marketing/dist (build before compose up); the product app is only on app..
Stage / dev on the same Droplet (legacy)¶
Prefer a dedicated stage Droplet (see below) so production and stage never share RAM, Caddy, or operator confusion (e.g. MCP data written to stage while browsing app.*).
One Caddy (production project) routes subdomains on the shared realtypad-edge network. Nonprod stacks omit Caddy/docs and use separate data dirs.
| Env | Suggested DEPLOY_PATH |
Host | Data |
|---|---|---|---|
| production | /opt/housemaxxing |
app.realtypad.ai |
/mnt/housemaxxing-data/postgres |
| stage (same host) | /opt/realtypad-stage |
stage.realtypad.ai |
/mnt/housemaxxing-data/stage/… |
| dev | /opt/realtypad-dev |
dev.realtypad.ai |
/mnt/housemaxxing-data/dev/… |
Each nonprod dir needs its own .env with matching PUBLIC_BASE_URL / WEB_BASE_URL / CORS_ORIGINS. CI sets DEPLOY_ENV from the GitHub Environment name; remote-deploy.sh applies docker-compose.nonprod.yml.
RAM: a 4 GB Droplet can struggle with three full Postgres stacks. Stop stage/dev on the prod host once stage has its own Droplet.
Dedicated stage Droplet (recommended)¶
Isolates stage DB, MCP OAuth, and deploys from production.
- Create a second Droplet (same region as DNS). Point
stage.<SITE_ADDRESS>A record to its public IP (keepapp.<SITE_ADDRESS>on the prod Droplet). - Mount block storage (optional) at
/mnt/housemaxxing-dataand createstage/postgres+stage/attachments. - Clone/sync the repo to
/opt/realtypad-stage, copy.env.example→.env, set at minimum:
| Variable | Example |
|---|---|
SITE_ADDRESS |
realtypad.ai |
PUBLIC_BASE_URL |
https://stage.realtypad.ai |
WEB_BASE_URL |
https://stage.realtypad.ai |
CORS_ORIGINS |
https://stage.realtypad.ai |
STAGE_STANDALONE |
1 |
- First boot:
docker network create realtypad-edge, then
DEPLOY_ENV=stage APP_DIR=/opt/realtypad-stage IMAGE_TAG=local-bootstrap bash deploy/remote-deploy.sh
(or CI deploy withworkflow_dispatch→ stage).
STAGE_STANDALONE=1 adds docker-compose.stage-droplet.yml so this host runs its own Caddy (deploy/Caddyfile.stage-standalone) instead of relying on prod’s edge proxy.
- GitHub stage environment secrets (different from production):
| Secret | Stage-only Droplet |
|---|---|
DEPLOY_HOST |
Stage Droplet IP / hostname |
DEPLOY_PATH |
/opt/realtypad-stage |
DEPLOY_USER / DEPLOY_SSH_KEY |
SSH access to stage host |
Production deploys no longer use Droplet SSH — set DIGITALOCEAN_ACCESS_TOKEN and DOKS_CLUSTER_ID on the production GitHub Environment instead.
After migration: remove or stop realtypad-stage-* containers on the prod Droplet so stage.realtypad.ai only hits the stage host (DNS must not point at prod).
Migrating to DigitalOcean Managed Postgres (completed for prod)¶
Prod already uses Managed PostgreSQL 16 (realtypad-prod-pg). Connection: private host, port 25060, sslmode=require, database housemaxxing. Trusted sources should include the DOKS cluster (and temporarily a Droplet IP only for emergency restore).
For a new environment: create the managed DB, pg_dump/pg_restore (see deploy/k8s/scripts/migrate-postgres.sh), point DATABASE_URL, configure Spaces (STORAGE_BACKEND=spaces + SPACES_*), then drop any Compose db service.
Environment variables¶
| Variable | Purpose |
|---|---|
STORAGE_BACKEND |
local (default / Compose) or spaces (prod DOKS) |
SPACES_ENDPOINT / SPACES_REGION / SPACES_BUCKET |
DigitalOcean Spaces (prod ConfigMap) |
SPACES_ACCESS_KEY / SPACES_SECRET_KEY |
Spaces credentials (k8s Secret; never commit) |
SESSION_SECRET / JWT_SECRET |
HS256 signing key for local JWTs (login + agent + MCP OAuth tokens) |
JWT_ISSUER / JWT_AUDIENCE |
Claims on locally issued JWTs |
PUBLIC_BASE_URL |
Public API origin for MCP OAuth discovery / token (e.g. https://203.0.113.10.sslip.io) |
WEB_BASE_URL |
Web UI origin for MCP OAuth consent (same origin on the pilot Droplet) |
SITE_ADDRESS |
Caddy hostname only (e.g. 203.0.113.10.sslip.io) |
CORS_ORIGINS |
Comma-separated web origins (pilot: same HTTPS origin) |
MCP_OAUTH_ENABLED |
MCP browser OAuth (default true; set false to disable) |
APP_ROLE |
Process role: all (local/dev, REST+MCP), api (REST only), mcp (MCP + OAuth discovery). Prod DOKS/Compose run separate api and mcp Deployments. |
CATALOG_DOWNLOAD_CACHE_DIR |
Disk cache for nationwide Zillow CSVs (default /tmp/realtypad-catalog-cache; prod mcp uses /var/cache/realtypad-catalog) |
CATALOG_DOWNLOAD_CACHE_TTL_DAYS |
Reuse cached catalog downloads for this many days (default 7) |
APIFY_TOKEN |
Platform Apify API token injected into the proxied Apify MCP (https://mcp.apify.com) and used by image_backfill. Scope Actors/spend on Apify; never expose to clients. Also accepts APIFY_API_TOKEN or APIFY_KEY |
APIFY_PROXY_ENABLED |
Kill-switch for mounting Apify MCP on RealtyPad /mcp (default true when token set) |
AIRROI_API_KEY |
AirROI Markets API key for platform catalog STR (refresh_catalog_trends source=airroi / refresh_airroi_trends / POST /api/markets/{id}/trends/airroi) |
OIDC_ISSUER |
External IdP issuer URL (enables JWKS validation) |
OIDC_AUDIENCE |
Expected aud on IdP access tokens (often API client id) |
OIDC_CLIENT_ID |
Exposed to SPA via GET /api/auth/oidc for PKCE login |
OIDC_TENANT_CLAIM |
JWT claim for workspace id (default tid); else first membership |
LOCK_OPENAPI |
true to hide /docs on public API |
BOOTSTRAP_TENANT_NAME |
First workspace name (first deploy only) |
BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD |
First owner user |
ALLOW_TENANT_CREATE |
true to let admins create more tenants via API |
ALLOW_PUBLIC_SIGNUP |
true to allow POST /api/auth/register (personal workspace); default false on shared hosts |
TWILIO_ACCOUNT_SID |
Twilio account for Email API (comms.twilio.com) |
TWILIO_API_KEY_SID / TWILIO_API_KEY_SECRET |
Preferred app credentials (API Key) for sending mail |
TWILIO_AUTH_TOKEN |
Fallback for local smoke only; prefer API keys in prod |
EMAIL_FROM |
Verified sender address (default noreply@realtypad.ai) |
EMAIL_FROM_NAME |
Display name (default RealtyPad) |
EMAIL_ENABLED |
true to send; when false or creds missing, mail is logged only |
MFA_SECRET_KEY |
Optional Fernet material for TOTP secrets; defaults to JWT_SECRET / SESSION_SECRET |
Twilio Email: from must be on a Twilio Email verified domain/sender. Smoke (expect 202 + operationId):
curl -sS -X POST 'https://comms.twilio.com/v1/Emails' \
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"from":{"address":"noreply@realtypad.ai","name":"RealtyPad"},"to":[{"address":"you@example.com"}],"content":{"subject":"Smoke","text":"ok","html":"<p>ok</p>"}}'
Do not use twilio email:send for this path — that CLI helper expects a SendGrid SG. key. The app uses POST https://comms.twilio.com/v1/Emails only.
Team growth on an existing brokerage workspace: use Account → Team invites (emailed when EMAIL_ENABLED=true; link still copyable), not POST /api/auth/tenants (that provisions a separate tenant + owner user).
Local CLI (Typer)¶
Add users against the running API container (uses the same DATABASE_URL as the API):
docker compose exec api python -m app.cli --help
docker compose exec api python -m app.cli users add you@example.com --tenant default --role owner
docker compose exec api python -m app.cli users list
docker compose exec api python -m app.cli tenants list
docker compose exec api python -m app.cli tenants create "Partner Realty" --email partner@example.com
--password is optional; the CLI prompts (hidden) when omitted. --tenant accepts a slug, UUID, or default.
Creating your realtor partner’s tenant¶
With ALLOW_TENANT_CREATE=true, an owner can call:
POST /api/auth/tenants
Content-Type: application/json
Authorization: Bearer <access_token>
{
"tenant_name": "Partner Realty",
"owner_email": "partner@example.com",
"owner_password": "choose-a-strong-password"
}
Or run equivalent logic in a one-off script using create_tenant_with_owner from app.auth.bootstrap.
Backups¶
Pilot Droplet:
- Block Volume: Postgres + attachments + dumps under
/mnt/housemaxxing-data - DigitalOcean Droplet backups (weekly)
- Nightly
deploy/pg_dump_nightly.sh→/mnt/housemaxxing-data/dumps(14-day retention)
Local / generic Compose named volumes:
housemaxxing_pgdatahousemaxxing_attachments
Migrations¶
Schema changes ship as Alembic revisions under api/alembic/versions/. Run alembic upgrade head on every deploy.
Fresh Postgres volumes still apply db/init/ once; always run Alembic afterward for auth/tenant tables.
MCP URL¶
Clients use https://<api-host>/mcp (same public host as the UI). Prefer browser OAuth (URL only in Cursor/Claude). Fallback: Bearer agent JWT from the web UI. See Connect Cursor.
In production, MCP runs in a separate process (APP_ROLE=mcp) from the REST API (APP_ROLE=api) so long catalog refreshes cannot take down the site. The public URL is unchanged.
OAuth discovery documents live at the app host root (/.well-known/oauth-protected-resource/mcp, /.well-known/oauth-authorization-server/mcp). Ingress/Caddy must proxy /.well-known/* and /mcp* to the mcp service — otherwise Claude fails DCR with “Couldn’t register with … sign-in service”.
CI/CD (GitHub Actions)¶
Workflow: .github/workflows/pipeline.yml
| Stage | What |
|---|---|
| Build | Docker images → DOCR (registry.digitalocean.com/realtypad/realtypad-*) |
| Test | Postgres + Alembic + pytest; web + marketing npm run build |
| Release | Retag images; GitHub Release on v* tags |
| Deploy | production → DOKS (.github/actions/deploy-doks); stage/dev → SSH + deploy/remote-deploy.sh |
Branch / trigger mapping:
| Trigger | Deploy target |
|---|---|
| PR (any branch) | Build + test only |
Push develop |
→ dev |
Push main |
Build + test + image push only (no auto stage deploy) |
| Push feature branch | Build + push images (no auto-deploy) |
Tag v* |
Release + production (DOKS) |
| Workflow dispatch → stage | stage (manual; any branch) |
| Workflow dispatch → dev | dev |
Workflow dispatch from a v* tag → production |
production (re-deploy) |
Production is tag-gated: only v* tags deploy to production (DOKS + Managed Postgres + Spaces).
Stage is manual: Actions → Pipeline → Run workflow → choose branch (usually main) → environment stage. Use a distinct DEPLOY_HOST on the stage GitHub Environment when stage runs on its own Droplet.
One-time GitHub setup¶
- Create Environments: dev, stage, production (Settings → Environments).
- On production, enable required reviewers (still useful as a second gate on tag deploys).
- Repository secret (required for image push on every non-PR build):
| Secret | Purpose |
|---|---|
DIGITALOCEAN_ACCESS_TOKEN |
doctl registry login + DOKS deploy |
- Per environment, add secrets:
| Secret | Example |
|---|---|
DEPLOY_HOST |
Droplet IP / hostname (stage/dev) |
DEPLOY_USER |
root |
DEPLOY_SSH_KEY |
Private key with access to that host |
DEPLOY_PATH |
/opt/housemaxxing (optional) |
DIGITALOCEAN_ACCESS_TOKEN |
DOCR pull on Droplet (stage/dev); DOCR + registry ops (production) |
DOKS_CLUSTER_ID |
DOKS UUID (production only) |
DOKS_KUBECONFIG |
Full kubeconfig YAML (production; preferred for deploy-doks) |
- Ensure each Droplet has Docker, the data volume mount, and a working
.env(never committed). Production app secrets (DATABASE_URL, Spaces keys, …) live in-cluster viadeploy/k8s/scripts/apply-secrets.sh— see deploy/k8s/README.md. - Create branch
developwhen you want a separate Dev stream. - Production app URL:
https://app.realtypad.ai· MCP:https://app.realtypad.ai/mcp· stage MCP:https://stage.realtypad.ai/mcp· marketing:https://realtypad.ai· MkDocs:https://api-docs.realtypad.ai· wiki:https://wiki.realtypad.ai.
Images: registry.digitalocean.com/realtypad/realtypad-api:<tag> (and web / marketing / docs; sha-<12> or vX.Y.Z).