Environment reference
OpenPartner follows the principle that env is for secrets and build-time config; runtime
content lives in the admin UI. So this list is shorter than you’d expect from competing
products. .env.example in the repo lists every variable, including the ones only OpenPartner’s
hosted service uses.
Required
| Variable | Description |
|---|---|
DATABASE_URL | Postgres connection string. Both the API and the router need it. |
SECRETS_ENCRYPTION_KEY | 32 bytes, hex or base64. Encrypts the SMTP passwords, Postmark tokens and other secrets stored from the admin UI. The API won’t start in production without it. |
PORTAL_URL | Public URL of the portal, e.g. https://partners.yourdomain.com. It’s the API’s CORS allowlist and the base of emailed invite and sign-in links. The API won’t start in production without it. |
Generate a key with:
openssl rand -hex 32Optional
| Variable | Description |
|---|---|
OPENPARTNER_MODE | selfhost (default), flat, or revshare. Gates billing layer. Self-hosted installs stay on selfhost; the other two are OpenPartner’s hosted billing modes. |
OPENPARTNER_TENANCY | single (default) or multi. multi hosts several brands on one install, each under /t/<slug>/, and won’t start without DATABASE_URL_APP. |
DATABASE_URL_APP | Connection string for the openpartner_app Postgres role, which row-level security applies to. Required with OPENPARTNER_TENANCY=multi. With single, leave it unset and the API uses DATABASE_URL. An empty value doesn’t count as unset. |
OPENPARTNER_APP_DB_PASSWORD | Password for the openpartner_app role. The API creates the role, or updates its password, each time it starts. Set it together with DATABASE_URL_APP. |
OPENPARTNER_ENABLE_SCHEDULER | 1 turns on the API’s in-process scheduler: commission auto-approval, scheduled payouts, housekeeping. Anything else leaves it off. docker-compose.prod.yml defaults it to 1. Run it on one API process only. See Scheduled jobs. |
ADMIN_API_KEY | Bootstrap / CI bearer. Bypasses Admin user, behaves like a headless admin. |
COOKIE_DOMAIN | Read by the router. Domain of the _cref attribution cookie: set it to your site’s domain with a leading dot (.yourdomain.com) so the cookie reaches the pages where you run the SDK. Default: localhost. (docker-compose.prod.yml sets .<OPENPARTNER_DOMAIN> when you leave it unset.) |
CORS_EXTRA_ORIGINS | Origins the API accepts besides PORTAL_URL, comma-separated. |
METRICS_TOKEN | Bearer token required on /metrics. Unset, /metrics is open, so set it whenever the API is reachable from the internet. |
API_PORT / ROUTER_PORT | Ports the API and router listen on. Defaults: 4601 / 4701. |
VELOCITY_MAX / VELOCITY_WINDOW_MS | How many clicks one IP can make on one link per window before the rest are flagged for fraud review. Default: 20 per 60,000 ms. |
Mail settings saved in the admin UI (Settings → Email delivery, or the install wizard) win. These are fallbacks for when the UI has none.
| Variable | Description |
|---|---|
MAIL_FROM | Default From: address. Quote it if it has a display name: "Acme Partners <[email protected]>". |
POSTMARK_SERVER_TOKEN | Postmark API token. |
POSTMARK_MESSAGE_STREAM | Postmark message stream. Default: outbound. |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD | SMTP fallback. Port defaults to 587. |
SMTP_SECURE | 1 to connect over TLS. Port 465 always does. |
Stripe
All optional on a self-hosted install. Set them to your own Stripe account to pay partners over
Stripe Connect and take in Stripe revenue through /webhooks/stripe. Leave them blank to pay
partners by hand.
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | Your Stripe secret key. Connect payouts are transfers from this account’s balance. |
STRIPE_WEBHOOK_SECRET_PLATFORM | Signing secret(s) for the event destination that receives your account’s own events (checkouts, invoices, charges, transfers). Comma-separated if you have several. |
STRIPE_WEBHOOK_SECRET_CONNECT | Signing secret(s) for the event destination that receives connected-account events (account.updated). Comma-separated if you have several. |
STRIPE_WEBHOOK_SECRET | Legacy. A single secret, or a comma-separated list, accepted for both destinations when the two variables above are unset. It works, but an event isn’t checked against the destination it should arrive on. |
The other STRIPE_* variables in .env.example, such as the plan price IDs, configure
OpenPartner’s hosted plans. Leave them empty.
Outbound webhooks
Webhook deliveries and partner postbacks only go to public IP addresses. These two variables loosen that.
| Variable | Description |
|---|---|
OPENPARTNER_OUTBOUND_ALLOWED_PORTS | Ports deliveries may connect to, comma-separated. Unset: any port on a single-tenant self-host, 80 and 443 otherwise. |
OPENPARTNER_OUTBOUND_ALLOW_PRIVATE_CIDRS | Private or loopback ranges deliveries may reach anyway, as comma-separated CIDRs (e.g. to test against a local service). Only allowed with OPENPARTNER_MODE=selfhost and OPENPARTNER_TENANCY=single; in any other mode the API refuses to start with it set. |
File uploads (brand logos, creator avatars)
Two backends, picked by OPENPARTNER_STORAGE_KIND. Default is fs — a local
directory served at /uploads/*. Switch to s3 if your runtime is ephemeral (DO App
Platform, Fly Machines, Heroku, etc.) or you just want a managed object store.
fs (default)
| Variable | Description |
|---|---|
OPENPARTNER_STORAGE_KIND | fs (default). |
OPENPARTNER_STORAGE_FS_DIR | Where to write files. Default: /var/lib/openpartner/uploads. Mount a volume here so uploads survive restarts, and make it writable by the API container’s node user (UID 1000). See below. |
OPENPARTNER_STORAGE_FS_PUBLIC_BASE | Public URL base for the static handler. Default: ${API_URL}/uploads. Override if the API is behind a CDN or different hostname. |
API_URL | Public URL of the API, used only to build the default above. Default: http://localhost:4601, so set this or OPENPARTNER_STORAGE_FS_PUBLIC_BASE in production. |
The API image runs as node (UID 1000), not root, and doesn’t create /var/lib/openpartner.
Docker creates a missing mount point owned by root, so the API can’t write to a fresh volume
until you hand it to UID 1000. For a bind mount, chown -R 1000:1000 the host directory. For a
named volume, run once:
docker run --rm -v <volume>:/data alpine chown -R 1000:1000 /datas3 (any S3-compatible bucket — DO Spaces, AWS S3, Cloudflare R2, MinIO)
| Variable | Description |
|---|---|
OPENPARTNER_STORAGE_KIND | s3. |
OPENPARTNER_STORAGE_S3_BUCKET | Bucket name. |
OPENPARTNER_STORAGE_S3_REGION | Region (e.g. nyc3 for DO, us-east-1 for AWS). |
OPENPARTNER_STORAGE_S3_ENDPOINT | Custom endpoint URL. Required for non-AWS providers (e.g. https://nyc3.digitaloceanspaces.com). Omit for AWS S3. |
OPENPARTNER_STORAGE_S3_ACCESS_KEY_ID | Access key ID. |
OPENPARTNER_STORAGE_S3_SECRET_ACCESS_KEY | Secret access key. |
OPENPARTNER_STORAGE_S3_PUBLIC_BASE | Optional CDN URL base (e.g. https://uploads.cdn.openpartner.dev). When unset, URLs are constructed from endpoint + bucket. |
Uploads are written public-read with a 1-year immutable cache header. Object keys are
random hex (16 bytes), so directory enumeration isn’t possible — but make sure the bucket
itself doesn’t allow listing.
What’s NOT in env
These all live in the database and are managed from the admin UI:
- Program name (the brand partners see)
- Support email
- Active mail transport (Postmark vs. SMTP vs. none)
- Attribution model + lookback window (set per program)
- Commission rules
Why? You can rotate them without redeploying, and self-host customers don’t have to manage config in two places.