Skip to content

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)

  1. Copy .env.example.env and set secrets (see below).
  2. Build the apex marketing site (Caddy serves marketing/dist): bash (cd marketing && npm ci && npm run build)
  3. docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
  4. docker compose exec api alembic upgrade head
  5. Create tenants (bootstrap env or POST /api/auth/tenants with ALLOW_TENANT_CREATE=true).
  6. Put TLS in front of API and web (Caddy is included in the prod Compose file). Do not expose Postgres publicly.
  7. Host map (Caddy on a Droplet — used for stage / local prod-like):
  8. https://<SITE_ADDRESS> / www → marketing SSG (marketing/dist)
  9. https://app.<SITE_ADDRESS> → production SPA + API + MCP (MCP on a dedicated process)
  10. https://stage.<SITE_ADDRESS> / https://dev.<SITE_ADDRESS> → nonprod stacks
  11. https://api-docs.<SITE_ADDRESS> / https://docs.<SITE_ADDRESS> → MkDocs Material (Compose). Prod wiki. / 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 → nightly pg_dump -Fc files

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

  1. Create DNS A records for @, www, app, stage, dev, api-docs → Droplet public IP (or point registrar NS at DigitalOcean and manage there). Prod wiki / docs point at the Wiki.js Droplet.
  2. Set:
  3. SITE_ADDRESS=realtypad.ai (apex only — no scheme)
  4. PUBLIC_BASE_URL / WEB_BASE_URL / CORS_ORIGINS = https://app.realtypad.ai
  5. docker compose … up -d so Caddy issues certs for apex, www, app, stage, dev, docs, and api-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.

Isolates stage DB, MCP OAuth, and deploys from production.

  1. Create a second Droplet (same region as DNS). Point stage.<SITE_ADDRESS> A record to its public IP (keep app.<SITE_ADDRESS> on the prod Droplet).
  2. Mount block storage (optional) at /mnt/housemaxxing-data and create stage/postgres + stage/attachments.
  3. 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
  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 with workflow_dispatchstage).

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.

  1. 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_pgdata
  • housemaxxing_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 → PipelineRun 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

  1. Create Environments: dev, stage, production (Settings → Environments).
  2. On production, enable required reviewers (still useful as a second gate on tag deploys).
  3. Repository secret (required for image push on every non-PR build):
Secret Purpose
DIGITALOCEAN_ACCESS_TOKEN doctl registry login + DOKS deploy
  1. 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)
  1. 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 via deploy/k8s/scripts/apply-secrets.sh — see deploy/k8s/README.md.
  2. Create branch develop when you want a separate Dev stream.
  3. 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).