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

7.6 KiB
Raw Blame History

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:
    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):
    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):

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

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:

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:

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