Skip to content

Deploy to DO App Platform

The repo includes .do/app.yaml, an App Platform spec that deploys OpenPartner as three components of one app. You bring the Postgres database.

What gets deployed

ComponentKindPortServes
apiService, built from apps/api/Dockerfile4601/api/*. Runs database migrations on start.
routerService, built from apps/router/Dockerfile4701/r/*: partner links and the _cref cookie
portalStatic site on App Platform’s CDNEverything else: the admin and partner UI

The spec’s ingress rules send /api/* to the API (with the /api prefix stripped) and /r/* to the router, so one domain serves the whole app.

The spec doesn’t create a database. Use a DigitalOcean Managed Postgres cluster, or any Postgres the app can reach, and give its connection string to both services as DATABASE_URL. To have App Platform create a database with the app instead, uncomment the databases block at the top of the spec and set DATABASE_URL on both services to ${openpartner-db.DATABASE_URL}.

Before you create the app

Fork getcoherence/openpartner to your GitHub, then edit .do/app.yaml in your fork:

  1. Point it at your fork. All three components build from github.repo: getcoherence/openpartner. Change each one to your fork, so App Platform builds and redeploys from it.

  2. Switch the API to self-host settings. The spec is set up the way OpenPartner’s hosted service runs it, with OPENPARTNER_MODE: flat and OPENPARTNER_TENANCY: multi. For your own install, change both on the api component:

    - key: OPENPARTNER_MODE
    value: selfhost
    - key: OPENPARTNER_TENANCY
    value: single

    flat is a hosted billing mode. Among other things, it refuses the Stripe Connect payouts that selfhost sends from your own Stripe balance.

  3. Delete DATABASE_URL_APP and OPENPARTNER_APP_DB_PASSWORD from the api component. A single-tenant API can run as DATABASE_URL, but it only falls back to it when DATABASE_URL_APP is unset, not when it’s empty.

To host several brands on one install instead (each under /t/<slug>/), keep OPENPARTNER_TENANCY: multi and both database entries. The API refuses to start in multi until they’re set (see below).

Create the app

  1. In App Platform, Create App → GitHub and pick your fork. App Platform detects .do/app.yaml. Or run doctl apps create --spec .do/app.yaml.

  2. Fill in the encrypted secrets the spec declares.

    On api:

    • DATABASE_URL: the Postgres connection string.
    • SECRETS_ENCRYPTION_KEY: 32 bytes, hex or base64 (openssl rand -hex 32). It encrypts the mail credentials you save in the admin UI. The API won’t start without it.
    • PORTAL_URL: the URL you’ll serve the portal on, e.g. https://partners.yourdomain.com. It’s the API’s CORS allowlist and the base of invite links. The API won’t start without it.
    • ADMIN_API_KEY: the bootstrap admin key. Generate one with node -e "console.log('op_' + require('crypto').randomBytes(24).toString('hex'))".
    • METRICS_TOKEN: recommended. /api/metrics is reachable from the internet, so set a token to require it as a bearer.
    • MAIL_FROM, POSTMARK_SERVER_TOKEN: optional. You can set up mail in the install wizard instead. These are used only when the admin UI has no mail settings.
    • STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET: optional. Your own Stripe account, to pay partners over Stripe Connect and take in Stripe revenue. Leave them empty to pay partners by hand. STRIPE_WEBHOOK_SECRET is the legacy combined secret; to tie each signing secret to its Stripe event destination, add STRIPE_WEBHOOK_SECRET_PLATFORM and STRIPE_WEBHOOK_SECRET_CONNECT as encrypted env vars instead (see Stripe).
    • Leave the other STRIPE_* secrets empty. They’re for OpenPartner’s hosted plans.
    • With OPENPARTNER_TENANCY: multi only:
      • OPENPARTNER_APP_DB_PASSWORD: a random string of 32+ characters. On each start the API creates the openpartner_app Postgres role with this password, or updates it.
      • DATABASE_URL_APP: the DATABASE_URL connection string with openpartner_app and that password as the user and password. Postgres row-level security keeps brands apart only on this connection.

    On router:

    • DATABASE_URL: the same connection string.
    • COOKIE_DOMAIN: your site’s domain with a leading dot, e.g. .yourdomain.com, so the _cref cookie reaches the pages where you run the SDK. Unset, the router sets it for localhost.
  3. Click Create Resources.

  4. If your database cluster limits connections to Trusted Sources, add the app to them.

The API runs migrations when it starts, so an empty database sets itself up. Once the deploy finishes, open the app’s URL. With no admin yet, the portal sends you to the install wizard at /install.

Scheduled jobs

The spec sets OPENPARTNER_ENABLE_SCHEDULER: '1', so the API approves commissions after their holdback, pays partners on your payout cadence and runs housekeeping on its own. See Scheduled jobs for what runs when, and set Payout cadence to Manual first if you want to pay partners by hand.

Keep the API at one instance (instance_count: 1). Every instance of a component gets the same env, so scaling it out would run the scheduler on each.

Custom domains

Add your domains in App Platform → Settings → Domains. DO handles certificate provisioning. A typical layout:

  • partners.yourdomain.com for the portal and API. Set PORTAL_URL to it.
  • go.yourdomain.com for partner links, e.g. https://go.yourdomain.com/r/<linkKey>. Partners paste these into their content, so keep it short.

Partner links also work on the app’s default ondigitalocean.app URL, because the spec routes /r/* to the router.

Keep domains in one place. doctl apps update --spec replaces the app’s whole spec, domains included. If you add domains in the console, also add them to a domains block in your fork’s spec, or make later changes in the console only.

File uploads (brand logos, creator avatars)

App Platform containers are ephemeral — local-disk storage gets wiped on every redeploy. For production, use DO Spaces (S3-compatible) so uploaded logos and avatars survive deploys.

  1. Create a Spaces bucket in the same region as your app.

  2. Generate a Spaces access key under API → Spaces Keys.

  3. Add these env vars on the api component (encrypted):

    OPENPARTNER_STORAGE_KIND=s3
    OPENPARTNER_STORAGE_S3_BUCKET=<bucket name>
    OPENPARTNER_STORAGE_S3_REGION=<bucket region, e.g. nyc3>
    OPENPARTNER_STORAGE_S3_ENDPOINT=https://<region>.digitaloceanspaces.com
    OPENPARTNER_STORAGE_S3_ACCESS_KEY_ID=<key>
    OPENPARTNER_STORAGE_S3_SECRET_ACCESS_KEY=<secret>

    If you’ve enabled the bucket’s CDN, also set OPENPARTNER_STORAGE_S3_PUBLIC_BASE=https://<bucket>.<region>.cdn.digitaloceanspaces.com.

Files written to Spaces get public-read ACL with a 1-year immutable cache header. Object keys are random hex (16 bytes), so make sure the bucket’s “Restrict File Listing” is on.

Updating

Push to your fork’s main branch. The spec sets deploy_on_push, so App Platform rebuilds and redeploys, and the API runs any new migrations when it starts. To change the app’s settings later, edit them in the console or run doctl apps update <APP_ID> --spec .do/app.yaml.