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>
194 lines
8.4 KiB
Markdown
194 lines
8.4 KiB
Markdown
# 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 (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://<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
|