Files
property-management-network/DIGITALOCEAN.md
T
Leon SerfatyandClaude Opus 4.8 5495b94924 Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:45:24 -04:00

194 lines
8.4 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 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
--build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
-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:<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