Configure a public installation
On this page
Use this guide to prepare one installation's database, email and subscription settings. For the local simulation, follow Run the local demo. For deployment, continue with Host on Render.
Public staging sends real email and uses Stripe test billing. Live sends real email and uses Stripe live billing. Give each environment its own database, origin, installation UUID, secrets and provider configuration. Do not convert a populated staging database into live: the billing environment, currency and tax mode are fixed once billing settings have been activated.
Create the private runtime file
Start from runtime.env.example, outside the source folder.
Use a private directory you control and replace /ABS/runtime.env in this guide with
its absolute, canonical path. On macOS or Linux:
cp runtime.env.example /ABS/runtime.env
chmod 600 /ABS/runtime.env
Edit this new file and replace every example value. Generate an installation UUID once:
node -e "console.log(require('node:crypto').randomUUID())"
Generate AUTH_SECRET and an independent email encryption key by running this command twice:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
Keep each variable on one line. Keep JSON compact and single-quoted, as in the template;
encode any apostrophe inside JSON as \u0027. Duplicate names and multiline values are
refused. Do not source the file in a shell. Run commands through the supplied loader:
npm run with-env -- /ABS/runtime.env doctor
The loader requires a regular file owned by your current user, with no group or other
permissions, and no symlink in the file or directory path. It passes values literally;
it does not expand shell variables. Installation settings come from this file;
missing values are not borrowed from your shell or .env.local.
Before using the runtime file with Next.js commands such as dev, start or
render:web, move Next.js dotenv configuration files out of the source directory,
or use a separate source copy for the public installation. This includes .env,
.env.local and mode-specific files such as .env.production; example templates
can stay. Stop the web service before changing these files or its runtime configuration.
Set the installation and database
| Setting | Value to provide |
|---|---|
APP_MODE |
public. |
APP_ENVIRONMENT |
staging or live. |
APP_ORIGIN |
Your canonical HTTPS origin, such as https://staging.example.com; lowercase hostname, no path, trailing slash or port. Localhost, .local and .test domains are refused. |
DATABASE_URL |
Direct PostgreSQL 18 connection URL containing host, username, password and database. Use a dedicated empty database for a new installation. |
APP_INSTALLATION_ID |
The lowercase UUID generated for this installation. Keep it stable. |
AUTH_SECRET |
Independent random secret: 64 lowercase hexadecimal characters. |
URL-encode special characters in database credentials. Use a DNS name or IPv4 address;
IPv6 database endpoints are not supported. Public connections verify the database's
TLS certificate. The only accepted URL query options are sslmode=require and
channel_binding=require, once each; the drivers remove these hints and enforce their
own TLS policy. Channel binding is not guaranteed. Use the direct connection endpoint,
not a transaction-pooling URL.
The connection's first existing schema in search_path must be public.
Migrations and diagnostics refuse another default schema before installing tables.
Keep the UUID and encryption keys with your protected installation records and recovery
material. Replacing an existing key's bytes under the same version name is refused.
Changing AUTH_SECRET on an established installation requires the
rotation procedure, not just an environment edit.
Configure Stripe subscriptions
Select the Stripe account and test environment for staging, or the live environment
for live. All keys, prices, portal configuration and webhook must belong to that
selection. The application creates its own customer and subscription bindings during
Checkout; do not pre-create customer records as a setup step.
For live, the account must have charges and payouts enabled and its card_payments
capability active; otherwise the provider check refuses it.
1. Create the prices and offers
Create one or more positive, fixed, per-unit subscription prices with licensed usage, recurring every one month or one year. Metered prices, adjustable quantities, custom amounts and transformed quantities are not supported.
Choose one currency for the installation: eur, usd, gbp, cad or aud.
Choose STRIPE_TAX_MODE="none" or "automatic-exclusive". The latter requires
exclusive prices and compatible Stripe Tax configuration in your account. Inclusive
prices are not supported in either mode. The setting describes how this application
requests tax calculation; it does not establish your tax obligations.
Put the saved price IDs in STRIPE_OFFERS, for example:
STRIPE_CURRENCY="eur"
STRIPE_TAX_MODE="none"
STRIPE_OFFERS='[{"key":"standard-monthly","price":"price_REPLACE","label":"Standard monthly","quota":20,"available":true},{"key":"standard-yearly","price":"price_REPLACEYEAR","label":"Standard yearly","quota":20,"available":true}]'
quota is the team's project limit. The displayed amount and interval come from Stripe,
not the label. Each offer must have exactly the five fields shown above:
key: unique, starts with a lowercase letter, followed by lowercase letters, digits, underscores or hyphens; at most 32 characters.price: unique Stripe price ID.label: 1–80 characters, without leading or trailing whitespace.quota: integer from 1 to 999999.available:truefor selectable offers,falsefor retired offers retained for existing subscriptions.
Use 1–16 offers and at most 32 KiB of JSON. Keep prices still referenced by existing subscriptions in the list when retiring an offer. Available prices must remain active.
2. Save a compatible customer portal
Create or update the portal configuration used by this installation, and retain its
bpc_… ID. It must be active and have these settings:
- Payment-method updates enabled; cancellation enabled at period end.
- Subscription updates enabled with price as the only allowed update.
- Proration behavior
create_prorations; billing-cycle anchor unchanged. - Adjustable quantity disabled for every product.
- Selectable prices exactly matching offers with
available: true, grouped under their actual Stripe product, with no duplicates or retired prices. - At most one available monthly and one available yearly price per product.
- Period-end scheduling conditions limited to
decreasing_item_amountandshortening_interval. Includedecreasing_item_amountwhen available prices on the same product differ in amount.
For the two-offer example above, you can create the exact configuration through the
installed Stripe SDK. First put the selected STRIPE_SECRET_KEY and
STRIPE_ACCOUNT_ID in your private runtime file. Replace the product and two price
IDs below with the same product's monthly and yearly prices from step 1. Run this
from the source folder after npm ci:
node --input-type=module - /ABS/runtime.env <<'NODE'
import Stripe from 'stripe';
import { readRuntimeEnv } from './scripts/shared/runtime-env.mjs';
const env = await readRuntimeEnv(process.argv[2]);
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2026-09-30.endive',
maxNetworkRetries: 0,
});
const account = await stripe.accounts.retrieve(null);
if (account.id !== env.STRIPE_ACCOUNT_ID) throw Error('Wrong Stripe account.');
const created = await stripe.billingPortal.configurations.create({
features: {
payment_method_update: { enabled: true },
subscription_cancel: { enabled: true, mode: 'at_period_end' },
subscription_update: {
enabled: true,
default_allowed_updates: ['price'],
proration_behavior: 'create_prorations',
billing_cycle_anchor: 'unchanged',
products: [{
product: 'prod_REPLACE',
prices: ['price_REPLACE_MONTHLY', 'price_REPLACE_YEARLY'],
adjustable_quantity: { enabled: false },
}],
schedule_at_period_end: {
conditions: [
{ type: 'decreasing_item_amount' },
{ type: 'shortening_interval' },
],
},
},
},
});
console.log(`STRIPE_PORTAL_CONFIGURATION_ID="${created.id}"`);
const saved = await stripe.billingPortal.configurations.retrieve(created.id, {
expand: ['features.subscription_update.products'],
});
console.log(JSON.stringify({ id: saved.id, active: saved.active, livemode: saved.livemode }));
NODE
This sends a request to Stripe and creates a new portal configuration in the key's
test or live environment; it does not create a subscription. The key is read from
the private file, not placed in the command or printed. Save the returned bpc_…
value as STRIPE_PORTAL_CONFIGURATION_ID. Check that active is true and
livemode matches the installation. Do not rerun the creation command simply to
inspect it: retrieve the saved ID instead, using
stripe.billingPortal.configurations.retrieve('bpc_YOUR_SAVED_ID', { expand: ['features.subscription_update.products'] })
with the same SDK initialization. For more products, add one products entry per
actual product, keeping the selectable prices identical to the available offers.
The preflight below checks the saved result against the application's full contract.
3. Create the webhook and save credentials
Create an enabled Stripe webhook endpoint for this installation:
https://YOUR_DOMAIN/api/billing/stripe/webhook
Use API version 2026-09-30.endive and exactly these events:
checkout.session.completed
checkout.session.expired
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
Do not select all events. Save these values in the runtime file:
| Setting | Staging / live value |
|---|---|
BILLING_MODE |
stripe-test / stripe-live. |
STRIPE_SECRET_KEY |
The selected environment's secret API key (sk_test_… / sk_live_…). Restricted rk_… keys are accepted when their permissions cover the required operations. |
STRIPE_ACCOUNT_ID |
Selected account's acct_… ID. |
STRIPE_PORTAL_CONFIGURATION_ID |
Saved portal's bpc_… ID. |
STRIPE_WEBHOOK_ENDPOINT_ID |
This endpoint's we_… ID. |
STRIPE_WEBHOOK_SECRET |
This endpoint's whsec_… signing secret. |
STRIPE_TEST_CLOCK_ID is optional for controlled Stripe test-clock exercises. Leave it
unset for ordinary setup; it is forbidden in live.
After installing the schema as described in Host on Render, run:
npm run with-env -- /ABS/runtime.env billing:recover -- preflight
Success prints "preflight":"compatible". This contacts Stripe to check the account,
prices, portal and saved endpoint, then records the billing environment, currency and
tax mode in the database. It does not charge a customer or test webhook delivery.
A refusal may identify the incompatible object, field and next action. Correct the
selected provider configuration before trying again.
Change an existing offer
Once a subscription's quota has been synchronized, changing that quota while keeping
the same Price ID is refused and can block its team's project writes. To offer a
different quota, create a new Stripe Price and add a new STRIPE_OFFERS entry. Keep
the old price association and its original quota while existing subscriptions use it;
set available: false if it should no longer be selectable. Adding the new offer does
not move existing subscriptions to it.
Update the existing portal's selectable prices to match the available offers and
the requirements in step 2. Keep its bpc_… ID and STRIPE_PORTAL_CONFIGURATION_ID
unchanged: existing subscriptions are bound to that portal. Do not rerun the portal
creation example for this change.
Coordinate the change using Deploy a later revision
so web, worker and operator runtime files use the same catalog and portal configuration.
Run the preflight above, then reconcile billing and verify that existing subscriptions
retain their expected quota and project access. In staging, also exercise a new Checkout
and a portal plan change, checking the resulting quota after synchronization. Follow the
live verification guidance for any real transaction.
Configure real email with Resend
- Add a sending domain in Resend, publish the DNS records supplied for that domain, and wait for verification. Use a sender address on that verified domain.
- Create a sending API key permitted to send from the selected domain.
- Create a webhook for
https://YOUR_DOMAIN/api/email/resend/webhook, subscribed toemail.sent,email.delivered,email.delivery_delayed,email.bounced,email.complained,email.failedandemail.suppressed. - Copy the API key and webhook signing secret into the runtime file. The Resend
signing secret is separate from Stripe's, even though both start with
whsec_. - Set the independent encryption key generated earlier:
EMAIL_FROM="hello@example.com"
EMAIL_FROM_NAME="Your application"
RESEND_API_KEY="re_REPLACE"
RESEND_WEBHOOK_SECRET="whsec_REPLACE"
PUBLIC_EMAIL_KEYS='{"v1":"REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS"}'
PUBLIC_EMAIL_ACTIVE_KEY="v1"
Use a lowercase sender address. The display name supports letters, digits, spaces, periods, ampersands, apostrophes, underscores and hyphens, up to 80 characters; it must start with a letter or digit and have no surrounding whitespace.
The key ring accepts 1–5 independently generated keys, with unique values. Version names start with a lowercase letter and contain lowercase letters, digits or hyphens, up to 32 characters. The active version must be present. Keep older keys while retained email payloads need them; see Operate the installation.
Set email request budgets
Identity email and business notifications have separate budgets. Both consume your Resend allowance, so size their combined traffic against your provider account.
| Setting | Default | Allowed values |
|---|---|---|
PUBLIC_EMAIL_DAILY_LIMIT |
100 |
Integer 1–100000. |
PUBLIC_EMAIL_RECIPIENT_HOURLY_LIMIT |
3 |
Integer 1–100. |
BUSINESS_EMAIL_DAILY_LIMIT |
100 |
Integer 1–100000. |
BUSINESS_EMAIL_RECIPIENT_HOURLY_LIMIT |
3 |
Integer 1–100. |
PUBLIC_EMAIL_WARNING_PERCENT |
80 |
Integer 1–100; warning threshold for both daily budgets. |
Daily budgets use UTC days. Configuration checks validate setting formats; verify the sending domain, API key and actual delivery by completing a staging account workflow after deployment.
Set staging access and monitoring
The Render web entry point requires Basic Auth in staging:
KIT_STAGING_ACCESS='{"origin":"https://staging.example.com","username":"staging","password":"REPLACE_WITH_AN_INDEPENDENT_RANDOM_SECRET"}'
The origin must exactly equal APP_ORIGIN. Use a username of 1–64 letters, digits,
underscores or hyphens, and a password of 16–128 printable ASCII characters without
spaces. Remove KIT_STAGING_ACCESS entirely for live; live startup refuses it.
Leave KIT_COLLECTOR_ENABLED="0" initially. The worker still runs its monitor and
writes operational results to logs. The optional external heartbeat collector is
configured separately in Operations.