Files
property-management-network/DIGITALOCEAN.md
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

183 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <your-registry>` (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=<scoped-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/<your-registry>
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
-t $REG/property-management-network:latest .
docker push $REG/property-management-network:latest
```
> 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` (plus the Stripe price IDs). 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:<pw>@<public-host>:25060/propertymanagementnetwork" \
DATABASE_SSL=no-verify node scripts/migrate.mjs
```
The migrator is idempotent (only pending migrations run). The current schema (00000002)
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://<your-domain>
CRON_SECRET=<same value as the app's 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://<your-domain>/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://<your-domain>/api/auth/callback/google`
as an authorized redirect URI.
---
## Post-deploy checklist
- [ ] `https://<domain>/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