Files
property-management-network/DIGITALOCEAN.md
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

8.4 KiB
Raw Permalink 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 \
  --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

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:

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