# Deploying Property Management Network on DigitalOcean App Platform This app runs as a single Next.js 16 (standalone) container. On App Platform it needs: - a **Managed PostgreSQL** database, - **DigitalOcean Spaces** (S3-compatible) for uploads — App Platform containers are **ephemeral**, so local-disk storage would be wiped on every deploy (the app already uses Spaces; see `SPACES_*` env vars), - **DigitalOcean Functions** for the two daily cron jobs (App Platform has no native cron), - a few third-party keys/credentials (Stripe, OpenAI, SMTP/SMTP2GO, Cloudflare Turnstile). Because the source repo lives on self-hosted **Gitea** (which App Platform can't pull), the app is deployed as a **pre-built image from DigitalOcean Container Registry (DOCR)**. --- ## 0. Prerequisites - `doctl` installed and authenticated (`doctl auth init`) - A DOCR registry: `doctl registry create ` (once) - A Managed PostgreSQL cluster (see step 1) - The Space `property-management-network` in `nyc3` with CDN enabled (already set up) ### Optional: manage this app with the DigitalOcean MCP server For natural-language management of App Platform, the database cluster, and Spaces from Claude Code (deploy status, logs, env vars), register the DigitalOcean MCP server. It's a **management convenience only** — deploys still go through DOCR + `doctl`, migrations through `scripts/migrate-prod.mjs`, and cron through DO Functions. 1. Create a **scoped** DO API token (console → **API → Tokens**) limited to the resources used here: App Platform (read + write), Databases (read), Spaces (read). Do **not** use a full-access token. 2. Register it locally — the token is stored in `~/.claude.json`, never in the repo: ```bash claude mcp add digitalocean --scope local \ -e DIGITALOCEAN_API_TOKEN= \ -- npx -y @digitalocean/mcp --services apps,databases,spaces ``` 3. Reconnect the Claude Code session (`/mcp`); `claude mcp list` should then show `digitalocean` connected. Remove anytime with `claude mcp remove digitalocean`. --- ## 1. Database Create a **Managed PostgreSQL** cluster and a database (e.g. `propertymanagementnetwork`). **Privileges:** the migrator and the app both run DDL (create the `drizzle` schema + tables, and apply migrations on boot). A database owned by `doadmin` does **not** grant DDL to a scoped user automatically. Either: - use the **`doadmin`** user in `DATABASE_URL`, **or** - grant your scoped user the needed rights (run once as `doadmin`): ```sql GRANT ALL ON DATABASE propertymanagementnetwork TO propertymanagementnetworksuser; \c propertymanagementnetwork GRANT ALL ON SCHEMA public TO propertymanagementnetworksuser; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO propertymanagementnetworksuser; ``` **Host & port:** use the **private** host (`private-...db.ondigitalocean.com`) in the app's `DATABASE_URL` — the app runs inside DO's network, so it's faster and not publicly exposed. Use the **public** host only for one-off admin/migration from your laptop. Use the **direct port `25060`** (not the `25061` connection pool) so `migrate-on-boot` and its advisory locks work correctly. **Trusted Sources:** on the DB cluster → **Settings → Trusted Sources**, add the App Platform app (and, temporarily, your laptop's IP for the initial migration). Otherwise the cluster's firewall refuses connections. **SSL (verified TLS — recommended):** DO's DB cert isn't in the system trust store, so verification against the system CAs fails. Use verify-full instead: **omit `?sslmode=...` from `DATABASE_URL`**, keep `DATABASE_SSL=require`, and set `DATABASE_CA` to the cluster's CA cert — DB cluster → **Connection Details → Download CA certificate**, then paste the PEM contents as the `DATABASE_CA` secret. With `require` and no (or an invalid) CA the app **refuses to connect** rather than run unverified — that's intended. `DATABASE_SSL=no-verify` (encrypted but unverified) exists only as an emergency fallback; do not use it in production. --- ## 2. Build and push the image to DOCR `NEXT_PUBLIC_*` values are inlined into the browser bundle at **build time**, so pass them as `--build-arg`. Use the URL you'll actually serve on (your custom domain, or the `*.ondigitalocean.app` URL once known): ```bash doctl registry login # auth Docker to DOCR REG=registry.digitalocean.com/ docker build \ --build-arg NEXT_PUBLIC_APP_URL=https:// \ --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \ --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \ --build-arg NEXT_PUBLIC_SENTRY_DSN= \ --build-arg SENTRY_AUTH_TOKEN= \ -t $REG/property-management-network:latest . docker push $REG/property-management-network:latest ``` > **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg` > (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The > server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert > until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is > optional and only uploads source maps for readable stack traces. > First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the > `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`. --- ## 3. Create the app ```bash doctl apps create --spec .do/app.yaml ``` Then set every `type: SECRET` value (App → Settings → Environment Variables), or edit `.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`, `SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden): `QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET` (e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring — plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP (SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without `SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime. Migrations do **not** run on boot (`RUN_MIGRATIONS_ON_START=false`) — the app user has no DDL rights by design. Apply schema changes out-of-band as **doadmin** before/after deploying, from a machine allowed by the DB's Trusted Sources: ```bash DATABASE_URL="postgresql://doadmin:@:25060/propertymanagementnetwork" \ DATABASE_SSL=no-verify node scripts/migrate.mjs ``` The migrator is idempotent (only pending migrations run). The current schema (0000–0002) is already applied to production. Redeploys reuse the same image tag — App Platform pulls the new `:latest` on push (`deploy_on_push`). --- ## 4. Cron — DigitalOcean Functions The two jobs are triggered by DO Functions schedulers hitting the app's protected endpoints. Create `functions/.env` (gitignored): ``` APP_BASE_URL=https:// CRON_SECRET= ``` Deploy: ```bash doctl serverless install # once doctl serverless connect # once, pick/create a namespace doctl serverless deploy functions --env functions/.env ``` This registers `cron/run` with two scheduler triggers: `daily` at `0 9 * * *` and `late-fees` at `0 8 * * *` (UTC). Verify in **Functions → Triggers**. --- ## 5. Stripe webhook Point a Stripe webhook at `https:///api/stripe/webhook` and put its signing secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.payment_failed`, `payment_intent.succeeded`. --- ## 6. Google OAuth (optional) Set `GOOGLE_CLIENT_ID/SECRET` and add `https:///api/auth/callback/google` as an authorized redirect URI. --- ## Post-deploy checklist - [ ] `https:///api/health` returns `{"status":"ok",...}` - [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*` + Turnstile) - [ ] Upload a document; confirm the object appears in the Space and serves via the CDN - [ ] Trigger the `daily` function manually (`doctl serverless functions invoke cron/run -p job:daily`) → 200 - [ ] Stripe webhook delivers successfully