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>
7.6 KiB
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
doctlinstalled 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-networkinnyc3with 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.
- 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.
- 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 - Reconnect the Claude Code session (
/mcp);claude mcp listshould then showdigitaloceanconnected. Remove anytime withclaude 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
doadminuser inDATABASE_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.appURL, then rebuild/push with that URL asNEXT_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 (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:
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/healthreturns{"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
dailyfunction manually (doctl serverless functions invoke cron/run -p job:daily) → 200 - Stripe webhook delivers successfully