Skip to content
Atelierby Alto Brio
Documentation navigation

Atelier 0.7.0

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: true for selectable offers, false for 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_amount and shortening_interval. Include decreasing_item_amount when 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

  1. 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.
  2. Create a sending API key permitted to send from the selected domain.
  3. Create a webhook for https://YOUR_DOMAIN/api/email/resend/webhook, subscribed to email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.failed and email.suppressed.
  4. 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_.
  5. 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.