Skip to main content

Environment variables

This is the per-variable reference for a self-hosted install. The setup order — Firebase project, security rules, indexes, TTL policies, reverse proxy — is in Self-hosting; read that first and keep this page open beside it while you fill in .env.selfhost.

Everything here was read out of the source rather than copied from the template. Where a variable is absent from .env.selfhost.example, this page says so, because the template is what most operators actually fill in.

Read this before you set anything​

Some of these are frozen when the image is built

Next.js replaces the literal text process.env.NAME with its value at build time. It does that for three groups:

  1. Anything named NEXT_PUBLIC_*, in both server and browser code.
  2. Anything listed in a next.config.js env block — with-aglyn.nextjs.config.js, apps/console/next.config.js and apps/tenant/next.config.js each declare one.
  3. Anything reachable from apps/console/middleware.ts, which is compiled into the edge bundle. The edge runtime has no request-time environment at all.

For every variable in those groups, changing the value in .env.selfhost and restarting the container does nothing. There is no error and no warning: the old value is literally compiled into the JavaScript that is running. You have to docker compose build again.

Only the dot form is substituted — the bracket form process.env['NAME'] never is — so a variable read that way stays runtime-only even when its neighbors are not.

Every row carries a When column:

WhenMeaning
RuntimeRead on each request or at process start. Change it and restart the container.
BuildFrozen into the image. Change it and run docker compose build before up.

And a Need column:

NeedMeaning
RequiredThe deployment does not work without it.
FeatureOne named feature is off without it. The row says which, and what you see.
OptionalA default applies. The row gives it.
Aglyn-onlyRead by the code but only meaningful on Aglyn's own hosted deployment. Leave unset.

Firebase — identity and data​

Firebase is not optional in self-hosting v1: Auth, Firestore, Storage, Realtime Database and Remote Config are the platform's identity and data layer. Create a project at console.firebase.google.com and enable all of them.

Client SDK config​

Where to get these: Firebase console → Project settings → General → Your apps → add a Web app → copy the firebaseConfig object. Each field below maps to one key of it.

Every one is NEXT_PUBLIC_*, so every one is Build. These end up in the browser bundle by design — Firebase web config is public, and access is controlled by your security rules, not by hiding these values.

VariableNeedWhenValue
NEXT_PUBLIC_FIREBASE_PROJECT_IDRequiredBuildThe project id, e.g. bramble-platform. Also the last of four sources the SAML SSO auth origin is derived from.
NEXT_PUBLIC_FIREBASE_PUBLIC_API_KEYRequiredBuildapiKey. Starts AIza….
NEXT_PUBLIC_FIREBASE_AUTH_DOMAINRequiredBuildauthDomain, e.g. bramble-platform.firebaseapp.com.
NEXT_PUBLIC_FIREBASE_DATABASE_URLRequiredBuilddatabaseURL — Realtime Database, not Firestore. Presence and live collaboration use it.
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKETRequiredBuildstorageBucket, e.g. bramble-platform.firebasestorage.app. Bare bucket name, no gs://.
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_IDRequiredBuildmessagingSenderId — a numeric string.
NEXT_PUBLIC_FIREBASE_APP_IDRequiredBuildappId, e.g. 1:123456789012:web:abc123def456.
NEXT_PUBLIC_FIREBASE_MEASUREMENT_IDOptionalBuildmeasurementId, G-XXXXXXXXXX, present only if you enabled Google Analytics on the Firebase project. Unset, Firebase Analytics is not configured and server-side conversion events carry no GA client id, so a purchase cannot be stitched back to the browser session that made it.
NEXT_PUBLIC_FIREBASE_AUTH_HANDLER_HOSTOptionalBuildA branded host serving /__/auth/handler and used as the SAML ACS origin. Precedence: this → auth.<workspace domain> → the auth domain above. A bare hostname, e.g. auth.example.com. It must also be a Firebase authorized domain and a registered OAuth redirect URI.
NEXT_PUBLIC_RECAPTCHA_PUBLIC_KEYFeatureBuildThe reCAPTCHA v3 site key, only if you enable Firebase App Check. Unset, App Check registration is skipped and the app logs one line saying so.
App Check is per Firebase app, and the key has its own allowlist

If you set NEXT_PUBLIC_RECAPTCHA_PUBLIC_KEY, the reCAPTCHA key's own domain allowlist has to cover every origin you serve — your console host and your site apex. reCAPTCHA matches a listed name and everything beneath it, never its parent, so listing example.com does not cover sites.example.com.

Admin service account​

Where to get these: Firebase console → Project settings → Service accounts → Generate new private key. That downloads a JSON file; three of its fields become environment variables.

VariableNeedWhenValue
FIREBASE_PRIVATE_KEYRequiredRuntimeThe private_key field with its literal \n escapes left in place, wrapped in double quotes: "-----BEGIN PRIVATE KEY-----\nMIIE…\n-----END PRIVATE KEY-----\n". The code replaces \n with real newlines itself.
FIREBASE_CLIENT_EMAILRequiredRuntimeThe client_email field, e.g. firebase-adminsdk-x1y2z@bramble-platform.iam.gserviceaccount.com.
FIREBASE_PROJECT_IDRequired (setup scripts)RuntimeThe project_id field. The running apps read NEXT_PUBLIC_FIREBASE_PROJECT_ID; this unprefixed one is what the tools/scripts/* setup, migration and backfill scripts read. Set both, to the same value.

The Admin SDK initializes only when FIREBASE_PRIVATE_KEY, FIREBASE_CLIENT_EMAIL and NEXT_PUBLIC_FIREBASE_PROJECT_ID are all present. Miss any one and initialization is skipped silently at module load; the containers still serve pages and /api/health answers 500.

docker run --env-file mangles the private key

docker run does not strip the quotes around a value, so FIREBASE_PRIVATE_KEY="-----BEGIN…" arrives with the quote characters attached and you get Failed to parse private key with an OpenSSL DECODER routines::unsupported stack. The quotes have to stay in the file, so use docker compose up, which strips them.

Seven template lines that nothing reads​

.env.selfhost.example carries the whole service-account JSON, but only the three fields above are ever read. These seven are inert — nothing in the console, the tenant runtime, the cloud functions or the setup scripts looks at them:

FIREBASE_TYPE · FIREBASE_PRIVATE_KEY_ID · FIREBASE_CLIENT_ID · FIREBASE_AUTH_URI · FIREBASE_TOKEN_URI · FIREBASE_AUTH_PROVIDER_X509_CERT_URL · FIREBASE_CLIENT_X509_CERT_URL

Nor does the Admin SDK read them behind our backs — the recurring guess, and it is wrong. Every initializeApp in the product builds its credential with cert({ projectId, clientEmail, privateKey }), an explicit three-field object; that overload accepts no other fields, and no code path hands the SDK a whole service-account object assembled from the environment. The setup scripts that use applicationDefault() instead read GOOGLE_APPLICATION_CREDENTIALS, a path to a JSON file, not these variables.

Leaving them blank changes nothing. They are in the template so the block reads as a whole service account and a copy-paste from the JSON does not look half-done.

Firestore and Storage​

VariableNeedWhenValue
FIRESTORE_DATABASE_IDOptionalRuntimePoints every Admin-SDK Firestore accessor — console, tenant and every tools/scripts/* run — at a named database instead of (default). Read at call time, so a restart picks it up. Unset or empty targets (default). The case for setting it is disaster recovery: gcloud firestore databases restore creates a new named database, and this repoints the apps at it with no code change.
FIREBASE_DATABASE_URLRequired (setup scripts)RuntimeThe Realtime Database URL again, for the setup scripts. The apps read NEXT_PUBLIC_FIREBASE_DATABASE_URL.
FIREBASE_STORAGE_BUCKETRequired (setup scripts)RuntimeThe bucket name again, for the setup scripts. The apps read NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET.
FIRESTORE_EXPORT_BUCKETOptionalRuntimeDestination bucket for the scheduled Firestore export, and the bucket /api/health/backups inspects for backup age. Default <projectId>-firestore-exports. Bare GCS bucket name, no gs://. Point it at a bucket in a sibling project for cross-project DR. If neither the default nor your value exists, exports fail and the backups health check reports missing or stale backups.
GCLOUD_PROJECTOptional (scripts only)RuntimeRead only by the tools/scripts/*.mjs maintenance and backfill scripts, where it defaults to aglyn-main. No app or cloud-function code reads it — the runtime resolves the project from the Admin SDK credentials. Set it to your project id before running any script, or the script targets a project name you cannot reach and fails.
Bucket CORS is a file, not a variable

cloud/storage-cors.json in the repository is Aglyn's own live bucket policy — it names https://app.aglyn.com, and a test asserts it stays byte-identical to what our bucket serves. It is applied with gcloud --cors-file, which cannot read environment variables. Copy it, replace the origin with your console's, and apply your copy. Without that, every upload over 3 MB dies at the CORS preflight as a generic "try again".


The addresses this install calls its own​

This is the group most often left at its default, and several of the defaults are Aglyn's addresses. Every one is Build.

VariableNeedWhenValue
NEXT_PUBLIC_CONSOLE_URLRequiredBuildFull origin of your console, no trailing slash: https://console.example.com. Feeds the tenant's fallback redirect, the edit-hint return allowlist, the auth-action link base and the CSP frame-ancestors list. It is also the host the console serves itself on: any label under NEXT_PUBLIC_WORKSPACE_DOMAIN works (console., app. or your own), that label can never be claimed as a workspace, and an unknown workspace, a lapsed custom console domain and a white-label sign-in are all sent back to it (inside the console the fallback is app.<NEXT_PUBLIC_WORKSPACE_DOMAIN>). Default https://app.aglyn.com, so unset sends a visitor who lands on an unresolvable host to Aglyn's console and builds password-reset links on it.
NEXT_PUBLIC_WORKSPACE_DOMAINRequiredBuildThe apex organization workspaces hang off, bare: example.com. A workspace acme is advertised at acme.example.com. Point a wildcard *.example.com at your console, or that URL does not resolve.
NEXT_PUBLIC_TENANT_DOMAINRequiredBuildThe apex your published sites' subdomains hang off, bare: sites.example.com. Defaults to aglyn.app — Aglyn's cloud — so leaving it unset makes your console display and link every one of your sites at an address you do not control, and makes each published site advertise that address as its canonical origin to search engines, feed readers and inboxes.
NEXT_PUBLIC_AGLYN_TENANT_HOST_CNAMERequiredBuildThe CNAME target your console prints and verifies in the custom-domain wizard. Normally the same value as NEXT_PUBLIC_TENANT_DOMAIN. Default sites.aglyn.app.
AGLYN_TENANT_HOST_CNAMERequiredBuildThe same value again, without the prefix. This is the load-bearing half: the tenant middleware matches the incoming Host: header against it. Leave it blank and nothing matches, so every visitor to every published site is redirected to your console — the deployment looks broken rather than misconfigured. Inlined through apps/tenant/next.config.js, so it must be right before docker compose build, not merely before up.
NEXT_PUBLIC_AGLYN_TENANT_APEX_ADDRESSESSet itBuildComma-separated IPv4 addresses the custom-domain wizard tells a customer to point an apex domain at. Default is Vercel's anycast set (216.198.79.1, 76.76.21.21 and three more) — Aglyn's own infrastructure. Unset, your console instructs your customers to point their apex DNS at a network you do not run.
AGLYN_TENANT_APEX_ADDRESSESSet itRuntimeThe same list again, server-side, used when /api/domains/verify checks an apex. Set both to the same value: setting only this one leaves the wizard printing the default list while the route verifies yours.
NEXT_PUBLIC_APP_URLOptionalBuildBase for the staff enterprise-billing Stripe checkout return URL, used only when the request supplies no Origin. Default https://app.aglyn.com. This is the only reader; everything else uses NEXT_PUBLIC_CONSOLE_URL.
NEXT_PUBLIC_DOCS_ORIGINOptionalBuildWhere every documentation link points: AI-assist citations, console help, besigner help, and the documentation URL your own REST API returns. Default https://docs.aglyn.com — this documentation, which stays broadly correct for a self-host install. Point it at your own build of apps/docs if you publish one.
NEXT_PUBLIC_AGLYN_DOCS_URLDeprecatedBuildThe older name for the same thing, still honored so an existing install does not break. NEXT_PUBLIC_DOCS_ORIGIN wins where both are set. Setting only this one retargets the console and besigner links while Assist citations keep the default.
AGLYN_SILOED_HOSTOptionalBuildOrigin the besigner editor iframe is loaded from. Unset it resolves to the console's own origin, which is what a self-host install wants. Set it only if you serve the besigner from a separate host. A bare hostname, or a value already starting // or http.
NEXT_PUBLIC_PLATFORM_BRAND_HOSTSOptionalBuildComma-separated bare hostnames that wear your brand palette instead of the neutral tenant default: example.com,marketing.example.com. A host named here resolves the platform theme in console.theme.ts; every other site resolves tenant.theme.ts — MUI's stock accents plus this platform's extra slots, which clears WCAG AA in both schemes without anyone authoring a palette. Default aglyn.com,aglyn.io, which matches nothing on your install, so unset every one of your sites — including your own marketing site — gets the tenant default. Set it to an empty string to put your own site there deliberately. A site that authored its own palette in the theme editor is unaffected either way.

Two more addresses are set by the image rather than by you:

VariableSet toNote
PORT4200 console, 4500 tenantCompose publishes both on 127.0.0.1.
HOSTNAME0.0.0.0Listens inside the container's own network namespace. Do not change it to reach the container from outside — that is what the proxy is for.

Your reverse proxy, the client IP, and geo​

X-Forwarded-For: tell the product how many proxies you run​

VariableNeedWhenValue
AGLYN_TRUSTED_PROXY_COUNTRecommendedRuntimeHow many proxies sit between the internet and the container. One reverse proxy is 1, which is also the default. A CDN in front of your own proxy is 2. 0 means nothing is in front and forwarding headers are ignored entirely. Not a header index — you never have to work out which end of the list to count from.

Every client address in the product is read through one reader, which takes the hop this number identifies and ignores everything to its left. With N proxies in front, the last N entries of x-forwarded-for were written by your proxies and are the only ones a caller cannot forge; the outermost of them recorded the address it actually saw, which is the visitor.

Set this if you run more than one proxy

nginx's usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for appends. A request arriving with a header the client typed — X-Forwarded-For: 1.2.3.4 — leaves your proxy as 1.2.3.4, <real address>. At the default of one trusted proxy the product reads <real address> and the forged value is discarded, which is what you want. Configure 2 when a CDN sits in front, or the reader names your own proxy instead of the visitor — several visitors then share one rate-limit bucket and limits bite sooner than they should.

Too high is the safer direction than too low: a chain shorter than the configured depth is clamped to its leftmost entry, which was still written by a proxy you trust.

What this protects:

  • Authentication throttles — passkey sign-in (both ceremony steps), password-reset mailbombing, identifier resolution, storefront member login and member recovery.
  • Provisioning throttles — organization creation (the bot-farm control), site creation, page-password unlock, form submission, newsletter signup, booking creation, visitor plugin writes, the pre-auth REST budget.
  • Unauthenticated beacons — the console and tenant error collectors, CSP reports, attribution, analytics collection.
  • Stored evidence — the address printed in the new-device sign-in alert email and stored on the user's device record, and the ipAddress written onto the clickwrap legal-acceptance record. Here a spoofed value is durable rather than merely a bypass: it is what an account owner reads when deciding whether a sign-in was theirs.

A single reverse proxy needs no configuration at all. Either style works:

# nginx — either form is correct at AGLYN_TRUSTED_PROXY_COUNT=1.
# $proxy_add_x_forwarded_for appends, and the appended entry is the one read.
location / {
proxy_pass http://127.0.0.1:4200;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
# Caddy — reverse_proxy sets X-Forwarded-For itself. Nothing to add.
console.example.com {
reverse_proxy 127.0.0.1:4200
}
# Traefik — leave insecure OFF and name the proxies you actually trust,
# so Traefik's own chain is one you can count.
entryPoints:
websecure:
address: ':443'
forwardedHeaders:
insecure: false
trustedIPs:
- 10.0.0.0/8 # your own load balancers only

If a CDN sits in front of your own proxy, that is two hops: set AGLYN_TRUSTED_PROXY_COUNT=2 and restrict your proxy to the CDN's egress ranges, so nothing can reach it around the CDN and shorten the chain.

x-real-ip and RFC 7239 Forwarded are read too, in that order, when x-forwarded-for carries nothing usable — so a proxy that sets one of those instead works without configuration. When nothing readable arrives the product gets no address rather than a placeholder, and each control decides for itself: address-keyed rate limits are skipped rather than collapsing every anonymous caller into a single shared bucket, and stored evidence records that the address is unknown instead of recording a guess.

docker-compose.yml publishes both containers on 127.0.0.1 so nothing can reach them without passing your proxy. Do not widen that binding. If your proxy runs on another host, put it on this host's network or firewall the published port to the proxy alone — a directly reachable container is a chain of zero trusted hops, and no header reading survives that.

Geo headers​

Three features read the visitor's country off a request header, and one reads the city. On Aglyn's cloud those come from Vercel's edge. A container has no edge — it gets whatever its proxy puts there.

VariableNeedWhenValue
AGLYN_GEO_COUNTRY_HEADERFeatureBuildName of the header carrying ISO 3166-1 alpha-2, lower-cased. Default x-vercel-ip-country. Behind Cloudflare: cf-ipcountry.
AGLYN_GEO_REGION_HEADERFeatureBuildName of the header carrying the ISO 3166-2 subdivision, bare (43) or prefixed (UA-43) — both are read, and a single digit is zero-padded. Default x-vercel-ip-country-region. Cloudflare sends no subdivision; leave it blank there.
AGLYN_GEO_CITY_HEADEROptionalBuildName of the header carrying the city. Default x-vercel-ip-city. Percent-encoding is decoded; anything over 64 characters is rejected, so a proxy leaking a user-agent under this name cannot become a "city".

These are build-time because apps/console/middleware.ts pulls the sanctions gate — and through it the geo reader — into the edge bundle, which has no request-time environment. Set them before docker compose build.

Unset does not mean broken. Each of the three falls through a chain of names that common edges already use, in this order:

CountryRegionCity
1your configured nameyour configured nameyour configured name
2cf-ipcountryx-appengine-regioncf-ipcity
3x-appengine-countryx-client-geo-regionx-appengine-city
4x-client-geo-countrycloudfront-viewer-country-regionx-client-geo-city
5cloudfront-viewer-countryx-geo-regioncloudfront-viewer-city
6fastly-geo-countryx-region-codex-geo-city
7x-geo-countryx-city
8x-country-code
9x-country

A name you configure always wins over the fallbacks, so a deployment behind Cloudflare, CloudFront, App Engine or Fastly usually works with none of the three set. XX, T1 and ZZ are rejected as non-countries rather than treated as country codes. There is deliberately no IP-geolocation lookup: sending a visitor's address to a third party to decide what to ask them about privacy would itself be a disclosure, and a subprocessor.

There is one further fallback, and it is client-side and console-only: when both the session cache and /api/consent/region produce nothing, the console infers a consent posture from the browser's own time zone. It answers only for the EEA / prior-consent set and returns "unknown" rather than guessing anywhere else. The tenant runtime has no such fallback.

What geo drives, and what happens with no signal:

FeatureWith a countryWith no country
Sanctions / embargo gate — console pages, session minting, org creationBlocks CU, IR, KP, SY, and — matched on the region header alone — Crimea, Sevastopol, Donetsk and Luhansk. A blocked visitor gets HTTP 451 with a plain page naming your operator identity.Fails open. Nothing is blocked. Logged once per instance as [sanctions-geo] FAILING OPEN. A country of UA with no region header logs a second, unthrottled line, because the sub-country entries cannot be evaluated.
Consent posture — /api/consent/region, console and tenantRegion-conditional consent defaults apply.Falls back to opt-in, the strictest posture, and logs [consent-geo] FALLING TO OPT-IN.
New-device sign-in alert emailNames city, region and country.The email says Unknown location, and that string is stored on the device record.
Staff breach-notification reportBuckets data subjects by country, read back off that stored string.Its widest bucket counts nobody. The report stays honest about what it does not know — it just knows nothing.

The tenant runtime is deliberately not wired to the sanctions gate. That is a decision, not a gap.


Secrets this deployment signs with​

Generate each with openssl rand -hex 32. All are runtime, all are server-only, and none of them may ever be prefixed NEXT_PUBLIC_.

VariableNeedWhenValue
TOKEN_SIGNING_SECRETRequiredRuntimeSigns commerce download links, gift-card links, gated-video streams, media access and edit-hint tokens. Must be identical for console and tenant — compose shares one env file, so it is. The code fails closed: unset, it throws rather than issuing an unsigned link.
MEMBER_SESSION_SECRETRequiredRuntimeSigns the storefront member session cookie (30-day TTL). Unset or empty, a fresh random key is generated per boot, so members are signed out on every container restart and replicas disagree with each other. An empty value is treated as unset, not as a key.
REVALIDATE_SECRETSet itRuntimeThe shared secret the console sends when it asks the tenant runtime to drop a published page from cache. Easy to miss, because its absence is completely silent: unset, the console never sends the request at all and records the reason as not-configured: publishing reports success and the live page keeps serving the old HTML for up to 10 minutes and the old site documents for up to an hour. Set the same value on both containers.
EMAIL_UNSUBSCRIBE_SECRETFeatureRuntimeHMAC key over {hostId}:{email} for one-click unsubscribe links. Falls back to CRON_SECRET; with neither set, campaign sends answer 501 and every unsubscribe link answers 400.
CRON_SECRETFeatureRuntimeThe shared secret every scheduled-job route checks — see Scheduled jobs. It is also the fallback unsubscribe key above, which makes rotating it a two-step operation: rotating it alone permanently breaks the unsubscribe link in every marketing email already delivered.
PLUGIN_JOBS_SECRETFeatureRuntimeAuthorizes the tenant's /api/plugins/run-jobs endpoint, which the every-minute beat POSTs. Unset, that route answers 501 and no scheduled plugin job ever runs — no scheduled publishing, no booking-hold expiry. On the cloud-functions side it is a Secret Manager secret, not a plain variable.
VERCEL_LOG_DRAIN_SECRETAglyn-onlyRuntimeSignature secret for the Vercel log-drain receiver. Fails closed if unset, which is the correct state off Vercel.
AGLYN_PROBE_TOKENOptionalRuntimeSent as x-aglyn-probe by the scheduled jobs and the uptime scripts so a bot-protection layer in front of your console lets them through. Only needed if you have such a layer; unset, the header is simply not sent. Never send it to a third-party host.
AGLYN_VERCEL_BYPASSAglyn-onlyRuntimeVercel's Protection Bypass for Automation secret, sent as x-vercel-protection-bypass by the scheduled jobs. Distinct from AGLYN_PROBE_TOKEN above, which a custom firewall rule matches: a custom rule cannot exempt Vercel's Attack Challenge Mode, which challenges everything at the edge ahead of it — on 2026-09-19 that answered the beats with 403 for fifteen hours while the bypass rules were in place and working. Only meaningful on Vercel; unset, the header is simply not sent, which is correct anywhere else.
AGLYN_FUNNEL_HOSTOptional (scripts only)RuntimeHost the check:funnel-conversions operator script attributes signup and sign-in page views to, defaulting to Aglyn's own marketing host. Set it to the host serving YOUR signup page, or the ratio is computed over a hostname nobody visits and reads as a permanent outage.
AGLYN_FUNNEL_MIN_LEAD_FORMSOptional (scripts only)RuntimeHow many PUBLISHED lead-routing forms /api/health/funnel expects the watched site to carry. Graded in both directions: fewer means the funnel lost a lead surface, more means the number no longer describes the funnel — and the surplus is exactly how many forms could stop routing before a count could notice. Set it to what your funnel actually has; it is deliberately not derived from the forms it grades, because an expectation that tracks its own subject can never fail.
FIREBASE_RULES_API_BASEOptional (scripts only)RuntimeOverrides the Firebase Rules API origin the rules deploy and drift scripts talk to. Exists for tests and for an emulator; leave it unset against a real project.
IDENTITY_TOOLKIT_API_BASEOptional (scripts only)RuntimeOverrides the Identity Platform origin the auth-door health probe talks to. Same purpose and same advice as the row above — unset is correct against a real project.
FUNCTION_TARGETSet by the platformRuntimeSet by the Cloud Functions runtime to the name of the function being served; the code reads it, never writes it. beforeSignupCreate uses it to warm its lockdown read ONLY when it is the function actually running, so the other exports in the same module do not pay for a read they never make. Do not set it yourself.
AGLYN_SIGNIN_PROBE_EMAILOptionalRuntimeAddress of a disposable account the passwordSignIn door of /api/health/auth-doors signs in as, so the check exercises a real sign-in rather than only the identity provider's reachability. Give the account no organization, no entitlement and no staff claim — a monitor that owns something is a blast radius. Unset, the door is still graded by its anonymous half; it does not report an outage.
AGLYN_SIGNIN_PROBE_PASSWORDOptionalRuntimeThat account's password. Store it the way you store any credential — it never belongs in the repository or in an image. Needed together with the address above: one without the other is treated as no probe at all, because a typo must not look like a sign-in outage.

Requiring SSO for a domain you own​

VariableNeedWhenValue
AGLYN_SSO_REQUIRED_DOMAINSFeatureRuntimedomain=gcipTenantId pairs, comma- or space-separated: example.com=bramble-tenant-a1b2c. Empty governs nothing, which is the right default. Anything unparseable is dropped rather than throwing — this sits on the sign-in path and a malformed value must not take authentication down.
AGLYN_SSO_DOMAIN_ENFORCEMENTFeatureRuntimeExactly on starts refusing sign-ins that violate the rule above. Anything else is off. Both halves are needed: this switch alone governs nothing.
AGLYN_SSO_EXEMPT_EMAILSFeatureRuntimeAddresses on a listed domain that may still sign in without SSO, comma- or space-separated, such as an app store reviewer's demo login. name+tag*@domain also admits every address extending that plus tag, for a check that signs up a fresh account each run; a * anywhere else is ignored. Empty by default.
Keep an account the rule cannot refuse

A wrong tenant id here locks you out of your own console. The rule is about a domain, not about staff — staff can be granted to any account, on any domain, in any pool — so keep one owner account on an ungoverned domain before you turn enforcement on.

Session and auth-link settings​

VariableNeedWhenValue
AUTH_ACTION_ALLOWED_ORIGINSOptionalRuntimeComma-separated extra origins a password-reset or verify-email link may be built on when the request supplies one. Empty — the default — means request-supplied origins are always ignored and the link is built on NEXT_PUBLIC_CONSOLE_URL, which is the safe state. This is a security boundary: a wrong entry lets a request-supplied host receive a live reset code. Intended for preview deployments.
NEXT_PUBLIC_AUTH_IDLE_TIMEOUT_MINUTESOptionalBuildIdle window before the console signs a user out. Default 120. A non-numeric value makes the comparison NaN, so the idle logout silently never fires — there is no clamping and no warning.

Stripe​

Stripe is optional. Without it, commerce checkout and paid platform plans are unavailable and the rest of the platform runs.

Where to get these: dashboard.stripe.com → Developers → API keys for the two keys, Developers → Webhooks for the signing secrets, and Product catalog → each price → its price id.

VariableNeedWhenValue
STRIPE_SECRET_KEYFeatureRuntimesk_live_… or sk_test_…. Enough on its own for storefront commerce checkout.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYFeatureBuildpk_live_… / pk_test_…. Needed for platform plan checkout; the secret key alone is not enough.
STRIPE_WEBHOOK_SECRETFeatureRuntimewhsec_… for the endpoint you create at https://console.example.com/api/billing/webhook. Without it, webhook-driven state — subscription changes, payment settlement — is never applied: the money moves and your database never hears about it.
STRIPE_WEBHOOK_SECRET_TESTOptionalRuntimeA second accepted signing secret so test-mode deliveries verify while live keys are in force. Each is tried in turn; the webhook route answers 501 only when all three secrets are unset.
STRIPE_CONNECT_WEBHOOK_SECRETFeatureRuntimewhsec_… for the Connect destination, which feeds Connect readiness. Unset, those deliveries fail signature verification with 400 behind a green "Active" badge in the Stripe dashboard.
STRIPE_WEBHOOK_URLOptionalRuntimeThe endpoint URL the billing health check expects to find registered in your Stripe account. Default is Aglyn's URL, so a self-host operator who leaves it unset gets a permanent endpoint-missing red on /api/health/billing. Must match the URL exactly as Stripe stores it.
STRIPE_LIVEMODEOptionalRuntimeOverrides whether this deployment considers itself the live one. Normally inferred from the key prefix. Exactly true or false — anything else, 1 included, falls through to inference.
STRIPE_TERMINAL_LIVE_ENABLEDOptionalRuntimeExactly true turns on Stripe Terminal card readers for live register payments, once Terminal is activated for live payments on your platform Stripe account. With a test key (sk_test_…) Stripe's simulated reader works without it. Unset, the register and its settings hide every card-reader control and the server refuses a card-present payment.
AGLYN_PLATFORM_ORG_IDOptionalRuntimeThe id of the organization that runs this deployment. With the marketplace fee waived for it, that organization sells without a payout account: there is nobody to pay, so a sale makes no transfer. Leave it unset if you do not sell on your own marketplace. Never client-readable.
Localhost with live keys

STRIPE_SECRET_KEY does not know it is on your laptop. If you copy a filled-in .env.selfhost into a development checkout, swap in test keys.

Card reader start-up screens

Readers show each store's chosen start-up screen with no extra variable. The default screen is uploaded on Staff → Reader start-up screen (a PNG or JPEG under 2 MB: 1080 × 1920 for the S700 and S710, 720 × 1280 for the WisePOS E, which is drawn from the first when left empty). In the Stripe Dashboard, leave your platform account's default Terminal configuration without a splash screen: a Location without its own screen inherits it, so it would also show on white-label workspaces' readers.

The screens are drawn with sharp. On Vercel only the console's /api/plugin-images/… function ships its native library, so the console's other functions and the tenant's scheduled jobs ask that function to draw, at NEXT_PUBLIC_CONSOLE_URL, signed with TOKEN_SIGNING_SECRET; set both on both projects. Anywhere else (one server holding every route) the screens are drawn where they are asked for, and nothing more is needed.

Which events to subscribe​

A signing secret only proves the delivery is real. If the endpoint is not subscribed to an event, Stripe never sends it, the handler answers nothing, your dashboard stays green, and the state it drives simply never moves — a refund that never revokes an entitlement looks exactly like a healthy integration.

Subscribe your platform endpoint to all twenty:

customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
checkout.session.completed
invoice.finalized
invoice.paid
invoice.payment_failed
invoice.voided
invoice.marked_uncollectible
charge.refunded
charge.dispute.created
charge.dispute.closed
customer.updated
payment_method.attached
payment_method.detached
radar.early_fraud_warning.created
review.opened
charge.failed
terminal.reader.action_succeeded
terminal.reader.action_failed

radar.early_fraud_warning.created and review.opened raise fraud signals to staff. charge.failed lets a workspace's own failed subscription payment place an automatic security hold when Stripe flags it as fraud. The two terminal.reader events complete a card-reader payment at the point-of-sale register; without them a reader payment still completes while the register screen is open, because the register checks the reader itself.

invoice.voided, invoice.marked_uncollectible and the three customer and payment-method events serve usage a plugin bills on its own one-off invoices. Without invoice.voided and invoice.marked_uncollectible an unpaid usage invoice never ends, so the pause it set on further usage never lifts. Without the three customer events nothing notices that the workspace's default payment method changed, and a bound that assumes a card holds against a bank debit that takes days to settle.

Connect is a second destination, not more events on the first one. Connected-account events are delivered only to an endpoint created with connect: true, it carries its own signing secret (STRIPE_CONNECT_WEBHOOK_SECRET), and it needs one event:

account.updated

Without it, a merchant whose Stripe account is later restricted keeps selling against a stale readiness flag, and the shopper meets the failure at payment time. Create that destination with the metadata aglyn_scope=connect — Stripe's API does not report the connect flag back, so that marker is how the health check recognizes it.

/api/health/billing reports what is missing under unsubscribedRequiredEvents. Check it after any change to your Stripe account, not only at setup.

Plan and add-on price ids​

Only needed if you sell platform plans to your own users. A single-tenant install normally leaves the whole block empty. All are runtime, all take a Stripe price id (price_…), and none is in .env.selfhost.example.

Base plan, one per plan per interval, for the two paid plans sold, Starter and Pro:

STRIPE_PRICE_STARTER STRIPE_PRICE_STARTER_YEARLY
STRIPE_PRICE_PRO STRIPE_PRICE_PRO_YEARLY

There is deliberately no STRIPE_PRICE_ENTERPRISE: Custom is quoted per deal and is not self-serve. free has nothing to sell. The plans that are no longer sold read no price variable at all.

The staff Coupons page reads the same four variables. A coupon limited to a plan's base price (What a coupon applies to) is limited to the Stripe product those prices belong to, which the console looks up from the price ids when the coupon is created. Nothing else needs configuring for it, and no product id is set anywhere. With neither of a plan's two variables set, that plan cannot be picked for a base-price coupon, and the page says which variable to add. Keep each plan's monthly and yearly prices on one product, as the setup script does; if they are on two, the coupon is limited to both.

Per-plan add-ons. The name is assembled at call time as STRIPE_PRICE_{PLAN}_{KIND}[_YEARLY], so every combination below is a real variable the code looks up:

Add-onName pattern
Extra manager seatSTRIPE_PRICE_{STARTER|PRO}_EXTRA_SEAT[_YEARLY]
Extra membersSTRIPE_PRICE_{…}_EXTRA_MEMBER[_YEARLY]
Extra datasetsSTRIPE_PRICE_{…}_EXTRA_DATASET[_YEARLY]
Extra hostsSTRIPE_PRICE_{…}_EXTRA_HOST[_YEARLY]

Flat add-ons, priced the same across plans:

STRIPE_PRICE_POS_REGISTER STRIPE_PRICE_POS_REGISTER_YEARLY
STRIPE_PRICE_EVENT_CALENDAR STRIPE_PRICE_EVENT_CALENDAR_YEARLY
STRIPE_PRICE_SITE_CARE STRIPE_PRICE_SITE_CARE_YEARLY

The Site Care price also names the product that Site Care extra time is billed under: the extra-time invoice item is priced against that product, and its tax code must be txcd_10103001, or the charge is refused rather than billed under the wrong code.

Aglyn AI credits, sold by the block: one price, the same on every paid plan, one id per interval. The subscription item's quantity is the number of blocks.

STRIPE_PRICE_AI_CREDITS STRIPE_PRICE_AI_CREDITS_YEARLY
Half-configured add-ons sell and then read back empty

The sell path only uppercases the plan name, so it will happily create a subscription against any price id you configured. The read-back path recognizes an add-on by matching the id against the same list. Configure a price for sale and forget its variable and the purchase succeeds while the entitlement it was supposed to grant is written as zero — and seat add-ons are entitlement inputs, raising host limits and register counts and flipping features on. Configure a plan's add-ons in full, or not at all.

Metered usage, if you bill API usage:

VariableNeedWhenValue
STRIPE_PRICE_METERED / STRIPE_PRICE_METERED_YEARLYFeatureRuntimePrice ids for the metered component.
STRIPE_METER_IDFeatureRuntimeThe Stripe Billing meter id, mtr_…. Checked before price ids so a re-minted price keeps working. With neither this nor a metered price configured, the usage-reporting route withholds reporting rather than reporting into the dark.
STRIPE_METER_EVENT_NAMEOptionalRuntimeThe meter's event_name. Default aglyn_metered_usage. If it does not match the name configured on the meter, Stripe accepts every event and none of them is ever priced.
STRIPE_METERED_BACKFILLOptionalRuntimeboundary (default), immediate or off — when a metered item is attached to existing subscriptions. Matched lowercased but untrimmed, so "immediate " with a trailing space silently resolves to boundary.

Billing cutover dates​

Each turns a billing behavior on from a chosen month rather than immediately, so enabling it cannot reach backwards and retro-bill. Format is YYYY-MM; a full date, true, 1 or a typo all fail closed and change nothing.

VariableNeedWhenValue
AUTO_LOCK_BILLING_FROMOptionalRuntimeFirst month the sweep may auto-suspend organizations delinquent past a 30-day grace. Unset, nothing ever auto-locks.
BILL_ASSIST_TOKENS_FROMOptionalRuntimeFirst month Assist token cost appears on an invoice. Unset, assist usage is measured but never billed.
BILL_ORG_LIBRARY_STORAGE_FROMOptionalRuntimeFirst month media-library bytes are charged. Unset, storage is metered and cap-enforced but not charged.
BILL_EMAIL_SEND_OVERAGE_FROMOptionalRuntimeFirst month email past the plan's included band is charged, as YYYY-MM. Unset, the overage is measured, shown on the billing page and priced into the cost model, but never reaches an invoice. Not retroactive: no month before the one named here is ever charged, however late it is set.
AI_OVERAGE_INVOICED_FROMOptionalRuntimeFirst month AI credits past a plan's included band are charged to the card on file as they accrue, as YYYY-MM, instead of being metered onto the renewal invoice. Unset, AI overage bills exactly as every other metered line does and nothing charges mid-period. Setting it also turns on the guards that bound what a workspace may owe — a card requirement, a monthly limit that rises with payment history, a pause on a failed charge, and a refusal once accrued-but-unpaid overage reaches its limit. The two move together on purpose: a workspace refused for unpaid overage needs an invoice it can pay, and before this month there is none. Not retroactive, and one month bills through exactly one channel.
STRIPE_PRODUCT_AI_OVERAGEOptionalRuntimeThe Stripe product the AI overage line is billed against; setup-stripe.mjs creates it and prints the id. It carries the tax code automatic tax computes the line from. Required once AI_OVERAGE_INVOICED_FROM names a month; without it nothing is charged and the reason is logged.

Email​

Where to get these: with the default provider, resend.com → API Keys, and Domains to verify the domain you send from. To send through anything else, see Which provider carries your mail.

The shared pool is yours to create, and nothing works until it exists

A published site never sends from USAGE_EMAIL_FROM. A site with a sending domain of its own sends as that; every other site sends its transactional mail — receipts, password resets, booking confirmations — from a member of a small shared pool inside your mail apex. Nothing provisions that pool for you.

Before any site sends, create AGLYN_TENANT_SHARED_POOL_SIZE domains at your mail provider — shared1.{mail apex} through shared{n}, four by default — publish the three records each one issues into your own zone, and verify all of them. Twelve records in total for the default pool, and that number does not grow with sites.

Skip this and every site without a domain of its own refuses every message, receipts included, with tenant-identity-unprovisioned. Do not create a domain object for the bare mail apex: nothing sends from it, and the pool members are one label deeper so each one signs for itself.

Which provider carries your mail​

Everything that decides whether a message may leave — suppressions, the send rate, the phishing screen, the sending identity, the unsubscribe footer — runs inside the product. The provider only carries the finished message, and you choose which one:

AGLYN_MAIL_PROVIDERWhat carries the mail
resendResend, with RESEND_API_KEY. The only provider with a delivery feed, a credential probe and a read API, so it is the only one under which open and click statistics, automatic bounce and complaint suppression, the staff delivery history, the shared-pool health check and CRM email capture work.
webhookYour own sender. Each message is POSTed as JSON to AGLYN_MAIL_WEBHOOK_URL — put a relay there into your SMTP server, Amazon SES, Postmark or anything else. Needs no rebuild: it is read at request time, not built into a bundle.
any other valueA provider a plugin or your own fork registered under that name with registerMailProvider from @aglyn/shared-util-email.

Unset, the deployment picks the first provider that has its settings, in the order above: resend when RESEND_API_KEY is set (however else you are configured), webhook when only AGLYN_MAIL_WEBHOOK_URL is, and otherwise resend, unconfigured, so every send is skipped with a log line naming RESEND_API_KEY. A value that names no built-in and no registered provider is refused, not replaced: every send is skipped with an error naming the value, and the staff email-health page shows it as a blocker. Falling back to another provider would hand your recipients and message bodies to a vendor you did not choose because a name was misspelled.

The webhook's wire format. POST $AGLYN_MAIL_WEBHOOK_URL with Content-Type: application/json and, when AGLYN_MAIL_WEBHOOK_TOKEN is set, Authorization: Bearer <token>:

{ "type": "mail.send", "version": 1, "context": "invite",
"message": { "from": "\"Bramble\" <hello@example.com>", "to": ["ada@example.org"],
"subject": "…", "text": "…", "html": "…",
"headers": { "List-Unsubscribe": "<…>" },
"tags": [{ "name": "context", "value": "invite" }],
"replyTo": "support@example.com" } }

bcc, a list of addresses, is present only when a message carries blind copies, such as a review platform's invitation address on an order email. Deliver it as blind copies: never add those addresses to the message's own headers.

Answer 2xx once the message is accepted, with { "id": "…" } if your relay has an id for it. Answer 429, with Retry-After in seconds, to ask for a slower pace: the message is kept and retried, never dropped. Any other status refuses that one message, and your response body is logged as the reason. A relay that has not answered in 10 seconds is treated as a network failure. from is always an address this install verified; every header and tag must be passed through as given, because the unsubscribe headers are how mailbox providers honor a one-click opt-out. Keep the endpoint on your own network: each request carries the token, the recipients and the whole message.

VariableNeedWhenValue
AGLYN_MAIL_PROVIDEROptionalRuntimeresend, webhook, or the name of a registered provider. See Which provider carries your mail.
AGLYN_MAIL_WEBHOOK_URLFeature, webhook onlyRuntimeWhere each outbound message is POSTed. Setting it with no RESEND_API_KEY selects the webhook provider on its own.
AGLYN_MAIL_WEBHOOK_TOKENRecommended, webhook onlyRuntimeSent as Authorization: Bearer <token> on every POST, so your relay can refuse anything else. Any random string of 32 characters or more.
RESEND_API_KEYFeature, resend onlyRuntimere_…. With the resend provider and no key, every outbound send — invites, receipts, password resets, campaigns, security alerts — is an inert no-op. Nothing errors; mail simply does not arrive.
USAGE_EMAIL_FROMFeatureRuntimeThe verified sender identity for your install's own mail — invites, billing, security alerts, console password resets — and the address the "is email configured" check every sender consults reads. A bare address or Bramble <billing@example.com>, on a domain you verified in Resend. A published site never sends from it, under any configuration: a tenant's list quality must not be charged against the domain your own account mail depends on, so tenant mail resolves its own identity and refuses rather than borrowing this one. Unset, every platform sender no-ops or answers 501 with an actionable message; nothing throws.
EMAIL_PROVIDER_REQUESTS_PER_SECONDOptionalRuntimeHow many API requests a second your mail provider accepts, as a whole number. Default 10, which is Resend's published per-team limit — counted across every key on the account, not per key and not per domain. A campaign sends one request per recipient, so a batch of five hundred is the only thing this deployment does that can approach it; the batch paces itself to one request less than this number, leaving the remainder for transactional mail that lands in the same second. Raise it if Resend has raised your account's limit; a value that is blank, negative or unparsable falls back to the default rather than removing the pace, and 1 paces at one request a second. 0 turns pacing off entirely — set it only when something in front of this process already limits the rate, since without it a large batch earns 429s. Refused requests are never lost either way: a 429 defers the rest of the batch to the next run rather than dropping those recipients.
EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLDOptionalRuntime, console onlyThe bulk-sender line for campaigns whose mail-client unsubscribe button (the List-Unsubscribe / List-Unsubscribe-Post header pair) has been turned off. A whole number; default 5,000, the figure Gmail and Yahoo publish for their bulk-sender rules, which require one-click unsubscribe. A campaign email whose organization would reach this many campaign messages in 24 hours — counted as what it sent today and yesterday (UTC), plus everyone the email is about to address — carries the header anyway, and the campaign page says it was turned back on and why. Blank, unparsable, zero or negative falls back to the default: there is no value that switches the guard off. It works the same whatever provider sends your mail, because the header is two ordinary headers on the message. Campaigns that leave the setting on, and all transactional mail, are unaffected.
RESEND_WEBHOOK_SECRETOptionalRuntimeSvix signing secret (whsec_…) verifying Resend's delivery, open, click, bounce and complaint webhooks. Unset, that endpoint answers 501 and nothing is recorded: no open/click statistics, no bounce suppressions, no per-recipient delivery history on the staff user page, and /api/health/auth-doors reports its verification-delivery arm as having no opinion — with nothing recording deliveries, an empty log says nothing about whether mail went out.
RESEND_READ_API_KEYOptionalRuntimeA full-access Resend key, used only by the staff Import delivery history action to read already-sent mail into the per-recipient delivery log. Deliberately separate from RESEND_API_KEY, which is sending-scoped and answers every read with 401 restricted_api_key — a leaked sending key must not be able to enumerate everyone you have ever emailed. Unset, the import answers 501 and says so; the live webhook feed is unaffected.
CRM_INBOUND_DOMAINin.aglyn.comRuntime, console onlyThe domain the CRM's email capture address is minted under: crm+<token>@<domain>. Set it to a receiving domain YOU own at Resend (with its MX record published) — the default is Aglyn's, and mail to it never reaches a self-hosted install. A value that is not a bare hostname falls back to the default.
CRM_INBOUND_WEBHOOK_SECRETOptionalRuntime, console onlySvix signing secret of the Resend webhook endpoint that subscribes email.received to /api/crm/inbound. Unset, the route also accepts RESEND_WEBHOOK_SECRET, for an install that subscribed the event on its delivery-events endpoint; with neither set the route answers 501 and nothing is captured. Reading the message itself needs RESEND_READ_API_KEY.
SITE_CARE_INBOUND_WEBHOOK_SECRETOptionalRuntime, console onlySvix signing secret of the Resend webhook endpoint that subscribes email.received to /api/site-care/inbound, which turns a message to the support address (NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL, or support@<CRM_INBOUND_DOMAIN>) from a member of a workspace with Site Care into a Site Care request, and a message to support+<request id>@<CRM_INBOUND_DOMAIN> (the Reply-To of every Site Care email) into a reply on one. Have the support mailbox also deliver each message it receives to support@<CRM_INBOUND_DOMAIN>; every other message is acknowledged and left alone, with nothing filed and nothing sent back. Unset, the route also accepts RESEND_WEBHOOK_SECRET; with neither set it answers 501 and no request arrives by email. Reading the message itself needs RESEND_READ_API_KEY.
PLATFORM_MARKETING_HOST_IDOptionalRuntimeThe Firestore document id of the host — one of your own sites — whose contacts receive a person's decision about your product-update email. The console asks in three places: an optional, unticked checkbox on the sign-up form, the Product updates switch under Manage Account → Email addresses, and a one-time prompt for accounts that were never asked. Every answer is written onto the person's own users/{uid} document; when this names a host, the same person is also captured into that host's org contacts (source account) with the decision and its provenance — which door, which account, which wording version — so your own campaigns see a declared basis under the strict consent policy. Unset, the preference is recorded on the account document only and no contact is captured, which is the right state for an install with no product audience of its own. A value naming a host that does not exist still records the preference; the refused contact is logged and nothing throws. Staff see both halves on a person's page (Staff → Users → the Identity card): the answer on their account, and what a campaign from this host would actually do — the contact's basis under your consent policy, then the host's own unsubscribe list, the platform suppression list and its Product updates list — with a link to the contact and a warning when the two disagree. Unset, the card says no marketing site is configured. The account's answer stands for this host's built-in Product updates topic (product-updates), so send your product announcements on that topic: an unsubscribe from it — or from everything the host sends — turns the account's answer to No, while leaving any other topic does not touch it, and the switch turned back on from a verified address resumes that one topic and keeps every other one the person left. Set it on both apps, with the same value: the console records the answers, and the site runtime serves this host's unsubscribe and preference pages, which is where an unsubscribe reaches the account — a site runtime without it leaves every account answer untouched. The console keeps asking while this is unset, so the answers are on file if you set it later; each reaches the host's contacts the next time that person answers. The same host is where your system emails get their email blocks: the staff system-email editor offers that site's email blocks, such as a header and footer, and a system email sent under your own brand carries the ones it places, while one sent to the people of a white-label organization never does. Unset, the editor offers no blocks and a placed block sends as nothing.
RESEND_DOMAINS_API_KEYOptionalRuntime, console onlyA full-access Resend key, used only to create a customer's sending domain (POST /domains) and read back the DKIM record it issues. A third key on purpose: RESEND_API_KEY is sending-scoped and cannot create anything, and RESEND_READ_API_KEY is for reading message history. Set it on the console project only — a key that can create a domain can also list every domain in the account and mint further keys, and the tenant runtime serves published sites to the public. Unset, a requested domain stops at requested, has no records to publish, and the console says pendingProvider rather than showing an empty DKIM row. Transactional mail keeps moving either way: a site whose platform subdomain never got a key falls through to the shared pool, and only a domain the CUSTOMER owns refuses while unverified — because that one is an instruction the merchant gave. Marketing is refused in both cases. The pool itself needs no key, because you create its members by hand.
AGLYN_SENDING_DOMAIN_PROVIDEROptionalRuntime, console onlyresend or none. Unset, the deployment detects: resend when RESEND_DOMAINS_API_KEY is present, none otherwise. An unrecognized value logs [sending-domain-provider] unknown AGLYN_SENDING_DOMAIN_PROVIDER and falls back to detection. An explicit value always wins, none included. Naming resend without the key still issues nothing — the driver reports "not configured" rather than failing.
AGLYN_TENANT_MAIL_APEXOptionalRuntimeThe apex a site's own sending domain hangs off, bare: a site with the pinned label northwind sends from northwind.{this value}. Default mail.{NEXT_PUBLIC_TENANT_DOMAIN}, so an install that has set its tenant apex already has a mail namespace inside its own zone and needs nothing here. Set it only to put mail in a different zone — a separate registrable domain is the strongest form of the reputation split, since one site's list quality then cannot reach the domain your own account mail leaves on. A value equal to the web apex is ignored and the default used instead: a mail namespace that is also the web namespace lets a renamed site's freed web slug become a name another site's mail is signed for. Unset on an install that also left NEXT_PUBLIC_TENANT_DOMAIN unset, every sending name this install builds — the shared pool included — lands inside Aglyn's mail.aglyn.app, which you cannot publish records into, so nothing verifies and no tenant mail leaves at all.
AGLYN_TENANT_SHARED_POOL_SIZEOptionalRuntimeHow many domains the shared sending pool holds — shared1.{mail apex}, shared2.… and so on — for sites that have no domain of their own. A whole number 1–64; default 4. A blank, unparsable or sub-1 value falls back to the default, and anything above 64 is clamped so a typo cannot ask for thousands. The pool is fixed-size and does not grow with sites, so its whole cost is this many provider domain objects and three DNS records each, whether you host twelve sites or a hundred thousand. Four rather than one because every site on a member shares its reputation: one member in trouble takes a quarter of your transactional mail with it, not all of it. ⚠ Raising it requires creating the matching domain objects and their records at your provider first — the code derives the pool from this number, and a member that does not exist is an address nothing signs for. Each member also spends a provider domain slot counted against AGLYN_SENDING_DOMAIN_CAPACITY, and is a reputation that has to be earned by sending steadily. Changing the size does not reshuffle existing sites: assignment is rendezvous-hashed, so growing the pool moves only about 1/size of them onto the new member and nothing else observes the change, while shrinking it moves the removed member's sites and throws away the reputation that name had earned. Every pool label is reserved against tenants at any size, so a site can never be handed a name the pool is about to want. There is no configuration that produces no pool — a site with no domain of its own would then have no sending identity at all.
AGLYN_TENANT_SHARED_LOCAL_PARTOptionalRuntimeThe mailbox the shared pool sends as — the part before the @. Default notifications, giving notifications@shared2.{mail apex}. Normalized like any local part, and a value that normalizes to nothing falls back to the default rather than being placed in a header. Deliberately not hello, which is what a site's own domain defaults to: the two are different promises, and an address inviting replies to a mailbox belonging to no particular site invites mail nobody reads. Only the local part is configurable — the address always sits directly on a pool member, never beneath one, because under the published strict DKIM alignment the From: domain must be exactly the domain whose key signs the message and each member signs only for itself. A configurable domain here would produce mail that authenticates and still fails DMARC.
AGLYN_SENDING_DOMAIN_CAPACITYOptionalRuntime, console onlyHow many sending domains this deployment may hold at the mail provider. A whole number; default 10, and a blank or unparsable value falls back to it. Resend caps domains per account by plan (Free 3, Pro 10, Scale 1000) and every site's subdomain is its own domain object, so there is no wildcard that escapes the cap. It binds the dedicated per-site domains — a site on the shared pool is provisioned nothing and is never counted here, though the pool's own members each occupy a slot of their own. The default is the lowest paid tier's allowance, so a deployment that has not been told its plan refuses before the vendor does: over the ceiling a site's provisioning ends at-capacity with that reason stored on the record and logged as [provision-sending-domain] at the sending-domain ceiling, rather than failing at the provider with a message about somebody else's billing. The allowance is a bundled quota, not a per-domain price — Resend meters emails and contacts, never domains — so the cheap way past the ceiling is the flat domain add-on ($20/mo for 100 more domains, on Pro or Scale), and a tier upgrade buys the same domains for considerably more. Choose the tier by send volume, then raise this to the allowance that tier plus its add-ons carries. -1 switches the check off for a provider with no such limit. A domain that already holds a DKIM key is never re-counted, so a raised ceiling does not strand the sites already mid-provision. Reaching the ceiling is not an outage: a site refused here keeps sending its transactional mail on the shared pool, so what is lost is reputation isolation rather than receipts, and the fix is a purchase rather than an incident. Nothing draws on this automatically — a dedicated domain is claimed when a merchant asks for one from the sending card, never at signup and never on a plan change — so the count moves at the pace people request them. Watch the headroom rather than the limit: GET /api/admin/provision-sending-domains reports held, capacity, remaining, used (a 0–1 share, null when uncapped) and atCapacity without writing anything, and the sweep logs a warning from 80% spent so there is time to buy more allowance before a request is refused. Moving merchants onto domains they own is the other lever — a provider slot is spent either way, but a customer's own domain costs no records in your zone and no place in the re-verification sweep.
AGLYN_EMAIL_SPF_INCLUDEOptionalRuntimeThe SPF mechanism printed in the DNS instructions a customer follows to send from their own domain. Default amazonses.com, which is what Resend sends through. Change it only if you front a different provider. A blank value falls back to the default rather than being honored: an empty SPF include would print an instruction that authorizes nobody, and the customer would publish a record that silently fails their mail. Not taken from the provider's response even when it offers one: the SPF and return-path records come from these settings, and only the DKIM record comes from the provider.
AGLYN_EMAIL_RETURN_PATH_HOSTOptionalRuntimeThe host a customer's return-path (bounce) CNAME is pointed at, printed in the same instructions. Default feedback-smtp.us-east-1.amazonses.com — note the embedded region, which must match the region your provider actually sends from, or bounce processing goes to a host that is not listening. Blank falls back to the default, for the same reason as the SPF include.
AGLYN_EMAIL_TRACKING_CAOptionalRuntimeThe certificate authority named in the CAA record a customer publishes so the click-tracking host can be issued a TLS certificate. Default amazon.com, which is what Resend's link CDN uses. Change it only if you front a different provider. ⚠ The record is only ever shown to a domain that already publishes CAA which would refuse that authority — a domain publishing none needs nothing, because any authority may already issue, and handing it a CAA record to paste would be the change that starts restricting it and breaks whatever else renews on that name. A domain that does publish CAA must add this one alongside what it has, never in place of it. The check walks up from the tracking host exactly as a certificate authority does and stops at the first name publishing any record; a lookup nobody answers drops the record too, because "we could not tell" has to resolve toward the instruction that cannot hurt.
AGLYN_SENDING_TRACKING_RETENTION_DAYSOptionalRuntime, console onlyHow long a sending domain that carries a click-tracking host is held before its provider domain object is released. A whole number of days; default 30, and 0 switches the hold off. Every link in every message that domain has already sent points at its tracking host, and the provider deletes the host along with the domain — a tracking subdomain cannot even be removed on its own, precisely because live mail points at it. So releasing on the day a site is torn down does not merely stop future tracking, it retroactively breaks links for recipients who did nothing. The deadline is stamped on the record the first time the reaper sees the domain, so it terminates rather than restarting each pass, and the daily reap finishes the job once the window is up. An erasure never waits — a person asking to be erased outranks a link in somebody's inbox — and an untracked domain has no links to preserve and is released the same day, so the hold never spends a provider slot (see AGLYN_SENDING_DOMAIN_CAPACITY) on nothing.
AGLYN_EMAIL_MARKETING_CAP_PER_DAYOptionalRuntimeHow many marketing messages one person may receive from one site in a rolling 24 hours — a campaign, a member post, an abandoned-cart reminder, a back-in-stock alert and a workflow email all count toward it. Default 5, which is above the worst legitimate day and exists to stop a runaway (a workflow firing on every form submission, a member post published repeatedly). Whole number, 1–1000; a blank, unparsable or out-of-range value falls back to the default rather than switching the ceiling off, because a control a typo can disable is not a control. Over the ceiling, the send is skipped and nobody is removed — no contact is deleted, no audience trimmed, no unsubscribe recorded — and the cron sweeps retry on the next beat. Transactional mail is never counted or refused.
AGLYN_EMAIL_SUNSET_AFTER_DAYSOptionalRuntimeEngagement-based sunsetting, off unless you set it. A whole number of days, 30–3650. When set, an automated marketing message is skipped if this site has been mailing the address for longer than the window and the address has neither opened nor clicked anything inside it. A blank, unparsable or out-of-range value reads as off — the opposite of the cap above, because a typo there weakens a guard that is already on and a typo here would switch on a refusal nobody asked for. Nobody is removed: no unsubscribe, no suppression, no contact or list change, and the very next message after the person opens or clicks anything goes. Campaigns are exempt, like the frequency cap, because a campaign shows its recipient count before it is sent. Transactional mail is never affected.
EMAIL_UNSUBSCRIBE_SECRETFeatureRuntimeSee Secrets.
STAFF_ALERT_EMAILRecommendedRuntimeOne internal inbox for every operator alert: fraud and risk (card testing, a flagged, disputed or early-fraud-warned payment, a phishing email held on your sending domain, a seller under review), a dispute lost or nobody owns, a DMCA counter-notice or takedown, a failed data erasure or database export, a billing webhook that failed its signature or half applied, a health check that went red, and the rest of the list on Staff → Operator alerts, where each type can be switched off or moved into a daily digest. Each alert also appears in the staff console. One address, not a comma list. Unset, alerts go to NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL, and when that is unset too, to every staff account's own address, so an install nobody configured still hears. Preview and development deployments (a VERCEL_ENV other than production) use only this variable and never fall back.
OPERATOR_ALERT_WEBHOOK_URLRecommendedRuntimeAn out-of-band channel for the same operator alerts: every alert delivered immediately is also POSTed here as JSON, and the daily digest as one post. The body carries a Slack-compatible text field plus the alert's type, tier, category, title, body and url, so a Slack or Mattermost incoming-webhook URL works as is, and any HTTP endpoint can route on the fields. http or https; redirects are not followed; a post that takes more than 5 seconds is abandoned. It exists for the failures email cannot report: your mail provider down, or rejecting its key. Unset, alerts are email and console only.
OPERATOR_ALERT_WEBHOOK_SECRETOptionalRuntimeSigns each webhook post. With it set, the request carries x-operator-alert-timestamp (Unix seconds) and x-operator-alert-signature: sha256=<hex>, the HMAC-SHA256 of {timestamp}.{raw body} under this secret; reject a post whose signature does not match or whose timestamp is old. Any random string of 32 characters or more. Unset, posts are unsigned.
OPERATOR_HEALTH_TENANT_ORIGINOptionalRuntime, console onlyThe origin of one published site, for example https://demo.sites.example.com: the operator alerts tick asks the published-site runtime's health endpoints there (serving, the error beacon, the capture funnel, site and marketing rendering), so a site runtime that stops rendering alerts the operator. The console reaches its own endpoints without it. Unset, the tick checks the console only, and the site runtime's health is seen only by an external monitor, if you have one.
RENDER_MONITOR_ORIGINSOptionalRuntime, console onlyExtra published sites for the render monitor to watch, as origins separated by commas: https://www.example.com,https://shop.example.com. It always watches one site besides these: OPERATOR_HEALTH_TENANT_ORIGIN when set, otherwise your demonstration site, https://demo.<NEXT_PUBLIC_TENANT_DOMAIN> (the label is AGLYN_TENANT_DEMO when set). off watches nothing, for an install with no published site. Values that are not an origin are ignored.
RENDER_MONITOR_PAGESOptionalRuntime, console onlyReal published pages for the render monitor to draw fresh, one per site per run, taking turns: host or host/path, separated by commas or spaces, e.g. shop.example.com/cart www.example.com/. Each site named is also watched like RENDER_MONITOR_ORIGINS. Needs RENDER_MONITOR_RENDER_ORIGIN too; off draws none.
RENDER_MONITOR_RENDER_ORIGINOptionalRuntime, console onlyThe address the render monitor asks for those pages through: an origin of your published-site runtime that picks the site from ?tenantHost= (on Vercel, the tenant project's production *.vercel.app domain), reached with AGLYN_VERCEL_BYPASS — or the VERCEL_AUTOMATION_BYPASS_SECRET Vercel exposes to the console when the two projects share one bypass value. Unset, no real page is drawn.
RENDER_MONITOR_FAILURE_THRESHOLDOptionalRuntime, console onlyHow many render monitor runs in a row must fail before the alert goes out. Default 2, which at the five-minute schedule alerts within ten minutes.

Alarm thresholds. All are integers, all fail to their default on a blank or unparsable value, and all are inert unless the mail provider has its settings (RESEND_API_KEY, or AGLYN_MAIL_WEBHOOK_URL) and USAGE_EMAIL_FROM is set and there is a staff alert inbox (STAFF_ALERT_EMAIL, or its fallbacks). Each accepts -1 as a deliberate forced-failure lever for proving the alert path works.

VariableDefaultDrives
RATE_LIMIT_ALARM_MAX_CALLS0Rate-limiter fallback calls tolerated in the window before /api/health/rate-limits reports degraded.
SERVER_ERROR_ALARM_MAX_ERRORS5Uncaught server errors tolerated in a 30-minute window.
SIGNUP_ALARM_MAX_PER_HOUR10Organization creations per hour before the signup-wave alarm. Sized for single-digit-org production — a real launch will trip it.
SIGNUP_REFUSAL_ALARM_MAX_PER_HOUR50Refused (429'd) organization creations per hour. A refusal marked unreadable is graded separately at zero tolerance and this number does not mute it.
SIGNUP_DROUGHT_MIN_TRAFFIC3Attempts to create an organization in the trailing hour below which zero accounts created is treated as a quiet hour rather than an outage. It counted signup-page SERVES until 2026-09-09, which counted lookers rather than people who tried and reported an ordinary hour as an outage. This one's forced-failure lever is 0, not -1: at zero, any hour with no accounts created reports a drought.
SIGNUP_CANARY_ENABLEDunsetSet to exactly 1 to have /api/health/journeys report signupCanary — the verdict of a scheduled job that walks a real signup end to end on this deployment and deletes what it made. Left unset the check is absent from the body entirely, not reported green: with nothing walking the signup, the endpoint makes no claim about it. Once on, a missing or stale verdict is RED, because "nothing has demonstrated a stranger can sign up" is the one thing this must never report as calm.
APP_CHECK_ATTESTATION_ENABLEDunsetSet to exactly 1 to have /api/health/journeys report appCheckAttestation — the share of App Check verifications that were ALLOWED for real visitors, sampled hourly from Cloud Monitoring by tools/e2e/appcheck-attestation.mjs. Reds when attestation collapses, which is the recaptcha-allowlist failure where an origin that is attached and routed but not allowlisted renders a console nobody can sign in to. Left unset the check is absent from the body rather than green, because green would claim a measurement nobody took. Deliberately independent of SIGNUP_CANARY_ENABLED: this is what covers the canary's debug-token blindness, so it must not go dark with it.
EDGE_ADMISSION_ENABLEDunsetSet to exactly 1 to have /api/health/journeys report edgeAdmission — how long since the metered page-view total last GREW, sampled by tools/e2e/edge-admission.mjs. This is the only check that can see the edge refusing real visitors: every other one carries the x-aglyn-probe bypass and would ride past a firewall that had started challenging everybody. It grades an OUTCOME rather than a probe, because a probe cannot answer the question — a non-JS client is challenged from a home connection too, and that is the healthy state. Left unset the check is absent from the body rather than green.
VERIFICATION_DELIVERY_MIN_ACCOUNTS3Accounts created in the trailing day — password signups only, ignoring the last 15 minutes so the delivery feed has time — below which a missing verification delivery event is treated as too little data rather than an outage. Its forced-failure lever is 0, like the drought check's: at zero any window is graded, so a quiet one reports red. The arm is skipped entirely when RESEND_WEBHOOK_SECRET is unset, because nothing records deliveries then.

Sequences: a rep's own Google or Microsoft 365 mailbox​

Sequences sends one-to-one email from each rep's own Google mailbox through the Gmail API — never through RESEND_API_KEY — so a rep connects their Google account in Sequences → Mailboxes. That needs an OAuth client of your own in Google Cloud:

  1. Enable the Gmail API in the project.
  2. Configure the OAuth consent screen. An Internal screen, on a project owned by your Google Workspace organization, lets that organization's own users grant the restricted gmail.readonly scope without Google's app verification or its annual security assessment. Anyone outside the organization needs an External screen, which Google has to verify first.
  3. Create an OAuth client of type Web application and add one authorized redirect URI: {NEXT_PUBLIC_CONSOLE_URL}/api/outreach/mailboxes/oauth/callback, for example https://console.example.com/api/outreach/mailboxes/oauth/callback. Google matches it exactly.

The grant asks for openid, email, https://www.googleapis.com/auth/gmail.send and https://www.googleapis.com/auth/gmail.readonly. The OAuth state is signed with TOKEN_SIGNING_SECRET (see Secrets), which must be set too. Set all three variables below on the console only: the tenant runtime never loads Sequences, and together the client secret and the token key are the power to send mail as every rep who connected a mailbox.

VariableNeedWhenValue
GOOGLE_OUTREACH_CLIENT_IDFeatureRuntime, console onlyThe OAuth client id, …apps.googleusercontent.com. Unset — or with either variable below unset — Sequences → Mailboxes says connecting a Google mailbox is not configured on this deployment, and every mailbox route answers 503 with reason not-configured. Mailboxes already connected stop being able to send, because no token can be refreshed.
GOOGLE_OUTREACH_CLIENT_SECRETFeatureRuntime, console onlyThat client's secret. Used to redeem the authorization code at connect, to refresh each mailbox's access token before a Gmail call, and to revoke a grant when a mailbox is disconnected or its organization is erased. A secret Google refuses surfaces as client_misconfigured on every send rather than as a disconnected mailbox.
OUTREACH_TOKEN_KEYFeatureRuntime, console only32 random bytes, base64 — openssl rand -base64 32. Seals every stored refresh token with AES-256-GCM; nothing else is encrypted with it. A value that is not exactly 32 bytes counts as unset. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a token opened under an old key is sealed again under the new one the next time its mailbox is used. Each credential records the id of the key that sealed it in tokenKeyId, so drop the old key once no credential names its id. Losing the key loses every connected mailbox: a token that no listed key opens cannot be recovered, and its mailbox moves to Reconnect required until the rep connects it again.

A rep's own Microsoft 365 mailbox​

A rep can connect a Microsoft 365 (Exchange Online) mailbox instead, through Microsoft Graph. That needs an app registration of your own in Microsoft Entra (the Azure portal → App registrations → New registration):

  1. Supported account types: accounts in any organizational directory, or your own tenant only. Set MICROSOFT_OUTREACH_TENANT to match.
  2. Redirect URI, platform Web: the same callback Google uses, {NEXT_PUBLIC_CONSOLE_URL}/api/outreach/mailboxes/oauth/callback. Both providers come back to the one route, and the signed state says which one a connect went to.
  3. API permissions → Microsoft Graph → Delegated permissions: Mail.Send, Mail.ReadWrite, User.Read, offline_access, openid, email and profile. A tenant administrator can Grant admin consent for the tenant; otherwise each rep consents on their first connect.
  4. Certificates & secrets → New client secret. Microsoft's secrets expire, so note the date and rotate it before then.

Mail.Send sends as the rep. Mail.ReadWrite reads the replies and bounces a sequence stops on, and creates the draft each step is sent from, which is what gives a send the conversation id the next step threads into. The grant is sealed with OUTREACH_TOKEN_KEY, like a Google one. Microsoft has no way for an app to revoke one refresh token, so disconnecting deletes the stored grant; the rep removes the app at myapps.microsoft.com to end it at Microsoft too.

VariableNeedWhenValue
MICROSOFT_OUTREACH_CLIENT_IDFeatureRuntime, console onlyThe app registration's Application (client) ID. Unset — or with the secret or OUTREACH_TOKEN_KEY unset — Connect with Microsoft is disabled, Mailboxes says connecting a Microsoft 365 mailbox is not configured, and a Microsoft connect answers 503 with reason not-configured. Microsoft mailboxes already connected stop sending, because no token can be refreshed. Google mailboxes are unaffected.
MICROSOFT_OUTREACH_CLIENT_SECRETFeatureRuntime, console onlyThe client secret's Value (not its id). A secret Microsoft refuses — expired, or mistyped — surfaces as client_misconfigured on every send.
MICROSOFT_OUTREACH_TENANTOptionalRuntime, console onlyWho may sign in: common (the default), organizations, or one tenant's id or domain. Anything else counts as unset.

With Count link clicks on, every link in a sequence email is a short link, {NEXT_PUBLIC_CONSOLE_URL}/api/outreach/l/<id>. An organization can move those links onto its own domain in Sequences → Mailboxes → Link domains: mail sent as rep@example.com then carries https://links.example.com/<id>, the same links. host a sending domain's campaign click tracking uses. Nothing needs configuring for the default to work, and an install that never sets one up keeps the console address.

How the host reaches your console is the domain driver's job, scope console, like a workspace subdomain:

  • vercel attaches links.<domain> to VERCEL_CONSOLE_PROJECT_ID when a member selects Set up, and Vercel issues its certificate.
  • webhook sends your endpoint an attach for it, scope console.
  • wildcard and none register nothing: point links.<domain> at the console yourself — a DNS record, and a proxy route that terminates TLS with a certificate for the name and passes the Host header through. A wildcard certificate on your own workspace domain does not cover a customer's domain.

The console answers the host by its Host header, on any deployment: the middleware rewrites /<id> to the short-link route, answers /_aglyn/link-host with {"service":"aglyn-link-host","host":…}, and 404s every other path. Check fetches that probe over HTTPS from the console itself, so the host verifies only once DNS, the certificate and the routing all work — and links move onto it only then.

Bot protection. Mail clients, link previews and corporate link scanners open these links without running JavaScript. A proxy or WAF that challenges non-browser clients must let links.* hosts through on /<id> and /_aglyn/link-host (and /api/outreach/l/* on the console), or scanners are answered with a challenge instead of the redirect and the probe cannot verify. Aglyn's own Vercel firewall carries this as the Click-tracking link bypass rule (tools/scripts/lib/firewall-posture.mjs).

VariableNeedWhenValue
AGLYN_LINK_HOST_TARGETOptionalRuntime, consoleThe CNAME target the Link domains card tells a member to point links.<domain> at — your proxy's hostname. Unset, the vercel driver's cname.vercel-dns.com, and no target at all under any other driver, where the card says to point the name at the console instead.
AGLYN_LINK_HOST_CAOptionalRuntime, consoleThe certificate authority named in the card's optional CAA row, which only matters to a domain that already publishes CAA records. Default letsencrypt.org.

A links. host under your own NEXT_PUBLIC_WORKSPACE_DOMAIN is never answered as a link domain — the workspace router owns every name there — and a domain whose campaign email already has a provider tracking host on links. cannot be set up, because both would need the same name.

Text messages​

The SMS plugin texts order receipts and order updates through a Twilio Messaging Service. Leave all three variables unset and nothing offers a text: order notifications go by email only. Set them on the console and the tenant runtime, since both send order notifications.

VariableNeedWhenValue
TWILIO_ACCOUNT_SIDFeatureRuntimeAC…, the Twilio account.
TWILIO_AUTH_TOKENFeatureRuntimeSigns every API call, and verifies the X-Twilio-Signature on inbound texts. An unsigned inbound text is refused.
TWILIO_MESSAGING_SERVICE_SIDFeatureRuntimeMG…, the Messaging Service every text is sent through. Register its sender for A2P 10DLC before texting US numbers. Texts held for quiet hours are scheduled on this service.

Point the Messaging Service's incoming-message webhook at POST https://<console>/api/sms/inbound. A reply of STOP is then recorded on the platform's own suppression list, which is checked before every send, and START lifts it.

Shipping labels​

The Shipping plugin buys carrier labels, quotes live rates at checkout and follows parcels through Shippo or EasyPost, one managed account (Shippo) or child user (EasyPost) per workspace under your platform credential. Without a provider credential and the sealing key, the plugin is not configured: every card, rate kind and route answers as though it did not exist, and checkout uses the seller's own rates. Set the provider variables on the console and the tenant runtime, because the storefront's cart and buy-now ask for rates in the tenant.

VariableNeedWhenValue
SHIPPO_API_TOKENFeatureRuntimeThe platform's Shippo token. Set, Shippo is the provider.
EASYPOST_API_KEYFeatureRuntimeThe platform's EasyPost key. The provider when SHIPPO_API_TOKEN is unset, or when SHIPPING_PROVIDER says easypost.
SHIPPING_PROVIDEROptionalRuntimeeasypost to prefer EasyPost while both credentials are set. Anything else, or unset, prefers Shippo.
SHIPPING_TOKEN_KEYFeatureRuntime32 random bytes, base64. Seals every stored account id and child key. A comma-separated list rotates: the first key seals, the rest only open.
SHIPPO_WEBHOOK_TOKENFeatureRuntime, consoleThe token Shippo's tracking webhook URL carries as ?token=.
SHIPPO_WEBHOOK_HMAC_SECRETOptionalRuntime, consoleShippo's HMAC secret. When set, the Shippo-Auth-Signature header is verified as well.
EASYPOST_WEBHOOK_SECRETFeatureRuntime, consoleThe webhook_secret EasyPost signs each event with (X-Hmac-Signature).

Point the provider's webhook at POST https://<console>/api/shipping/webhooks/shippo?token=… or POST https://<console>/api/shipping/webhooks/easypost.

Accounting: QuickBooks Online and Xero​

The Accounting plugin posts sales, refunds, fees and payouts to a workspace's QuickBooks Online company or Xero organization. A provider is offered only when its client id, its client secret and the token key are all set; with neither provider configured the Accounting page says nothing can be connected. Set these on the console only. Together they can write into every connected business's books, so the tenant runtime, which serves the public internet, must never have them.

VariableNeedWhenValue
INTUIT_CLIENT_IDFeatureRuntime, consoleThe Intuit developer app's client id.
INTUIT_CLIENT_SECRETFeatureRuntime, consoleThe Intuit developer app's client secret.
INTUIT_ENVIRONMENTOptionalRuntime, consolesandbox (the default) or production: which QuickBooks API host the keys open. A development app's keys only open sandbox companies. Any other value leaves QuickBooks unconfigured.
XERO_CLIENT_IDFeatureRuntime, consoleThe Xero app's client id.
XERO_CLIENT_SECRETFeatureRuntime, consoleThe Xero app's client secret.
XERO_SCOPESOptionalRuntime, consoleSpace-separated scopes that replace the default request, for a Xero app made before Xero's granular scopes.
CODAT_API_KEYFeatureRuntime, consoleThe Codat client's API key. Set, the Accounting page also offers other accounting software (QuickBooks Desktop, NetSuite, Sage, FreshBooks, Zoho Books, Wave and the rest Codat reaches), each workspace as one Codat company.
ACCOUNTING_TOKEN_KEYFeatureRuntime, console32 random bytes, base64. Seals every stored token. A comma-separated list rotates: the first key seals, the rest only open, and a token opened under an older key is sealed again under the first.

Register https://<console>/api/accounting/oauth/callback as the redirect URI in both developer apps. The console builds it from NEXT_PUBLIC_CONSOLE_URL.

For Codat, set the redirect in the Codat Portal under Settings > Auth flow > Redirects to https://<console>/api/accounting/oauth/callback?code={companyId}&state={state}&statusCode={statusCode}, and turn on the accounting integrations you want to offer under Settings > Integrations > Accounting. Codat is a subprocessor: publish its row before setting the key in production.

Tax services​

A merchant can connect their own Avalara AvaTax or TaxJar account for the sales tax at checkout and the register. The deployment holds no vendor account: each merchant brings their own credentials, and the deployment holds only the key those credentials are sealed under. Leave it unset and no Tax service card appears, checkout and the register tax at each store's own rates, and nothing is sent to either vendor. Set it on the console and the tenant runtime, since checkout asks the service from the tenant and the console records paid orders and refunds.

VariableNeedWhenValue
TAX_ENGINES_TOKEN_KEYFeatureRuntime32 random bytes, base64 — openssl rand -base64 32. Seals every stored AvaTax license key and TaxJar API token with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a credential opened under an old key is sealed again under the new one the next time its store is taxed. Losing the key loses every connection: each merchant connects their account again, and until they do their store taxes at its own rates.

Review platforms: Trustpilot and Yotpo​

A merchant can invite customers to review their store on Trustpilot or Yotpo Reviews with their own account. Trustpilot's invitation address needs nothing of the deployment: it is a blind copy on the buyer's order email. The Trustpilot API and Yotpo use the merchant's own keys, and the deployment holds only the key those are sealed under. Leave it unset and the Trustpilot card offers the invitation address alone, no Yotpo card appears, and no key is sent to either service. Set it on the console and the tenant runtime, since orders are fulfilled and their events handled in both.

VariableNeedWhenValue
REVIEW_PLATFORMS_TOKEN_KEYFeatureRuntime32 random bytes, base64 — openssl rand -base64 32. Seals every stored Trustpilot API key and secret and Yotpo secret key with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens. Losing the key loses every API connection: each merchant pastes their keys again.

Ad conversions: Meta, TikTok and Pinterest​

A site can send its purchases and leads to the merchant's own Meta, TikTok and Pinterest ad accounts from the server, beside the browser tags set on its Tracking tab. Each merchant connects with their own access token, so the deployment needs no vendor account: only the key those tokens are sealed under. Leave it unset and no Ad conversions card appears and no server-side event is sent; the browser tags work either way. Set it on the console only: delivery runs there, and the tenant runtime never opens a token.

VariableNeedWhenValue
AD_CONVERSIONS_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored access token with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a token opened under an old key is sealed again under the new one the next time it is used. Losing the key loses every connection: each merchant connects again.

Email platforms: Mailchimp, Klaviyo, Omnisend and Attentive​

A site can keep its contacts and their unsubscribes in step with the merchant's own Mailchimp, Klaviyo or Omnisend account, both ways, and send its orders to Klaviyo and Omnisend for their flows. Each merchant connects with their own API key, so the deployment needs no vendor account: only the key those keys are sealed under. Leave it unset and no Email platforms card appears and nothing is sent to any of them. The app registrations are optional: with them, a merchant can connect by signing in to the platform instead of pasting a key, and with Attentive's partner app, Attentive is offered at all. Set these on the console only: the sync runs there, and the tenant runtime never opens a merchant's credential.

VariableNeedWhenValue
MARKETING_PLATFORMS_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored API key and OAuth token with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a credential opened under an old key is sealed again under the new one on its next sync. Losing the key loses every connection: each merchant connects again.
MAILCHIMP_CLIENT_IDOptionalRuntime, consoleThe Mailchimp app's client id, for Connect with Mailchimp. Without it merchants connect with an API key.
MAILCHIMP_CLIENT_SECRETOptionalRuntime, consoleThe Mailchimp app's client secret.
KLAVIYO_CLIENT_IDOptionalRuntime, consoleThe Klaviyo app's client id, for Connect with Klaviyo. Without it merchants connect with a private API key.
KLAVIYO_CLIENT_SECRETOptionalRuntime, consoleThe Klaviyo app's client secret.
ATTENTIVE_CLIENT_IDOptionalRuntime, consoleThe Attentive partner app's client id. Attentive is offered only with it: its API takes no merchant key.
ATTENTIVE_CLIENT_SECRETOptionalRuntime, consoleThe Attentive partner app's client secret.

Register https://<console>/api/marketing-platforms/oauth/callback as the redirect URI in each app. The console builds it from NEXT_PUBLIC_CONSOLE_URL.

Help desks: Gorgias, Re:amaze, Kustomer, Gladly and Richpanel​

A site can keep its buyers and their recent orders in the merchant's own Gorgias, Re:amaze, Kustomer, Gladly or Richpanel account, and send contact form messages there as tickets. Each merchant connects with their own API credentials, so the deployment needs no vendor account: only the key those credentials are sealed under. Leave it unset and no Help desks card appears and nothing is sent to any of them. Set it on the console only: the sync runs there, and the tenant runtime never opens a merchant's credential.

VariableNeedWhenValue
HELP_DESKS_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored help desk credential with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a credential opened under an old key is sealed again under the new one on its next sync. Losing the key loses every connection: each merchant connects again.

Zapier​

The REST hooks Aglyn's Zapier app subscribes to (/v1/sites/{siteId}/hooks) are served by every deployment and post only to Zapier's hook host, with each merchant's own API key. What this variable gates is the console's Zapier card on a site's setup page, which lists and disconnects a site's Zaps: until it is set the card draws nothing. The app itself (apps/zapier) is published from Zapier's developer platform under your own Zapier developer account; point it at your console with its AGLYN_API_URL environment variable there.

VariableNeedWhenValue
ZAPIER_APP_URLOptionalRuntime, consoleThe https:// link to your published Zapier app (its public page or invite link). Set it once the app is published; the card's Open in Zapier button goes there.

Fulfillment networks: ShipBob, ShipMonk and Amazon Multi-Channel Fulfillment​

A store can send its paid orders to the merchant's own ShipBob or Amazon Multi-Channel Fulfillment account, read the shipments and tracking back onto the order, and keep its stock counts in step. Neither network takes a key a merchant could paste: each needs an app the deployment registers with the network, and the merchant signs in to grant it. A network is offered only when its app's variables and the token key are set; with neither network set, no Fulfillment networks card appears and nothing is sent. Set these on the console only: the job that sends orders runs there, and the tenant runtime never opens a grant.

VariableNeedWhenValue
FULFILLMENT_NETWORKS_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored grant with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD). Losing the key loses every connection: each merchant connects again.
SHIPBOB_CLIENT_IDOptionalRuntime, consoleThe ShipBob developer app's client id. ShipBob is offered only with it and its secret.
SHIPBOB_CLIENT_SECRETOptionalRuntime, consoleThe ShipBob app's client secret.
SHIPBOB_ENVIRONMENTOptionalRuntime, consolesandbox sends everything to ShipBob's sandbox, which takes test orders only. Unset is production.
AMAZON_SP_API_APPLICATION_IDOptionalRuntime, consoleThe selling-partner app's id (amzn1.sp.solution.…). Amazon is offered only with it and the two Login with Amazon values below.
AMAZON_SP_API_LWA_CLIENT_IDOptionalRuntime, consoleThe app's Login with Amazon client id.
AMAZON_SP_API_LWA_CLIENT_SECRETOptionalRuntime, consoleThe app's Login with Amazon client secret.
AMAZON_SP_API_REGIONOptionalRuntime, consolena (default), eu or fe: the Selling Partner API region the deployment's sellers are in.
AMAZON_SP_API_ENVIRONMENTOptionalRuntime, consolesandbox sends everything to Amazon's sandbox. Unset is production.
AMAZON_SP_API_DRAFT_APPOptionalRuntime, consoletrue while the app is a draft, so the consent page is asked for with version=beta.
SHIPMONK_ENABLEDOptionalRuntime, consoletrue offers ShipMonk. It needs no app: each merchant pastes their own ShipMonk API key and store id, sealed with the token key.
SHIPMONK_ENVIRONMENTOptionalRuntime, consolesandbox sends everything to ShipMonk's sandbox (sandbox.shipmonk.dev), which takes test orders only and needs sandbox keys. Unset is production.
FLEXPORT_ENABLEDOptionalRuntime, consoletrue offers Flexport. It needs no app: each merchant pastes an API token from their own Flexport Seller Portal, sealed with the token key. Flexport's production and sandbox accounts share one address (logistics-api.flexport.com); the token decides which one a call reaches, so a connection is always treated as production.

Register https://<console>/api/fulfillment-networks/oauth/callback as the redirect URI in both apps. ShipBob's webhooks are subscribed for each connection at https://<console>/api/fulfillment-networks/webhooks/shipbob with a token of their own; nothing needs registering for them. ShipMonk's webhooks are set up by each merchant in their ShipMonk account, at the address and with the signing secret the card shows once, and are answered at https://<console>/api/fulfillment-networks/webhooks/shipmonk; each is verified by its HMAC-SHA512 X-Sm-Signature. Flexport's webhooks are registered through its API for each connection, at https://<console>/api/fulfillment-networks/webhooks/flexport, with a token of their own sent in a header; nothing needs registering for them.

Couriers: DoorDash Drive​

A store can send a DoorDash courier for its own local deliveries from the merchant's own DoorDash Drive developer account. The deployment registers nothing and holds no DoorDash account: each merchant pastes their own keys, and DoorDash bills each courier to that merchant's account. The deployment holds only the key those keys are sealed under. Leave it unset and no Couriers card or Send a courier appears, and nothing is sent to DoorDash. Set it on the console only: the routes, DoorDash's webhook and the job that follows open deliveries run there.

VariableNeedWhenValue
COURIERS_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored DoorDash Drive signing secret with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a secret opened under an old key is sealed again under the new one the next time it is used. Losing the key loses every connection: each merchant connects again.

Each merchant registers the webhook in their own DoorDash developer portal, at https://<console>/api/couriers/webhooks/doordash?site=<siteId> with the token their Couriers card shows; the card gives them both.

Inventory sync: Cin7 Core, inFlow, Brightpearl and Ordoro​

A store can keep its stock counts, products and paid orders in step with the merchant's own Cin7 Core, inFlow Inventory, Brightpearl or Ordoro account. Cin7 Core, inFlow and Ordoro need nothing registered by the deployment: the merchant pastes API keys from their own account, and all three are offered once the token key that seals those keys is set. Brightpearl needs a developer app the deployment registers with Brightpearl, and is offered only when its references and the token key are set. With no token key, no Inventory and ERP card appears and nothing is sent. Set these on the console only: the job runs there, and the tenant runtime never opens a stored key.

VariableNeedWhenValue
INVENTORY_SYNC_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored API key and grant with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD). Losing the key loses every connection: each merchant connects again.
BRIGHTPEARL_APP_REFOptionalRuntime, consoleThe Brightpearl app's reference, its OAuth client id. Brightpearl is offered only with it and the developer reference.
BRIGHTPEARL_DEV_REFOptionalRuntime, consoleThe developer reference every Brightpearl API call carries.
BRIGHTPEARL_CLIENT_SECRETOptionalRuntime, consoleThe app's client secret, when the app has confidential OAuth turned on.

Register https://<console>/api/inventory-sync/oauth/callback as the redirect URI of the Brightpearl app. The console builds it from NEXT_PUBLIC_CONSOLE_URL.

Marketplaces: Amazon, eBay, Etsy, TikTok Shop, Walmart and Faire​

A store can keep its listings on the merchant's own marketplace seller accounts in step with its stock, bring their orders in as its own orders, and send tracking back. No marketplace takes a key a merchant could paste: each needs an app the deployment registers with the marketplace, and the merchant signs in to grant it. A marketplace is offered only when its app's variables and the token key are set; with none set, no Marketplaces card appears and nothing is sent. Set these on the console only: the job runs there, and the tenant runtime never opens a grant.

VariableNeedWhenValue
MARKETPLACES_TOKEN_KEYFeatureRuntime, console32 random bytes, base64 — openssl rand -base64 32. Seals every stored grant with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD). Losing the key loses every connection: each merchant connects again.
MARKETPLACES_AMAZON_APPLICATION_IDOptionalRuntime, consoleThe selling-partner app's id (amzn1.sp.solution.…). Amazon is offered only with it and the two Login with Amazon values below.
MARKETPLACES_AMAZON_LWA_CLIENT_IDOptionalRuntime, consoleThe app's Login with Amazon client id.
MARKETPLACES_AMAZON_LWA_CLIENT_SECRETOptionalRuntime, consoleThe app's Login with Amazon client secret.
MARKETPLACES_AMAZON_REGIONOptionalRuntime, consolena (default), eu or fe.
MARKETPLACES_AMAZON_ENVIRONMENTOptionalRuntime, consolesandbox sends everything to Amazon's sandbox. Unset is production.
MARKETPLACES_AMAZON_DRAFT_APPOptionalRuntime, consoletrue while the app is a draft, so the consent page is asked for with version=beta.
MARKETPLACES_EBAY_CLIENT_IDOptionalRuntime, consoleThe eBay keyset's App ID. eBay is offered only with it, its Cert ID and the RuName.
MARKETPLACES_EBAY_CLIENT_SECRETOptionalRuntime, consoleThe keyset's Cert ID.
MARKETPLACES_EBAY_RU_NAMEOptionalRuntime, consoleThe RuName whose accept URL is the redirect address below.
MARKETPLACES_EBAY_ENVIRONMENTOptionalRuntime, consolesandbox for eBay's sandbox. Unset is production.
MARKETPLACES_EBAY_MARKETPLACE_IDOptionalRuntime, consoleThe eBay site listings go to, EBAY_US by default.
MARKETPLACES_ETSY_KEYSTRINGOptionalRuntime, consoleThe Etsy app's keystring. Etsy is offered only with it and the shared secret. Etsy has no sandbox.
MARKETPLACES_ETSY_SHARED_SECRETOptionalRuntime, consoleThe Etsy app's shared secret.
MARKETPLACES_TIKTOK_APP_KEYOptionalRuntime, consoleThe TikTok Shop app's key. TikTok Shop is offered only with it, its secret and its service id. It has no sandbox.
MARKETPLACES_TIKTOK_APP_SECRETOptionalRuntime, consoleThe app's secret.
MARKETPLACES_TIKTOK_SERVICE_IDOptionalRuntime, consoleThe app's service id, from its authorization link in Partner Center.
MARKETPLACES_TIKTOK_REGIONOptionalRuntime, consoleus (default) or global: which consent page sellers are sent to.
MARKETPLACES_WALMART_CLIENT_IDOptionalRuntime, consoleThe Walmart solution-provider app's client id. Walmart is offered only with it and its secret.
MARKETPLACES_WALMART_CLIENT_SECRETOptionalRuntime, consoleThe app's client secret.
MARKETPLACES_WALMART_ENVIRONMENTOptionalRuntime, consolesandbox for Walmart's sandbox. Unset is production.
MARKETPLACES_WALMART_CHANNEL_TYPEOptionalRuntime, consoleThe WM_CONSUMER.CHANNEL.TYPE value Walmart issued the solution provider, when it issued one.
MARKETPLACES_FAIRE_APPLICATION_IDOptionalRuntime, consoleThe Faire app's application id. Faire is offered only with it and its secret. It has no sandbox.
MARKETPLACES_FAIRE_APPLICATION_SECRETOptionalRuntime, consoleThe Faire app's application secret.

Register https://<console>/api/marketplaces/oauth/callback as the redirect address in every app (for eBay, as the accept URL of the RuName).

A merchant can connect their own Printful store or Printify shop, import its products, and have paid orders sent to it to make and ship. Each merchant connects with a token made in their own account — neither service needs a developer app or partnership of the deployment's — so the deployment holds only the key those tokens are sealed under. Leave it unset and no Print on demand card appears and no order is sent anywhere. Set it on the console and the tenant runtime: paid orders are sent from wherever commerce's order events are delivered, and the console connects, imports and runs the retry job.

VariableNeedWhenValue
PRINT_ON_DEMAND_TOKEN_KEYFeatureRuntime32 random bytes, base64 — openssl rand -base64 32. Seals every stored Printful and Printify token, and each connection's webhook secret, with AES-256-GCM. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens, and a token opened under an old key is sealed again under the new one the next time it is used. Losing the key loses every connection: each merchant connects again, and until they do their paid orders are not sent.

The services are told about shipments at https://<console>/api/print-on-demand/webhooks/printful and …/webhooks/printify, which the console registers itself when a merchant connects and builds from NEXT_PUBLIC_CONSOLE_URL. A console the services cannot reach (a laptop) still works: each open order is asked after by the console job every 15 minutes.

Delivery apps: DoorDash, Uber Eats and Grubhub​

A store's POS register can take the merchant's own DoorDash, Uber Eats and Grubhub orders. No service takes a key a merchant could paste: each admits a point-of-sale integration only through a partner (integration provider) account the deployment's operator holds with it, and the merchant links their store by the store id the service shows them. A service is offered only when every variable it names is set; with none set, no Delivery apps card appears, the register shows no delivery orders and every webhook answers 404. Set these on the console only: the webhooks, the register's routes and the job run there.

VariableNeedWhenValue
DELIVERY_APPS_DOORDASH_DEVELOPER_IDOptionalRuntime, consoleThe DoorDash developer id of the integration. DoorDash is offered only with it and the three below.
DELIVERY_APPS_DOORDASH_KEY_IDOptionalRuntime, consoleThe integration's access key id.
DELIVERY_APPS_DOORDASH_SIGNING_SECRETOptionalRuntime, consoleThe access key's signing secret, as DoorDash issues it (base64url). Signs each request's JWT.
DELIVERY_APPS_DOORDASH_WEBHOOK_SECRETOptionalRuntime, consoleThe token set on the integration's order webhook; a webhook without it is refused.
DELIVERY_APPS_DOORDASH_PROVIDER_TYPEOptionalRuntime, consoleThe provider type DoorDash assigned the integration, sent with each menu.
DELIVERY_APPS_DOORDASH_ENVIRONMENTOptionalRuntime, consolesandbox marks DoorDash orders as test orders. Unset is live.
DELIVERY_APPS_UBER_EATS_CLIENT_IDOptionalRuntime, consoleThe Uber Eats app's client id. Uber Eats is offered only with it and its secret.
DELIVERY_APPS_UBER_EATS_CLIENT_SECRETOptionalRuntime, consoleThe app's client secret. Also the key Uber signs each webhook with.
DELIVERY_APPS_UBER_EATS_ENVIRONMENTOptionalRuntime, consolesandbox marks Uber Eats orders as test orders (Uber's test stores). Unset is live.
DELIVERY_APPS_GRUBHUB_CLIENT_IDOptionalRuntime, consoleThe Grubhub partner client id. Grubhub is offered only with it and the two below.
DELIVERY_APPS_GRUBHUB_SECRET_KEYOptionalRuntime, consoleThe partner's MAC secret key, as Grubhub issues it (base64). Signs each request and verifies each webhook.
DELIVERY_APPS_GRUBHUB_PARTNER_KEYOptionalRuntime, consoleThe partner key sent as X-GH-PARTNER-KEY.
DELIVERY_APPS_GRUBHUB_ENVIRONMENTOptionalRuntime, consolesandbox sends Grubhub calls to its pre-production host and marks its orders as test orders. Unset is live.

Register each service's order webhook as https://<console>/api/delivery-apps/webhooks/doordash, …/uber-eats or …/grubhub. Uber's webhook notifications must include orders, cancels, failures and resolved fulfillment issues.

POS hardware shop​

Merchants can buy Stripe Terminal card readers and accessories from the deployment on Products → Point of sale (the Hardware card). The sale is the operator's: Checkout runs on the platform Stripe account with no connected account, and a staff member places each paid order in the Stripe Dashboard's Terminal hardware shop, shipping to the merchant, then records Stripe's order id and the tracking on Staff → Hardware orders. Canceling or refunding an order there asks whether to Email the buyer (on unless you turn it off): the buyer then gets the same canceled-and-refunded email a workspace or account closure sends, once whichever sends it first, and a reader sold in person gets the refund email with what came back. The choice is in the action's audit row. Set these on the console only. The shop stays hidden until it is switched on, at least one reader has a price, a shipping figure is set, and card readers are available (a test key, or STRIPE_TERMINAL_LIVE_ENABLED).

VariableNeedWhenValue
POS_HARDWARE_SHOP_ENABLEDOptionalRuntime, consoleExactly true opens the shop. Anything else keeps it hidden.
POS_HARDWARE_PRICESOptionalRuntime, consoleJSON of Stripe's own price for each item, in cents, keyed by stripe-reader-s710, stripe-reader-s700, stripe-reader-t600, bbpos-wisepos-e, stripe-reader-m2, stripe-reader-s700-dock, stripe-reader-hub, stripe-reader-s700-case, stripe-reader-s700-cable, stripe-reader-charger, stripe-reader-m2-dock, stripe-reader-m2-mount, bbpos-wisepos-e-dock or bbpos-wisepos-e-power-cable. An item with no price, or a malformed one, is not offered.
POS_HARDWARE_MARKUP_BPSOptionalRuntime, consoleThe markup on Stripe's price as a whole number of basis points (hundredths of a percent). Unset is the default, 3000. 0 sells at Stripe's price; a value that is not a whole number hides the shop.
POS_HARDWARE_SHIPPINGOptionalRuntime, consoleJSON of a flat shipping amount in cents for each speed offered: standard, express, priority. The merchant pays it.
POS_HARDWARE_AUTOMATIC_TAXOptionalRuntime, consoleExactly true turns on Stripe automatic tax for hardware checkouts, on the platform's own tax registrations.
POS_HARDWARE_IN_PERSON_ENABLEDOptionalRuntime, consoleExactly true lets your own store's register sell readers in person (below). Anything else keeps it off.
POS_HARDWARE_SELLER_HOST_IDOptionalRuntime, consoleThe site id of your own store whose register sells readers in person.
POS_HARDWARE_SELLER_ORG_IDOptionalRuntime, consoleThe id of the workspace that owns that store. A store moved to another workspace stops selling hardware.

Selling readers in person. With the three variables above set, a product on that one store can say which reader or accessory it is (Point of sale hardware in the product editor, shown on that store only), and a sale of it at the store's register becomes a hardware order for the buyer's workspace. The store's own prices, sales tax and payment account apply, exactly as for anything else it sells; none of the shop's figures above are needed.

At that store's register, anyone who may sell there:

  • has the buyer accept the terms of sale before they pay (your published Terms, Sections 4.26–4.32, summed up with a link): Show on display puts them on the register's customer display, and Accept on this tablet shows them on the register for the buyer to accept. Each acceptance is recorded on the sale and its hardware order with the Terms version, the time, the screen, the register user and the device's address and browser. Every receipt of the sale prints which terms it is made under, and Print the booth sign opens a sign saying the same, for when no screen can be used;

  • on a sale with a reader, selects Attach buyer and finds the buyer by their exact email address, their name or their workspace's name, then picks the workspace and, if they know it, the store. Each lookup is rate-limited and written to the staff audit log (the typed address masked there), and shows only a name, the whole email address and each workspace's stores. Someone with no account is invited by email and gets the reader in the workspace they create;

  • may scan or type each reader's serial number, so the order is set up by itself when the buyer adds the reader, or add the reader to the buyer's store there and then with its pairing code. A store with no card reader address yet takes its address there too, with the buyer; it is saved as the store's reader address, exactly as the store's POS devices card saves it, and a store that already has one keeps it;

  • finds every reader sold at the kiosk, while the register was offline or to someone not yet attached under To attach.

Staff → Hardware orders lists the same sales under Sold in person, to attach, buyers' return requests under Return requested, and refunds an in-person sale to the card it was paid with, from the store's own account. A sale paid in cash is refunded as a return at the store's register. On the staff sales tax return, each in-person sale counts once, in its store's register sales.

POS_HARDWARE_SELLER_ORG_ID also names your own stores for the staff sales tax return, whether or not readers sell in person: everything sold from any site of that workspace, card readers, apparel or anything else, is your own sale and is folded into the return's filing figures, with a breakdown by store and state. Leave it unset and no store's sales are folded in.

Paid orders arrive on the platform webhook as checkout.session.completed, which the endpoint already subscribes to; there is no new endpoint.

4G for card readers​

Stripe sells cellular service for the Stripe Reader S710 as a monthly subscription per reader, billed to the Stripe account the reader is registered to, which is your platform account. With these set, a store's POS devices card offers Turn on 4G wherever the store has an S710: it turns cellular on for every S710 at that store, on the store's reader configuration, and each workspace is charged once a month, in arrears, for each S710 that had 4G in the month just ended. The charge is Stripe's price plus what collecting it costs you, with no markup, on an invoice to the workspace's card. Set them on the console and tenant (the charge runs as a background job). Unset, nothing about 4G shows anywhere.

Turning 4G on takes the workspace's Manage billing permission as well as Registers and devices; turning it off takes only Registers and devices. A charge that doesn't collect stays open on the workspace's Billing page and raises an operator alert, and the members who manage the workspace's billing are emailed once, that day, with the date 4G turns off unless it is paid. If it is still unpaid after the grace period, 4G is turned off at Stripe for all of the workspace's stores, those members are emailed again, and another operator alert is raised. Paying doesn't turn 4G back on: the merchant turns it on again once the charge is paid. When a workspace is erased, its erasure charges the month it is erased in (and the month before, if the monthly run has not charged it yet) before any data is deleted, then turns 4G off.

VariableNeedWhenValue
POS_CELLULAR_MONTHLY_COST_CENTSOptionalRuntime, console and tenantWhat Stripe charges you for one reader's cellular service a month, in whole cents. Unset, or not a whole number, keeps 4G hidden.
POS_CELLULAR_BILLING_COST_BPSOptionalRuntime, console and tenantThe card processing percentage on the monthly charge, in basis points. Unset is the platform's card rate.
POS_CELLULAR_BILLING_COST_FIXED_CENTSOptionalRuntime, console and tenantThe card processing fixed fee on the monthly charge, in cents. Unset is the platform's.
POS_CELLULAR_GRACE_DAYSOptionalRuntime, console and tenantHow many days a month's 4G charge can stay unpaid before 4G is turned off for the workspace's stores, as a whole number from 1 to 90. Unset is 14. Any other value keeps 4G hidden.
STRIPE_PRODUCT_POS_CELLULAROptionalRuntime, console and tenantThe Stripe product the monthly charge is billed against; setup-stripe.mjs creates it and prints the id. It carries the tax code automatic tax computes the line from. Unset keeps 4G hidden.

Mobile apps and push​

The native apps (Aglyn and Aglyn POS, for iOS, iPadOS and macOS from apps/ios, and for Android and the Windows desktop from apps/android) are built, not served by your containers. Their settings are build settings, compiled into the app, so changing one means building and shipping the app again. None of them is a secret. On Apple they are xcconfig keys (copy apps/ios/Config/Production.xcconfig.example); on Android and the desktop they are Gradle settings, -Paglyn.<name>=… or the AGLYN_<NAME> environment variable.

Apple (xcconfig)Android (Gradle)NeedValue
AGLYN_CONSOLE_URLconsoleUrlRequiredYour console origin. Every API call and console page the app opens is on it, and it is the app's universal-link host. Default https://app.aglyn.com.
AGLYN_BRAND_NAME—OptionalThe app's name and the product name its copy says. Default Aglyn.
AGLYN_FIREBASE_API_KEYfirebaseApiKeyRequiredThe Firebase app config of the app you registered in your project, per bundle.
AGLYN_FIREBASE_AUTH_DOMAINfirebaseAuthDomainRequiredAs above.
AGLYN_FIREBASE_PROJECT_IDfirebaseProjectIdRequiredAs above.
AGLYN_FIREBASE_APP_IDfirebaseAppIdRequiredAs above.
AGLYN_FIREBASE_MESSAGING_SENDER_IDfirebaseMessagingSenderIdOptionalAs above.
AGLYN_FIREBASE_STORAGE_BUCKET—OptionalAs above.
AGLYN_GOOGLE_IOS_CLIENT_ID, AGLYN_GOOGLE_WEB_CLIENT_IDgoogleWebClientIdOptionalGoogle sign-in's OAuth client ids, used by "Connect Google" on the Account page. On Apple both are needed; on Android only the Web client id, and the Firebase project needs an Android OAuth client for the app's package and signing certificate. The action is hidden until they are set.
—AGLYN_GOOGLE_DESKTOP_CLIENT_ID, AGLYN_GOOGLE_DESKTOP_CLIENT_SECRETOptionalThe Windows app's OAuth client, for "Connect Google" through the system browser. Google requires the secret of a desktop client and does not treat it as confidential. Environment variables, or -Daglyn.googleDesktopClientId and -Daglyn.googleDesktopClientSecret.
AGLYN_PASSKEY_RP_IDpasskeyRpIdOptionalThe domain passkeys are made for: your NEXT_PUBLIC_WORKSPACE_DOMAIN, bare (example.com). "Set up a passkey" is hidden until it is set. Set it only once the app is linked to that domain: an apple-app-site-association file and the webcredentials: Associated Domains entry for Apple, an assetlinks.json naming the app's signing certificate for Android.
AGLYN_AUTH_EMULATOR_HOSTauthEmulatorHostDevelopmenthost:port of a local Auth emulator. Never set in a store build.
AGLYN_FIRESTORE_EMULATOR_HOSTfirestoreEmulatorHostDevelopmenthost:port of a local Firestore emulator. Never set in a store build.

The server side of push is configured in your containers:

VariableNeedWhenValue
MOBILE_PUSH_ENABLEDOptionalRuntime, console and tenantThe kill switch for every push sender. 0 stops every push; anything else, or unset, leaves push on.
APNS_KEY_P8FeatureRuntime, console and tenantYour Apple Push Notification service key (the .p8 file from your Apple Developer account): its PEM text, or that PEM base64-encoded. Without it, and the two below, iOS and macOS devices are not sent a push, and the server logs that once. A secret.
APNS_KEY_IDFeatureRuntime, console and tenantThe 10-character id of that key.
APNS_TEAM_IDFeatureRuntime, console and tenantYour Apple Developer team id.

Passkeys set up from the Android app are checked against the app's signing certificate:

VariableNeedWhenValue
PASSKEY_ANDROID_APK_KEY_HASHESFeatureRuntime, consoleComma-separated SHA-256 hashes of the signing certificates of your Android builds, each as base64url without padding (43 characters). An Android passkey is accepted only from an app whose hash is listed; unset, Android passkeys are refused and the web console's are unaffected.

The two files that link your apps to your domains are served from these, by both containers:

VariableNeedWhenValue
NATIVE_APPLE_APP_IDSFeatureRuntime, console and tenantComma-separated Apple app ids, <team id>.<bundle id>, the preferred app first. They become /.well-known/apple-app-site-association: on the console's own host with webcredentials and the console paths the apps open (applinks), and on the host of NEXT_PUBLIC_PLATFORM_HOME_URL with webcredentials only. Unset, the file answers 404.
NATIVE_ANDROID_APPSFeatureRuntime, console and tenant;-separated <package>=<sha256>,<sha256> entries, each fingerprint the colon-separated SHA-256 of a signing certificate as Play Console prints it (list the Play app signing key and your upload key). They become /.well-known/assetlinks.json on the same two hosts, granting each package app links and passkeys. Unset, the file answers 404.

Every other host either container serves, a customer's site or a white-label console, answers 404 for both files.

A person with no registered device is never sent a push. A push carries the notification's title and body, with the device push token, to Apple Push Notification service for an iPhone, iPad or Mac and to Firebase Cloud Messaging for an Android device. The server sends to APNs directly with the key above, and to FCM with the Firebase project credentials it already holds, so FCM needs no variable of its own. If you run your own build of the app, those are your vendors, and your own subprocessor list names them.


Analytics and advertising​

The advertising ids are Aglyn's own marketing funnel. Leave them unset.

NEXT_PUBLIC_ADS_CONVERSION_ID, NEXT_PUBLIC_ADS_SIGNUP_LABEL and NEXT_PUBLIC_ADS_SUBSCRIBE_LABEL identify a Google Ads account and two conversion actions inside it, so that Aglyn's own signup and subscribe events reach Aglyn's own advertising account. Both events also hand the tag the account's email address for Google's enhanced conversions: the tag hashes it in the browser before anything is sent, and it goes nowhere when the visitor has not granted advertising, when the ids are unset, or when no tag is loaded.

NEXT_PUBLIC_META_PIXEL_ID, NEXT_PUBLIC_LINKEDIN_PARTNER_ID and NEXT_PUBLIC_GTM_CONTAINER_ID are the same kind of value one step further: they build retargeting audiences, so a hardcoded one would put your users into Aglyn's advertising lists — a disclosure you never made about people we have no basis to hold. Your own advertising tags are your own business; set your own ids or leave every one of these blank.

Nothing is compiled in, and the code fires nothing when they are unset — no tag, no request. The half-configured case is handled too: an id with an empty label would produce a target Google Ads accepts and files against the account's default conversion, so the code returns nothing instead.

There is no default because analytics is permitted to emit on any production build including a self-hosted one. A hardcoded id would have every operator reporting their users' signups into Aglyn's ad account. Leave them blank, or set your own.

VariableNeedWhenValue
NEXT_PUBLIC_ADS_CONVERSION_IDAglyn-only / your ownBuildGoogle Ads conversion id, AW- plus digits. Google Ads → Goals → Conversions → the tag's id.
NEXT_PUBLIC_ADS_SIGNUP_LABELAglyn-only / your ownBuildThe opaque conversion label for the signup action, from the same page. Fires only when the id is also set.
NEXT_PUBLIC_ADS_SUBSCRIBE_LABELAglyn-only / your ownBuildThe conversion label for the subscribe action.
NEXT_PUBLIC_META_PIXEL_IDAglyn-only / your ownBuildMeta (Facebook/Instagram) Pixel id — digits only. Loads the pixel in the console, and only for a visitor whose recorded consent grants the advertising category. Blank loads nothing.
NEXT_PUBLIC_LINKEDIN_PARTNER_IDAglyn-only / your ownBuildLinkedIn Insight Tag partner id — digits only, from LinkedIn Campaign Manager → Analytics → Insight Tag. Same consent gate as the pixel above. Blank loads nothing.
NEXT_PUBLIC_GTM_CONTAINER_IDAglyn-only / your ownBuildGoogle Tag Manager container, GTM- plus 5–10 characters. Gated on the visitor's analytics consent, never anything looser — a container is a loader, and it is the likeliest thing on a page to carry an advertising tag. What it loads is decided in Google's UI and is invisible to this codebase, so set it only if you know what is in the container you are pointing at. Blank loads nothing.
GA4_MEASUREMENT_IDOptionalRuntimeG-XXXXXXXXXX for server-side GA4 measurement-protocol events — Stripe webhook revenue, publish events, things no browser can send. Google Analytics → Admin → Data streams → your stream.
GA4_API_SECRETOptionalRuntimeThe measurement-protocol API secret for that stream. Both are needed; with either missing, every server-side hit is dropped silently — no log, no throw. Note this path has no consent gate, and its custom dimensions must be registered in GA4 or the events land unreportable.
NEXT_PUBLIC_ANALYTICS_ALLOW_NONPRODDevelopment onlyBuildRe-enables analytics on a non-production build. A build using it stamps traffic_type: internal on every hit unconditionally, so it cannot be used to collect real traffic. Leave unset.

Account-holder ad audiences​

A nightly job (/api/admin/ad-audience-sync, the consoleAdAudienceSync scheduled function) can keep a customer-list audience of your own account holders in step on your own Meta, Google and LinkedIn ad accounts, so you can retarget people who signed up and leave them out of prospecting. Each provider receives only the SHA-256 hash of each address, normalized the way that provider documents. An account is included only when its address is verified, it is not disabled, staff, locked, banned or on your support address's domain, a super staff member has not opted it out on its staff user page, and its country is the United States — the billing address of a workspace it owns, or else where it signed up from. An account with neither is left out.

It sends nothing until the release_ad_audience_sync release flag is on, and it is off by default. This is a new use of your account holders' data: publish it in your own privacy policy, and add the recipients to your own subprocessor list, before you turn the flag on. Each provider is independent and is skipped, with a log line naming what is missing, until all of its variables are set. Before you turn the flag on, ask the route for the numbers: a GET with Authorization: Bearer $CRON_SECRET reports, per provider, how many accounts are eligible and how many hashes would be added and removed, and sends nothing. These are Aglyn-only / your own values: set your own or leave every one blank. Set them on the console only.

VariableNeedWhenValue
META_AUDIENCE_SYNC_ACCESS_TOKENAglyn-only / your ownRuntime, consoleA system-user access token with ads_management on the ad account that owns the audience. A secret.
META_AUDIENCE_SYNC_AUDIENCE_IDAglyn-only / your ownRuntime, consoleThe id of a Custom Audience of subtype CUSTOM with customer file source USER_PROVIDED_ONLY, created once in Ads Manager or the Marketing API. The job never creates one. Each night's change replaces the audience's members in one session.
GOOGLE_AUDIENCE_SYNC_CLIENT_IDAglyn-only / your ownRuntime, consoleAn OAuth client id from a Google Cloud project with the Data Manager API enabled. The Google Ads API's Customer Match upload is closed to projects that have not used it recently, so the job uses the Data Manager API, which needs no developer token.
GOOGLE_AUDIENCE_SYNC_CLIENT_SECRETAglyn-only / your ownRuntime, consoleThat client's secret. A secret.
GOOGLE_AUDIENCE_SYNC_REFRESH_TOKENAglyn-only / your ownRuntime, consoleA refresh token for a Google account with access to the Google Ads account, granted the https://www.googleapis.com/auth/datamanager scope. A secret.
GOOGLE_AUDIENCE_SYNC_CUSTOMER_IDAglyn-only / your ownRuntime, consoleThe Google Ads customer id that owns the list, with or without dashes.
GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_IDOptionalRuntime, consoleA manager account the Google account reaches the customer through. Leave unset when it has direct access.
GOOGLE_AUDIENCE_SYNC_USER_LIST_IDAglyn-only / your ownRuntime, consoleThe id of an empty Customer Match list (contact information) in that account. Accept the Customer Match terms in Google Ads first: each upload states that they are accepted.
LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKENAglyn-only / your ownRuntime, consoleA member access token with rw_dmp_segments, from a developer app LinkedIn has approved for Matched Audiences. A secret.
LINKEDIN_AUDIENCE_SYNC_SEGMENT_IDAglyn-only / your ownRuntime, consoleThe DMP segment's id (or its urn:li:dmpSegment: URN).
META_AUDIENCE_SYNC_STAGE_FREE_UNPUBLISHED_AUDIENCE_IDOptionalRuntime, consoleThe Custom Audience for the free, not published stage list, made the same way as the main one. Unset, that stage is not sent to Meta.
META_AUDIENCE_SYNC_STAGE_FREE_PUBLISHED_AUDIENCE_IDOptionalRuntime, consoleThe Custom Audience for the free, published stage list, made the same way as the main one. Unset, that stage is not sent to Meta.
META_AUDIENCE_SYNC_STAGE_PAID_AUDIENCE_IDOptionalRuntime, consoleThe Custom Audience for the paid stage list, made the same way as the main one. Unset, that stage is not sent to Meta.
META_AUDIENCE_SYNC_STAGE_LAPSED_AUDIENCE_IDOptionalRuntime, consoleThe Custom Audience for the lapsed stage list, made the same way as the main one. Unset, that stage is not sent to Meta.
GOOGLE_AUDIENCE_SYNC_STAGE_FREE_UNPUBLISHED_USER_LIST_IDOptionalRuntime, consoleThe Customer Match list for the free, not published stage list, in the same Google Ads account. Unset, that stage is not sent to Google.
GOOGLE_AUDIENCE_SYNC_STAGE_FREE_PUBLISHED_USER_LIST_IDOptionalRuntime, consoleThe Customer Match list for the free, published stage list, in the same Google Ads account. Unset, that stage is not sent to Google.
GOOGLE_AUDIENCE_SYNC_STAGE_PAID_USER_LIST_IDOptionalRuntime, consoleThe Customer Match list for the paid stage list, in the same Google Ads account. Unset, that stage is not sent to Google.
GOOGLE_AUDIENCE_SYNC_STAGE_LAPSED_USER_LIST_IDOptionalRuntime, consoleThe Customer Match list for the lapsed stage list, in the same Google Ads account. Unset, that stage is not sent to Google.
LINKEDIN_AUDIENCE_SYNC_STAGE_FREE_UNPUBLISHED_SEGMENT_IDOptionalRuntime, consoleThe DMP segment for the free, not published stage list. Unset, that stage is not sent to LinkedIn.
LINKEDIN_AUDIENCE_SYNC_STAGE_FREE_PUBLISHED_SEGMENT_IDOptionalRuntime, consoleThe DMP segment for the free, published stage list. Unset, that stage is not sent to LinkedIn.
LINKEDIN_AUDIENCE_SYNC_STAGE_PAID_SEGMENT_IDOptionalRuntime, consoleThe DMP segment for the paid stage list. Unset, that stage is not sent to LinkedIn.
LINKEDIN_AUDIENCE_SYNC_STAGE_LAPSED_SEGMENT_IDOptionalRuntime, consoleThe DMP segment for the lapsed stage list. Unset, that stage is not sent to LinkedIn.

Google and LinkedIn have no replace, so the job keeps the hashes it last sent to each, server-side in adAudienceSync/{provider}/members, to remove an account that is no longer eligible — including one that was erased, whose address is gone. No address is stored and nothing is logged but counts. A night whose audience would shrink by more than half is held and raises an operator alert, since that is likelier a failed read than real churn; a POST with ?force=1 sends it.

Beside that one list, each provider can keep a stage list for each of four stages, so ads to people who already have an account can fit where they are. Every stage is a subset of the same eligible audience:

  • free, not published: owns no workspace on a paid plan, and no site with a page of its own published (a page a person made or edited; the starter page every new site is born with does not count);
  • free, published: owns no workspace on a paid plan, and at least one such published page;
  • paid: owns a workspace whose plan, as the billing rules resolve it, is a paid one (a staff comp counts; a canceled subscription does not);
  • lapsed: the account is more than 30 days old and nothing it did in the last 30 days was recorded: no entry of its own in a site's or workspace's change log, and no AI job it started. Sign-in times are not activity, because a browser can sign a person back in with nobody there. A lapsed account is also in one of the other three.

A stage list reuses its provider's credentials and needs only its own list id; create each one by hand, as for the main list. Unset, the stage is not sent. Each keeps its own record of what it was sent (adAudienceSync/{provider}--{stage}/members) and its own shrink hold. The GET report counts every stage's eligible accounts whether or not its lists are set, so you can wait until a stage is big enough to be worth a list.

Inline vendor boots need the CSP nonce. Every advertising tag the console mounts is a pair — an inline boot that declares the consent state and configures the account, then the vendor's library — and the console enforces a nonce'd script-src. The library has a src the policy allows; the boot has nothing but the nonce, and Next stamps one onto a <Script> only when it is handed one, because the pairs mount after hydration where its automatic stamping never reaches. So the root layout reads the per-request nonce the middleware minted and passes it down to the mount, which stamps both halves of every pair. Keep that path intact if you customize the layout: a mount that drops it loads every library and runs no boot, so the account is never configured, no conversion ever fires, and the only trace is a CSP violation — on the health board as an enforced script-src-elem row whose blocked origin is inline.

Sign-up attribution is built in, and needs nothing​

Where each account came from — the first page a visitor landed on, the site that sent them, campaign tags, which ad click ids were present, and the door they signed up through — is captured by the platform itself and written on the account when it is created, for the staff console's Acquisition card. There is nothing to configure and nothing to add to your env file, and no analytics vendor is involved at any step:

  • the hosts it treats as your own come from values you already set — NEXT_PUBLIC_WORKSPACE_DOMAIN and every subdomain of it, NEXT_PUBLIC_CONSOLE_URL, NEXT_PUBLIC_DOCS_ORIGIN and NEXT_PUBLIC_PLATFORM_HOME_URL — plus any a super staff member adds on Staff → Platform settings;
  • a hop between two of your hosts that share no cookie is sealed with TOKEN_SIGNING_SECRET, which the console and the site runtime already share;
  • where the account was created from is read from the same geo headers as the sign-in history (proxy and geo);
  • the "already known?" check reads the CRM of the workspace behind PLATFORM_MARKETING_HOST_ID when you set it, and says so on the card when you have not;
  • your docs build includes it from the console it already names: the console target in DOCS_STATUS_TARGETS, or else the console DOCS_ERROR_BEACON_ENDPOINT reports to. A build that names neither loads nothing.

See Adding a first-party surface.

NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID is different in kind — it belongs to your own Firebase project. See Firebase client config.


AI​

The AI plugin is provider-generic: a provider adapter registers against the plugin's provider contract, and every AI door — the console Assist panel, the besigner's "Rewrite with AI", the generative doors and the jobs — routes through the model catalog rather than naming a vendor. Two adapters ship: anthropic and openai-compatible (any endpoint that speaks the OpenAI chat completions shape). Bring your own key; nothing is compiled in. Without a provider key each door answers 501 and says no AI provider is configured. The console panel is additionally behind the release_assist flag and generation behind release_ai_generative, both off by default in Remote Config.

VariableNeedWhenValue
ANTHROPIC_API_KEYFeatureRuntimesk-ant-…, from the vendor's console under API keys. The key of the anthropic adapter.
AI_OPENAI_COMPAT_BASE_URLFeatureRuntimeThe base URL of an OpenAI-compatible endpoint (https://host/v1; a trailing /chat/completions is stripped). The openai-compatible adapter is ready only when this AND its key are set.
AI_OPENAI_COMPAT_API_KEYFeatureRuntimeThe bearer token for that endpoint.
AI_PROVIDEROptionalRuntimeWhich registered adapter is the platform default: anthropic or openai-compatible, or the id of a provider another plugin registers. Unset, the first ready adapter is the default. A workspace's pluginSettings/ai may pick its own.
AI_DEFAULT_MODELOptionalRuntimeA model id, served by the default provider, for every step kind. Unset, each step kind takes its catalog tier on that provider. An id absent from the built-in rate table falls back to approximate rates, so cost telemetry and the margin alarm become estimates — and the prompt-cache minimum moves with the model, so a swap can silently stop caching.
AI_IMAGE_VERTEX_PROJECTFeatureRuntimeThe Google Cloud project Media's photo mode bills to, through Google's image models on Vertex AI. Unset, no photos are made and the door says so; illustrations are unaffected. The platform's Firebase service account needs the Vertex AI User role there, and the project needs the Vertex AI API enabled. Google becomes a recipient of the descriptions users type, so list it as a subprocessor before you set this.
AI_IMAGE_VERTEX_LOCATIONOptionalRuntimeThe Vertex AI location images are made in: global (the default), the us or eu multi-region, or a single region that serves the model. Google's global endpoint makes no promise about where a request is processed; us or eu keeps processing inside that jurisdiction, for the models that offer it, and lists about 10% higher.
AI_IMAGE_MODELOptionalRuntimeThe image model photos are made with. Default gemini-3.1-flash-image. An id absent from the built-in image rate table is priced at the dearest known image rate.
NEXT_PUBLIC_AI_IMAGE_PHOTOSOptionalBuildon offers the Photo, Art and Design kinds in Media's Create with AI window. Set it with AI_IMAGE_VERTEX_PROJECT; without it the window offers the Vector kinds only.
PIXABAY_API_KEYOptional (console)RuntimeA Pixabay API key, free from pixabay.com/api/docs once signed in. With it, the pictures on a page an AI job builds are searched on Pixabay by the site's kind and each section's subject, and each chosen photo is copied into the site's own media library with its photographer and Pixabay page recorded; searches are cached 24 hours, as Pixabay's terms ask. Unset, pages use the starter photos every new site ships with. Costs no AI credits. Pixabay receives the search words and the key, never a customer's data.
SITE_IMPORT_BOT_INFO_URLOptional (console)RuntimeThe page the site-import crawler (AglynBot, rolling out) names in its user agent, AglynBot/1.0 (+<this address>), for a site owner who wants to know what read their site. Point it at your own page about your crawler; the default is Aglyn's Bring my site guide.
SITE_IMPORT_EDGE_TOKENOptional (console)RuntimeA secret of at least 32 characters from A-Z a-z 0-9 _ - (openssl rand -hex 32) that the site-import crawler sends as x-aglyn-import, so a bot-protection rule in front of YOUR tenant can let AglynBot read the sites you host: match this header's value AND a user agent starting AglynBot/, both in one rule. It is sent only to a name your deployment serves: a site's subdomain under your tenant domain, or a site's connected custom domain that your domain provider reports as serving right now (a provider with no status to report never qualifies). Never to a third-party site, and never on a redirect to another host. Unset, or shorter than 32 characters, the header is never sent. Change it in the console and in the rule together.
ASSIST_MODELOptionalRuntimeThe assistant's own override, above AI_DEFAULT_MODEL for the chat door alone — the incident-response lever the assistant has always honored.
ASSIST_FREE_DAILY_LIMITOptionalRuntimeMessages per free workspace per UTC day. Default 10.
ASSIST_ENTITLED_MONTHLY_LIMITOptionalRuntimeMessages per entitled workspace per month. Default 1000.
AI_FREE_DAILY_REQUESTSOptionalRuntimeAI requests one account may make per UTC day across all the Free workspaces it owns, counted at every AI door. Default 30. 0 means no free requests; junk and empty values take the default.
AI_FREE_DAILY_PLATFORM_CEILING_USDOptionalRuntimeCeiling on one UTC day of Free-tier provider spend across the whole deployment. At 80% staff are emailed; at 100% every Free workspace is refused AI generation until the day rolls, and paid workspaces are unaffected. Default 25. Empty, zero, negative and junk values take the default. There is no off value, so set a figure you are willing to spend. The email goes to the staff alert inbox (STAFF_ALERT_EMAIL, with its fallbacks); the pause applies regardless.
AI_FREE_MIN_ACCOUNT_AGE_HOURSOptionalRuntimeHours an account must exist before a Free workspace it belongs to may generate with AI. Paid workspaces never check it. Default 0, which turns the check off. Set a number of hours for a cooling-off period; junk and empty values take the default.
ASSIST_ORG_MONTHLY_COGS_LIMIT_USDOptionalRuntimeDollar ceiling per workspace per month, measured against metered cost rather than an assumed cost per message. Default 40. The literal word off removes the ceiling. Junk, empty, zero and negative values all read as unconfigured and take the default, so a typo can neither open the ceiling nor close it to $0.
ASSIST_ORG_MONTHLY_COGS_ALERT_USDOptionalRuntimeDollar figure at which a workspace's spend raises a staff margin alarm, below the hard ceiling. Default 25. Delivery needs STAFF_ALERT_EMAIL and USAGE_EMAIL_FROM.
AI_FREE_MIN_ACCOUNT_AGE_HOURSOptionalRuntimeHow old an account must be, read off its Auth record, before a Free workspace it belongs to may generate. Default 0 (off). Junk and an empty value take the default.
AI_FREE_DAILY_REQUESTSOptionalRuntimeFree requests one account may make per UTC day across its workspaces, counted at every reservation at either door. Default 30.
AI_FREE_DAILY_PLATFORM_CEILING_USDOptionalRuntimeThe platform-wide ceiling on one UTC day of Free-tier provider spend. Default 25. At 80% staff are mailed (STAFF_ALERT_EMAIL); at the ceiling every Free workspace is refused generation until the UTC day rolls, and paid workspaces are unaffected. There is no off: a deployment that wants no ceiling sets a figure it is content to spend.

Message counting happens in a transaction before the model is called, so a refused request spends nothing. A workspace at the ceiling is refused rather than quietly downgraded to a cheaper model.


Video delivery​

Optional. Without these, a video in the media library serves from this install's own media route, as it always has. With them, and the release_video_delivery flag on for a workspace (off by default in Remote Config), each video is copied to an object store and served from it: the media route and the gated-video stream answer with a short-lived signed redirect, and the store's edge sends the bytes and their ranges. The video-delivery plugin ships the adapter for Cloudflare R2 behind a Worker; the Worker's source and its wrangler.jsonc are in libs/plugins/video-delivery.

Your own bucket keeps every video either way, and storage is counted once. Cloudflare receives the videos and, while it serves them, your viewers' IP addresses and user agents, so list it wherever you name the vendors your install uses before you turn the flag on.

VariableNeedWhenValue
R2_ACCOUNT_IDFeature (console)RuntimeThe Cloudflare account that owns the bucket: 32 hex characters, shown on the account's R2 overview. Only the console writes copies, so only the console needs the four R2_* values.
R2_ACCESS_KEY_IDFeature (console)RuntimeThe access key id of an R2 API token scoped to Object Read & Write on the one bucket.
R2_SECRET_ACCESS_KEYFeature (console)RuntimeThat token's secret access key.
R2_VIDEO_BUCKETFeature (console)RuntimeThe bucket's name. Keep the bucket private, with no public r2.dev URL: the Worker reads it through its binding.
MEDIA_VIDEO_DELIVERY_HOSTFeatureRuntimeThe Worker's hostname, such as video.<your-subdomain>.workers.dev. A full origin is accepted for local testing with wrangler dev (http://localhost:8787); plain HTTP is refused anywhere else. Console and tenant.
MEDIA_VIDEO_DELIVERY_SECRETFeatureRuntimeSigns every delivery URL. At least 32 characters (openssl rand -hex 32); a shorter value reads as unset. Console and tenant, and the same value as the Worker's own MEDIA_VIDEO_DELIVERY_SECRET secret. It is deliberately not TOKEN_SIGNING_SECRET. Rotating it breaks every delivery URL already handed out, so a viewer mid-film reloads the page.

Delivery needs only the last two, so a workspace's videos redirect from the tenant app with no storage credentials there. A video is redirected only once a copy made from its current bytes exists; until then, and whenever the flag is off, it serves from this install as before.

Published pages admit MEDIA_VIDEO_DELIVERY_HOST in their media-src policy by themselves, read from the same variable, so no site owner approves it in the Security tab. A value the platform would not mint a URL on is left out of the policy as well.


Scheduled jobs​

Three separate mechanisms, and missing any of them is quiet rather than loud.

VariableNeedWhenValue
CRON_SECRETFeatureRuntimeShared secret every scheduled route checks, accepted as Authorization: Bearer <secret> or x-cron-secret: <secret>. Unset, every one of those routes refuses. openssl rand -hex 32.
AGLYN_JOB_RUNNER_URLFeature (cloud functions)RuntimeThe tenant origin the every-minute beat POSTs: https://sites.example.com/api/plugins/run-jobs. No default, deliberately.
AGLYN_CONSOLE_URLFeature (cloud functions)RuntimeThe origin that serves your console — never one that redirects to it, because a redirect drops the POST body and the x-cron-secret header. It drives the fifteen-minute console sweeps and the every-minute AI jobs beat. No default, deliberately.
AGLYN_ALLOW_UNCONFIGURED_FUNCTIONSOptionalDeploySet to 1 to let firebase deploy --only functions proceed when no dotenv in cloud/functions sets AGLYN_CONSOLE_URL and AGLYN_JOB_RUNNER_URL. Unset — the default — the predeploy check refuses, because a deploy without them replaces each function's env and its scheduled jobs then skip every run. Set it only for a deploy that intentionally carries no scheduled jobs.
CRM_DIGEST_TIME_ZONEOptionalRuntimeThe zone /api/crm/daily-digest reads "today" in when it counts overdue and due-today tasks. Default America/Chicago, which is why the schedule below fires at 13:00 UTC; an IANA name (Europe/London). A value the runtime does not know falls back to the default with a warning. Schedule the route for 08:00 in whatever zone you set.

What CRON_SECRET being unset silently switches off:

  • Scheduled campaign sends. You can schedule a campaign in the console, watch it be accepted, and watch its send time pass with nothing delivered and no error anywhere.
  • Audit archival, so the audit collection grows past its retention window and the retention promise goes quietly unkept.
  • Erasure runs, so personal data a customer asked you to delete is still there while the clock on that request runs.
  • Metered usage roll-up, so nothing is metered into Stripe and the monthly usage document is never written — which in turn means every usage budget is structurally unable to fire and the billing card permanently reads that the month has not been totalled.
  • Usage budget alerts and the monthly usage summary email.
  • Booking reminders, abandoned-cart mail and restock mail.
  • The weekly Firestore export, which is the restore point independent of managed backups.
  • Orphaned plugin-artifact reaping, which then accumulates unbounded.
  • Pending custom-domain completion, so a domain whose DNS has settled stays dark until someone presses Re-attach by hand.

The schedule Aglyn runs, as a starting point for your own scheduler. Times are UTC; every entry is an authenticated POST to the path shown on your console origin, except the last, which targets your tenant origin.

CronPathWhat it does
* * * * *tenant /api/plugins/run-jobsScheduled publishing, booking-hold expiry
*/15 * * * */api/campaigns/process-scheduledScheduled campaign sends
*/15 * * * */api/admin/finish-domain-attachmentsCompletes custom domains once DNS settles
*/5 * * * */api/admin/render-monitorFetches pages no cache can answer from each watched published site and alerts the operator when one stops rendering (Operator alerts)
*/15 * * * */api/admin/operator-alerts/tickAsks every health check so a red one alerts the operator; flags support tickets past their response time; sends the daily operator digest (Operator alerts)
0 2 * * */api/billing/report-usageMeters the closed month into Stripe
0 3 * * */api/admin/audit-archiveArchives audit rows
0 4 * * */api/admin/run-erasuresExecutes due erasure requests
0 7 * * */api/billing/report-usage?month=currentCurrent-month usage
0 8 * * */api/billing/usage-alertsBudget warnings, auto-lock sweep
0 * 1-2 * */api/billing/usage-emailMonthly usage summaries
0 13 * * */api/crm/daily-digestDaily CRM digest — 08:00 in CRM_DIGEST_TIME_ZONE; move the hour with the zone
0 * * * */api/crm/task-remindersCRM task reminders — each open task's reminder, within the hour after its time; the message reads the time in CRM_DIGEST_TIME_ZONE
0 5 * * 1/api/admin/firestore-exportWeekly export
30 5 * * 1/api/admin/reap-plugin-artifactsOrphaned artifact reaping
0 6 * * 1/api/admin/reverify-plugin-versionsRe-checks published plugin verdicts
30 6 * * 1/api/admin/backfill-scopeScope drift backfill
30 7 * * 1/api/admin/reverify-sso-domainsRe-verifies SSO domain ownership
Anything hourly or slower breaks a promise the product makes

The campaign composer accepts a send time down to the minute, so a scheduler that only runs hourly turns "send at 09:05" into "send some time after 10:00". /api/health/crons is the check that notices; it reds a fifteen-minute job after roughly three missed fires, and a daily one after somewhere between six and thirty hours of silence. Nothing polls that endpoint for you — point your own uptime monitor at it, or the only place a silent job shows up is a page nobody opens.

The two cloud/functions URLs have no default because they used to have one: it pointed at a specific published site, so a deployment that missed the variable POSTed to a stranger every minute carrying its own PLUGIN_JOBS_SECRET while none of its own jobs ran. Unset, each beat now refuses to fire and logs the variable name once per tick.

Both live in a dotenv file under cloud/functions/ — copy cloud/functions/.env.example to .env.<your-project-id> before your first firebase deploy --only functions. The Firebase CLI writes whatever that file contains onto the deployed service, so a missing file does not mean "keep the current values", it means "deploy with none".

CRON_SECRET and PLUGIN_JOBS_SECRET reach the function through Secret Manager (firebase functions:secrets:set CRON_SECRET), not through .env.selfhost, and must equal the console's and the tenant's respectively.

Never let two schedulers run the same job

report-usage meters a closed month into Stripe. A day on which two runners both fired it is a day your customers were billed twice.


Plugins and the sandbox​

VariableNeedWhenValue
PLUGIN_ARTIFACTS_BUCKETFeatureRuntimeThe separate GCS bucket holding executable plugin bundles — the code refuses to store executable code in the app bucket. Unset, plugin publishing and artifact serving both answer 501. A bare bucket name. Note the bucket is invisible to the Firebase console; manage it in the Google Cloud console.
PLUGIN_ARTIFACTS_BASEOptionalRuntimeOrigin the server-side remote-bundle loader fetches from. Falls back to NEXT_PUBLIC_PLUGIN_ORIGIN. Only relevant with remote server bundles enabled.
NEXT_PUBLIC_PLUGIN_ORIGINFeatureBuildFull origin the plugin sandbox iframe and bundles are served from, and the entry added to the CSP frame-src. Must be a different origin from the app — the cross-origin boundary is the sandbox. Unset, realm plugins do not load and installed executable plugins render as nothing, because the CSP has no entry for them.
PLUGIN_TRUST_PRIVATE_KEYFeatureRuntimeSigns realm trust grants. Base64 PKCS8 DER Ed25519, generated by tools/scripts/generate-plugin-trust-key.mjs. Unset, the grant action answers 501. Console only — never deploy it to a tenant runtime.
PLUGIN_TRUST_PUBLIC_KEYFeatureRuntimeThe server-side verification key, base64 raw Ed25519. Required with remote server bundles enabled; unset there, no bundle loads at all — it fails closed rather than degrading to a hash check.
NEXT_PUBLIC_PLUGIN_TRUST_PUBLIC_KEYFeatureBuildThe same public key again, for browser-side verification of realm bundles. A mismatch with the server-side name refuses every bundle.
PLUGIN_JOBS_SECRETFeatureRuntimeSee Secrets.
PLUGIN_CONNECTIONS_TOKEN_KEYFeatureRuntime32 random bytes, base64 — openssl rand -base64 32. Seals every marketplace plugin connection's secrets with AES-256-GCM: each publisher's OAuth app client id and secret, and each site's refresh and access tokens. To rotate, put the new key first and keep the old one after a comma (NEW,OLD): the first key seals, every key listed opens. Console only. Unset, no plugin can be connected and its connect routes answer 503. Losing the key loses every connection: each publisher sets their OAuth app again and each site connects again.

Development-only, and never to be set on a real deployment: NEXT_PUBLIC_PLUGIN_DEV (exactly enabled, and hard-gated off when NODE_ENV=production), NEXT_PUBLIC_PLUGIN_DEV_BUNDLES (comma-separated pluginId=url pairs whose hostnames must be localhost or 127.0.0.1), PLUGIN_REMOTE_SERVER (exactly enabled — the master switch for bundles that register API routes in-process, the highest-blast-radius switch in the platform), and PLUGIN_REMOTE_SERVER_BUNDLES (a comma-separated listingId@version allowlist; nothing loads implicitly from installs, so an empty list loads nothing even with the switch on).

The sandbox loader service​

If you deploy tools/plugin-loader/origin — the small separate service that serves the sandbox iframe on its own origin — it reads two variables from its own environment, not from the console container, and they are not NEXT_PUBLIC_* because it is not a Next app:

VariableNeedWhenValue
PLUGIN_LOADER_CONSOLE_URLRequired (loader)RuntimeYour console's origin, scheme and host, no path.
PLUGIN_LOADER_TENANT_DOMAINRequired (loader)RuntimeThe apex your published sites hang off. *.<this> is what may frame the sandbox.

Unset, the loader points at Aglyn's console: frame-ancestors would name only Aglyn's hosts, so your console could never frame your sandbox — a blank iframe the browser blocks — while its two manifest lookups would arrive at Aglyn's marketplace API carrying your listing and host ids and return 404s that silently strip every plugin's declared network capability.

Sales channels: pushing products to Google and Meta​

Every store's product feeds work with nothing set: Google, Meta, TikTok, Pinterest, Snapchat and Microsoft fetch them from the store's own domain. The variables below add the optional API connection on the Google and Meta channel cards, which sends products straight to the merchant's own Merchant Center account or Meta catalog when a member clicks Sync now. A provider is offered only when its two variables and the token key are all set; until then its Connect button and its routes do not exist. All six are read by the console only.

VariableNeedWhenValue
GOOGLE_MERCHANT_CLIENT_IDFeatureRuntimeThe client id of a Google Cloud OAuth client of type Web application, in a project with the Merchant API enabled. Add one authorized redirect URI: {NEXT_PUBLIC_CONSOLE_URL}/api/sales-channels/connect/callback. The content scope it asks for needs Google's consent-screen verification before anyone outside the project can grant it. Unset, the Google card shows the feed alone.
GOOGLE_MERCHANT_CLIENT_SECRETFeatureRuntimeThat client's secret. Unset, the same as the id.
META_CATALOG_APP_IDFeatureRuntimeThe app id of a Meta app with Facebook Login whose valid OAuth redirect URIs include {NEXT_PUBLIC_CONSOLE_URL}/api/sales-channels/connect/callback. It asks for catalog_management and business_management, which need App Review and business verification before anyone outside the app's roles can grant them. Unset, the Meta card shows the feed alone.
META_CATALOG_APP_SECRETFeatureRuntimeThat app's secret. Also signs every Graph call (appsecret_proof). Unset, the same as the id.
META_GRAPH_API_VERSIONOptionalRuntimeThe Graph API version, vNN.N. Default v26.0; a value in any other shape is ignored.
SALES_CHANNELS_TOKEN_KEYFeatureRuntime32 random bytes, base64 (openssl rand -base64 32): seals every stored Google refresh token and Meta user token. A comma-separated list rotates — the first key seals, the rest only open. Unset or unusable, neither provider is offered. Losing it means every merchant reconnects.

The connect also signs its OAuth state with TOKEN_SIGNING_SECRET (see Secrets); without it, Connect answers 503.


Who runs this install​

The reasoning is in Self-hosting → Who runs this install. The reference rows:

VariableNeedWhenValue
NEXT_PUBLIC_OPERATOR_NAMESet itBuildYour legal or trading name. Printed on the public abuse intake, the §512 counter-notice intake, the lockdown 503, the media quarantine notice and the sanctions 451. There is no fallback to Aglyn's name; unset renders an explicit "not configured".
NEXT_PUBLIC_OPERATOR_SUPPORT_EMAILSet itBuildThe address those same pages tell people to write to.
NEXT_PUBLIC_OPERATOR_LEGAL_EMAILOptionalBuildA separate mailbox for legal and copyright notices. Defaults to the support address, so one mailbox is a complete configuration.
NEXT_PUBLIC_OPERATOR_LEGAL_ORIGINOptionalBuildWhere your own terms, privacy and DMCA pages live, no trailing slash. The signup clickwrap links here, and it governs which origin counts as a published legal document.
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_NAMEOptionalBuildDesignated agent's name. Both this and the address must be set before the block renders at all — a mailbox with no legal person or no physical address behind it is not a designation.
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_ADDRESSOptionalBuildPhysical address.
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_EMAILOptionalBuildAgent email. Omitting it leaves that line out rather than inventing one.
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_PHONEOptionalBuildAgent phone. §512(c)(2) enumerates all four.
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_REGISTEREDOptionalBuildExactly true, and only if you have actually registered the agent with the U.S. Copyright Office. Naming an agent does not set it and nothing infers it.
NEXT_PUBLIC_OPERATOR_LEGAL_ORIGIN follows your origin, not your publication state

The list of paths treated as published legal documents is committed in the repository. Point the origin at yours and the marketplace publisher-agreement gate answers "published" for <your-origin>/legal/marketplace-publisher-agreement whether or not that page exists. Publish it at that path before you enable marketplace publishing.

Separately, the acceptance record is still pinned to Aglyn's document snapshots — version, sha256 and byte count. Your user follows a link to your terms and the row written into your Firestore names our bytes. Treat the recorded evidence as unusable and rely on your own acceptance flow if you need one that stands up.

Renaming the product​

VariableNeedWhenValue
NEXT_PUBLIC_PLATFORM_BRAND_NAMEOptionalBuildWhat this deployment calls itself: browser-tab titles, the installable app on a home screen, the relying-party name the OS shows when a user saves a passkey, transactional email, and the generator / x-powered-by fingerprint on every published site. Default Aglyn. Setting it also drops the Aglyn trademark line from the console footer — the source is Apache-2.0 and yours, the names are not.
NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAMEOptionalBuildThe legal entity for copyright lines. Defaults to <brand> LLC, which is a US company form — set it explicitly if you are not one.
NEXT_PUBLIC_PLATFORM_SUPPORT_URLOptionalBuildWhere "need help?" links point. Falls back to NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL as a mailto:, then to your console's own support page, NEXT_PUBLIC_CONSOLE_URL + /support; never to Aglyn's.
NEXT_PUBLIC_PLATFORM_HOME_URLOptionalBuildDestination of the "Made with …" badge on published free-tier sites, and of the logo at the top of your system email. Unset on a renamed brand, the badge renders as plain text with no link, and the email logo links nowhere.
NEXT_PUBLIC_PLATFORM_MARK_URLOptionalBuildThe square logo mark in that badge — a site-relative path is preferable, so it resolves on custom domains too. It sits on a dark pill, so supply a light variant. Unset on a renamed brand, the badge is text only.
NEXT_PUBLIC_PLATFORM_EMAIL_LOGO_URLOptionalBuildThe logo in the header of the system email your deployment sends under its own brand: password resets, invites, security alerts, the monthly usage summary. It must be an absolute https:// URL, because a mail client has no site to resolve a path against; any other value is ignored. On the Aglyn brand it defaults to the Aglyn wordmark. Unset on a renamed brand, the header is your product name in bold text. Mail to a white-label organization's people never carries it: that mail wears the organization's own email logo.
NEXT_PUBLIC_PLATFORM_POSTAL_ADDRESSOptionalBuildThe postal address in that email's footer, printed after © <year> <legal name>. Line breaks become commas, since the footer prints it on one line. On the Aglyn brand it defaults to the address of Aglyn's registered agent. Unset on a renamed brand, the footer prints the legal name alone — never Aglyn's address. White-label mail carries no legal line at all.

Brand images are not environment variables: the favicon, app icons and social card live under apps/console/public/_static/images/brand and apps/tenant/public/_static/images/brand. Replace the files in your Docker build context.


Sales tax, and what to do if you are not in Texas​

The three variables below are the BOOTSTRAP. The control is in the console.

Where you file and the numbers you file under live in Staff → Platform settings → Sales tax filing, where changing them needs the super staff role and no deploy. Registering in a new state is an operator action, so it must not require an environment edit and a release.

AGLYN_TAX_JURISDICTION, AGLYN_TAX_REGISTRATION_ID and AGLYN_TAX_FILING_ID are what a fresh install runs on before anybody opens that page: they fill in every field the console has not stored. Anything stored in the console wins, and the card names the layer each value came from — so a variable you set and then override is listed by name as not in force rather than quietly ignored. Clearing the stored record in the console hands these their layer back.

Unset, and with nothing stored, the jurisdiction is US-TX and the identifiers are absent, so both surfaces read NOT CONFIGURED rather than printing anyone else's numbers.

Texas is the one jurisdiction with a form this software knows. Everywhere else gets a return breakdown — what was collected, and where — labeled as raw material for filing by hand rather than as a return.

Tax collection and tax filing are different things in this product, and only one of them is portable.

Collection is a real feature, and jurisdiction-neutral​

Storefront tax is configured per store, in the console, not by environment variable, and there is nothing Texas-specific about it:

  • Rates are { country, state?, pct } entries. country is any ISO 3166-1 alpha-2 code and state any subdivision; the most specific match wins, and a country-only rate covers the whole country.
  • Tax-inclusive pricing is supported, which is what VAT- and GST-style pricing needs.
  • Modes are manual, stripe or none. Under stripe, Stripe Tax computes against whatever registrations your own Stripe account holds, and the tax lands in your balance.
  • An unset mode refuses the sale rather than silently zero-rating it.
  • The merchant-facing tax summary is gated on host and organization membership, not on staff, and contains no jurisdiction assumption.

Your own platform billing behaves the same way: subscription and add-on checkouts enable Stripe automatic tax, so it too is computed against your registrations.

Filing follows the jurisdiction you configure​

/admin/tax-return is behind the staff claim, and on your own deployment you control your own claims — so you will see it. What you get is:

  • Your own numbers. It reads your Firestore: your platform revenue, your storefront tax collected, your marketplace purchases. Nothing phones home, and the by-jurisdiction breakdown underneath is genuinely useful.
  • On your own jurisdiction's lines. The filing figures read the bucket named by the configured jurisdiction, and the page heading, the figures card and the export all name it. A code that matches no bucket makes every figure read 0.00, so it is refused at the console's own input and, for a code that arrived through the environment where nothing validates it, raised on the return as a blocking finding rather than filed as a quiet zero.
  • As a form only where a form is known. Texas gets Form 01-114's own lines and a Webfile-shaped export. Every other jurisdiction gets the period, the gross, the taxable base and the tax collected, split by the destination region the tax was computed for, under a banner saying it is for manual filing and is not a submittable return. There is no second state's form, no VAT or GST return, and no tax engine beyond Stripe Tax.
  • From the period your obligation began. The picker floors at Earliest filable period in Platform settings, which defaults to September 2026 — Aglyn's own first taxable month. Set your own and the menu offers your periods; the page reads the setting before it builds the menu, so the floor is right on first paint.
  • With liability sentences written for a marketplace facilitator, sourced from Aglyn's terms and its reading of its own position. The mechanics are the same wherever this runs; the conclusions are not advice about your registration.

Setting it up​

Set these to bring a fresh install up already filing correctly, or leave them unset and configure it in Staff → Platform settings → Sales tax filing, which is the same three values with an audit trail and no redeploy. Everything below is the bootstrap layer: a value stored in the console outranks it.

VariableNeedWhenValue
AGLYN_TAX_JURISDICTIONBootstrapRuntimeWhere this deployment files until the console stores a jurisdiction, as an ISO 3166-1 alpha-2 country with an optional subdivision — US-TX, US-CA, GB, DE. It is looked up as a key in the report's own buckets, which are COUNTRY-STATE where an address carries a state and COUNTRY where it does not, so write it the same way. Default US-TX.
AGLYN_TAX_REGISTRATION_IDBootstrapRuntimeThe number the authority knows you by — a Texas taxpayer number, a seller's permit, a VAT number. Printed on the return page and in the exported working papers; the console shows only a last four. Server-only: never prefix it with NEXT_PUBLIC_.
AGLYN_TAX_FILING_IDBootstrapRuntimeThe filing-portal credential where one exists, such as the Texas Webfile number — which the Comptroller's eSystems treats as an authentication code, so it is server-only for the same reason and more urgently. Required alongside the registration id for US-TX; optional everywhere else, because most authorities issue one number. The console never shows it back at all.
TX_WEBFILE_NUMBERDeprecatedRuntimeThe former name of AGLYN_TAX_FILING_ID. Still read when the environment's jurisdiction is US-TX, so an existing deployment is not unset by the rename.
TX_TAXPAYER_NUMBERDeprecatedRuntimeThe former name of AGLYN_TAX_REGISTRATION_ID, read under the same condition.

Both identifier variables are only in force while the jurisdiction in force is the one they were configured for. Change the jurisdiction in the console and they stop applying — one authority's registration number is never filed under another, so the return reads NOT CONFIGURED until the new authority's numbers are entered.

With none of them set and nothing stored, the page says so and names what to set, and the export writes NOT CONFIGURED … rather than a blank cell someone files from — which is the correct output for a deployment that does not file at all.

Two things this page cannot do for you, whatever you set: it does not decide whether you are a marketplace facilitator where you operate, and it does not know your authority's form. Configure collection properly in each store's tax settings, register where you owe, and read the working papers rather than transcribing a screen.


Caching, ISR, and running more than one replica​

Single-container is the supported shape.

Published pages are ISR-cached with a 10-minute window, site documents sit behind a one-hour render cache, and no shared cache handler is configured — so every replica keeps its own on-disk cache. Publishing POSTs /api/revalidate on the tenant, which busts the one replica that answered. The console reports an instant publish while every other replica keeps serving the old page for up to 10 minutes and the old documents for up to an hour.

If you scale out, either put a sticky-session proxy in front or fan the revalidate POST out to every replica yourself. Nothing errors in either case, which is what makes it worth knowing.

VariableNeedWhenValue
REVALIDATE_SECRETSet itRuntimeSee Secrets. Without it, no publish ever busts any cache. Set the same value on both containers: the console will not send the request without it, and the tenant endpoint answers 503 naming the variable rather than quietly doing nothing.
Your cache rules set your real takedown window

Several endpoints send s-maxage with little or no browser max-age, on the assumption that a shared cache honors it — the per-host manifest and robots.txt at 5 minutes to an hour, sitemaps and feeds at 5 minutes, page-node and commerce endpoints at 60–300 seconds. Behind a proxy that caches nothing they are simply recomputed per request: correct, slower, and several of them hit Firestore each time.

The consequence that is not about performance is media takedown. When staff disable a file, the reach of that action is derived from the media CDN's own cache directives, and the console prints those numbers to the person clicking the button: the origin stops serving it within about 15 seconds, a browser that already has it keeps it for up to 60 seconds, and a shared cache keeps it for up to one hour — because the media route sends s-maxage=3600. Immutable content-addressed URLs and copies already downloaded are not reachable at all, and the product says so rather than implying a number.

Those figures describe Aglyn's cache, not yours. If your proxy or CDN caches media for longer than an hour, that is how long a removed asset can still be served after you remove it — and that is the window your abuse and §512 responses are effectively promising. Decide it deliberately, and if you change it, change what you tell people.


Domains: how a hostname becomes reachable​

Registering a name used to be a call to Vercel's API, so a Docker install had no way to make a workspace subdomain resolve at all — it advertised a URL and skipped the registration. The hosting vendor is a driver now, and you pick one.

This section is the per-variable reference. Domain providers is the runbook beside it: how to choose a driver, the wildcard path end to end with worked Caddy and nginx configurations, a complete worked webhook endpoint, what each status state means for you, and how to migrate from one driver to another.

VariableNeedWhenValue
AGLYN_DOMAIN_PROVIDEROptionalRuntimevercel, wildcard, webhook or none. Unset, the deployment detects: vercel when VERCEL_TOKEN is present, none otherwise. An unrecognized value logs [domain-provider] unknown AGLYN_DOMAIN_PROVIDER and falls back to detection. An explicit value always wins, none included — switching a driver off while its credentials are still in the environment means it.
DriverFor
vercelAglyn's own hosting, and anyone else on Vercel.
wildcardThe ordinary Docker answer. A container behind a proxy that already answers for *.example.com.
webhookAnything else — Caddy, Traefik, cert-manager, a registrar's API, a shell script.
noneNames are handled entirely outside the product. Not an error state, and not something logged forever.
Detection never picks wildcard, and that is deliberate

The wildcard driver reports a name as serving without checking anything — there is nothing to check, because the operator's DNS record and certificate either cover the apex or they do not. Inferring it from an apex somebody merely configured would have the console show a green chip beside an address that resolves nowhere. Claiming to serve a name is your assertion to make, so it takes your explicit setting.

Every driver is bound by the same contract, which is worth knowing before you write one: it never throws (an exception here would fail an organization creation over a DNS API), attach on an already-attached name is success, and skipped means "not my job" rather than "it went wrong". A status of unknown is treated as serving, because a probe that could not answer is not evidence of a problem. Every call is bounded at 5 seconds.

wildcard​

Point one wildcard DNS record at your proxy, hold one wildcard certificate, and every workspace subdomain and platform site subdomain resolves the moment it is created. There is no API to call because there is nothing to register.

VariableNeedWhenValue
NEXT_PUBLIC_TENANT_APEXDo not setRuntimeRead in exactly one place — the memoization key that decides whether the selected driver may be reused — and by nothing else in the product. Setting it changes no behavior. The wildcard suffix default comes from NEXT_PUBLIC_TENANT_DOMAIN, which is the variable that key was meant to watch, so the memo does not notice a change to it. That only shows up where the environment changes inside one process, which a container's does not.
AGLYN_DOMAIN_WILDCARD_SUFFIXESOptionalRuntimeComma-separated apexes your DNS and certificate already cover. A leading *. is tolerated and stripped: *.example.com, sites.example.com. Defaults to NEXT_PUBLIC_WORKSPACE_DOMAIN plus NEXT_PUBLIC_TENANT_DOMAIN, which are the names the product itself hands out. An explicit list replaces those defaults rather than adding to them — if you set it, list every apex you serve.
A wildcard covers exactly one label

*.example.com serves a.example.com. It does not serve a.b.example.com, and it does not serve example.com itself — and a certificate issued for *.example.com covers exactly the same set. The driver matches that rule rather than "ends with the suffix", because the looser test would report a two-label name as serving and the visitor would meet a TLS error before you did.

A name outside your suffixes — a customer's own shop.acme.com — is not covered by anybody's wildcard. This driver has no way to add it and no way to see it, so it says so: skipped on attach, unknown on status. It never claims such a name is serving, and it never calls it broken either, because an operator who added a vhost by hand has a working domain the driver cannot see. If you want customer domains registered automatically, use webhook.

Detaching is honest in the same way: a wildcard cannot un-serve one of its names, so a removed workspace subdomain keeps resolving until the app itself stops recognizing the slug.

webhook​

Aglyn POSTs the three operations to an endpoint you run. What is behind it is your business.

VariableNeedWhenValue
AGLYN_DOMAIN_WEBHOOK_URLRequired (webhook)RuntimeThe endpoint. Ignored by every other driver.
AGLYN_DOMAIN_WEBHOOK_TOKENOptionalRuntimeSent as Authorization: Bearer …. Omitted entirely when unset.
Put this endpoint on your own network

The request carries the bearer token and the names of your customers' domains, and the reply decides whether the console tells someone their site is live.

The request. POST to your URL, Content-Type: application/json:

{
"action": "attach",
"scope": "tenant",
"domain": "shop.acme.com",
"redirectTo": "acme.com"
}
  • action is attach, detach or status.
  • scope says which app should answer for the name: console for the admin app and its workspace subdomains, tenant for published sites.
  • redirectTo appears only when the name should forward rather than serve — how a renamed workspace keeps its old slug working. It is always a bare hostname; Aglyn normalizes it before the wire, and refuses to send anything that is not one. If your layer cannot express a redirect, answer skipped rather than attaching a serving name: a serving twin is a second live copy of the console on a name that was supposed to forward.

The reply. 200, with an outcome for attach and detach:

{ "outcome": "attached", "detail": "added to caddy" }

outcome is one of attached, detached, already-exists, not-found, skipped or failed. already-exists is success — Aglyn re-attaches on reconcile passes and expects idempotence. detail is optional, reaches operator logs, and is truncated at 200 characters.

For status, a state:

{
"state": "ownership-pending",
"verification": [
{ "type": "TXT", "domain": "_acme-challenge.shop.acme.com", "value": "…" }
],
"conflicts": []
}

state is one of serving, certificate-pending, ownership-pending, dns-misconfigured, not-attached, skipped or unknown. verification carries the records a customer must add at their registrar and is shown to them. conflicts lists records answering for the name that are not yours — a non-empty list on an otherwise-serving domain is the case where it resolves correctly only some of the time.

Note certificate-pending is not treated as serving: the name is routed but no certificate exists yet, so a visitor meets a TLS error. Do not report serving until the certificate is issued.

A typo must never read as success

An unrecognized outcome is taken as failed, and an unrecognized state as unknown — both logged with what arrived. An endpoint that answers {"outcome":"attach"} has registered nothing, and accepting it would leave a workspace advertising a URL that resolves nowhere. A non-200, a timeout or a connection error is failed for attach and detach, and unknown for status — never not-attached, because an endpoint that did not answer is not evidence that the name is missing.

A worked endpoint, driving Caddy's admin API. This is the whole shape; the error handling is yours:

// node server.js — reachable only from the Aglyn containers.
import { createServer } from 'node:http'

const TOKEN = process.env.WEBHOOK_TOKEN
const CADDY = 'http://127.0.0.1:2019'

createServer(async (req, res) => {
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) {
res.writeHead(401).end()
return
}
const body = JSON.parse(await new Response(req).text())
const { action, scope, domain } = body
const upstream = scope === 'console' ? 'console:4200' : 'tenant:4500'
const id = `aglyn-${domain}`

const reply = (payload) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(payload))
}

if (action === 'attach') {
// Caddy provisions the certificate itself once the route exists.
const route = {
'@id': id,
match: [{ host: [domain] }],
handle: [
{
handler: 'reverse_proxy',
upstreams: [{ dial: upstream }],
},
],
}
const put = await fetch(`${CADDY}/id/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(route),
})
if (put.ok) return reply({ outcome: 'already-exists' })
const post = await fetch(`${CADDY}/config/apps/http/servers/srv0/routes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(route),
})
return reply(
post.ok
? { outcome: 'attached' }
: { outcome: 'failed', detail: `caddy ${post.status}` },
)
}

if (action === 'detach') {
const gone = await fetch(`${CADDY}/id/${id}`, { method: 'DELETE' })
return reply(gone.ok ? { outcome: 'detached' } : { outcome: 'not-found' })
}

if (action === 'status') {
const route = await fetch(`${CADDY}/id/${id}`)
if (!route.ok) return reply({ state: 'not-attached' })
// Only call it serving once TLS actually answers — Caddy will have a route
// before it has a certificate, and `certificate-pending` is the state for
// that gap.
const tls = await fetch(`https://${domain}/`, { method: 'HEAD' }).catch(
() => null,
)
return reply({ state: tls ? 'serving' : 'certificate-pending' })
}

reply({ outcome: 'failed', detail: 'unknown action' })
}).listen(9099)

Traefik is the same endpoint writing a dynamic-configuration file its file provider watches, and answering status from whether the router exists and its certificate resolver has issued. cert-manager is the same endpoint creating and reading a Certificate resource.

vercel​

Only this driver reads the Vercel credentials. They are its configuration, not a general requirement of the product.

VariableNeedWhenValue
VERCEL_TOKENRequired (vercel)RuntimeAPI token. Its presence is also what detection keys on when AGLYN_DOMAIN_PROVIDER is unset.
VERCEL_TENANT_PROJECT_IDRequired (vercel)RuntimeThe project the tenant scope attaches to.
VERCEL_CONSOLE_PROJECT_IDRequired (vercel)RuntimeThe project the console scope attaches to — workspace subdomains. A separate project from the one above.
VERCEL_TEAM_IDOptional (vercel)RuntimeTeam scope on those API calls.

Customer custom domains, per driver​

A customer connecting shop.acme.com goes through the same seam. What the console can promise them depends on which driver you chose:

DriverA customer's own domain
webhookRegistered automatically. Your endpoint is asked to attach it, and the wizard reports what your endpoint reports. This is the driver to pick if you sell custom domains.
vercelRegistered automatically, on your Vercel project.
wildcardNot registered. The name is outside your wildcard by definition — no wildcard covers somebody else's apex — so the driver returns skipped and the console answers 501.
noneNot registered; 501.

The 501 reads "Domain attachment is not configured on this deployment (no domain provider — set AGLYN_DOMAIN_PROVIDER)", and the same answer is given whether there is no driver at all or a driver that does not manage this particular name — which is honest, because from the customer's side those are the same situation.

What you see then: the domain is saved, an info toast says "platform attachment pending", and the domain sits in a red alert saying it "is not attached to our hosting platform, so it serves nothing", beside a Retry attachment button that cannot succeed until the deployment can register the name.

The site serves anyway. The Firestore claim on the domain is written before the refusal, and the tenant runtime resolves an unrecognized hostname against that claim. So on wildcard or none:

  1. Point the customer's DNS at your reverse proxy.
  2. Route that hostname to the tenant container by hand.
  3. The site is served.
The canonical redirect stays off, so the site answers on two addresses

The refusal marks the host record as having a pending attachment, and the canonical-domain redirect refuses to fire while that mark is set. So acme.sites.example.com never redirects to acme.com — both serve the same pages, which splits search ranking between them. Clearing that pending mark on the host record restores the redirect. Moving to the webhook driver avoids the situation rather than repairing it.

An attach that a driver accepts is still checked before the customer is told their domain is live: the console asks for the name's status and refuses to show a green chip on certificate-pending, because a routed name with no certificate answers with a TLS error. unknown and skipped statuses do not block an otherwise-successful attach — a status API that could not answer is not evidence of a problem.

Domain verification works everywhere: it requires an exact CNAME match, or an apex address match when the name carries no CNAME at all — which is why AGLYN_TENANT_APEX_ADDRESSES matters.

Upgrade if your image predates the verification fix

There is a soft pass in the verify step that accepts any CNAME, for local development where no DNS points at a tenant edge. It used to be enabled by the absence of a hosting vendor's environment variable — which a container never sets — so it was on in production on every self-host install, and any domain carrying any CNAME to anywhere verified. A user of your platform could claim a domain they do not control. It now keys on NODE_ENV, which both Dockerfiles set to production in the image that actually runs.

Other Vercel variables​

Nothing to set. These exist because Aglyn's own deployment runs on Vercel, and on a container they are absent and the code handles it.

VariableNeedNotes
VERCEL, VERCEL_ENV, VERCEL_REGION, VERCEL_URL, VERCEL_BRANCH_URL, VERCEL_PROJECT_PRODUCTION_URL, VERCEL_GIT_COMMIT_SHAAglyn-onlyInjected by Vercel, never present on a container.
VERCEL_LOG_DRAIN_SECRET, VERCEL_LOG_DRAIN_VERIFYAglyn-onlySignature and ownership-verification values for a Vercel log drain.

Selling domains​

The site's Custom Domain page can sell domains: an owner searches, buys, and the site goes live at the name with no DNS step, because the name is registered in the account that hosts the site. Renewals, refunds and expiry run unattended from the console's plugin cron. Leave either variable unset and the Get a domain card draws nothing, and nothing is sent to a registrar or charged. Set them on the console only.

VariableNeedWhenValue
DOMAINS_REGISTRARFeatureRuntime, consolevercel, the registrar driver to sell through, or unset or none to sell nothing. The Vercel driver uses the same VERCEL_TOKEN and VERCEL_TEAM_ID as the vercel domain provider, so a name lands in the team whose tenant project serves it. A plugin may offer another driver through the domains.registrar service, named here by its id.
STRIPE_PRODUCT_DOMAIN_REGISTRATIONFeatureRuntime, consoleThe Stripe product domain registrations and renewals are billed against; setup-stripe.mjs creates it and prints the id. It carries the tax code automatic tax computes the line from.

Documentation site build​

apps/docs is a standalone Docusaurus package. It is not part of docker compose; build and publish it separately if you want your own documentation site, and skip this section if you do not.

Every value here is read at build time by docusaurus build, from that build's environment. None is NEXT_PUBLIC_* — Docusaurus is not Next.

VariableNeedValue
DOCS_URLOptionalThe canonical origin your docs are served at. Canonical tags and the sitemap are built from it, so leaving it at the default makes your build claim docs.aglyn.com.
DOCS_ORGANIZATION_NAMEOptionalName in the footer copyright line. Default Aglyn LLC, followed by Aglyn's trademark attribution. Setting it replaces the name and drops the attribution.
DOCS_GA_TRACKING_IDOptionalYour own GA4 measurement id, G-XXXXXXXXXX. Blank loads no analytics tag at all.
DOCS_ERROR_BEACON_ENDPOINTOptionalWhere uncaught browser errors POST — e.g. https://console.example.com/api/errors. Blank installs no handlers. If you point it at your own console, set NEXT_PUBLIC_DOCS_ORIGIN on that console to your docs origin so its CORS grant accepts you.
DOCS_STATUS_TARGETSOptionalWhat /status probes, as comma-separated name|label|origin triples: console|Console|https://console.example.com,sites|Sites|https://sites.example.com. Blank probes nothing and says so.
DOCS_META_PIXEL_IDOptionalMeta Pixel id for the docs site — digits only. Loads only for a reader whose consent record grants advertising; see the note below. Blank loads nothing.
DOCS_ADS_CONVERSION_IDOptionalGoogle Ads id, AW- plus digits. Rides the gtag.js the analytics tag already loaded rather than fetching a second copy. Same consent gate. Blank loads nothing.
DOCS_LINKEDIN_PARTNER_IDOptionalLinkedIn Insight Tag partner id — digits only. Same consent gate. Blank loads nothing.
DOCS_GTM_CONTAINER_IDOptionalGoogle Tag Manager container, GTM- plus 5–10 characters. Gated on analytics rather than advertising, matching the analytics tag beside it. What a container loads is decided in Google's UI and is invisible to this codebase. Blank loads nothing.
DOCS_STATUS_FALLBACK_URLOptionalAn independent status page to send readers to when /status itself will not load — /status is served from your own infrastructure, so an outage broad enough to take that down takes the status page with it. A single http(s) URL; anything else is dropped rather than rendered.

Every telemetry-shaped value here is off when unset, and that is the point: unset means nothing is sent anywhere, never that it is sent to Aglyn.

The four advertising values need one more thing said about them. The docs site is a static build with no consent dialog and no region endpoint of its own, so its advertising tags are gated on the consent record the console wrote, carried across on a cookie at the shared registrable domain. If your console is not a sibling hostname of your docs — app.example.com and docs.example.com, say — that cookie never arrives, no reader is ever counted as having granted advertising, and these tags will correctly do nothing at all. Analytics is unaffected; it runs on its own region-conditional default.


Set by the image — do not put these in your env file​

VariableSet toWhy you must not override it
AGLYN_STANDALONEexactly 1, in the runner stage of both DockerfilesThis is what tells the software it is a real deployment rather than a laptop. It gates the whole host-resolution switch, the canonical custom-domain redirect, and the CSP that drops Aglyn's own hostnames from your frame-ancestors. It is kept out of .env.selfhost.example on purpose: compose env_file overrides image ENV, so a line there would be a way to delete it and silently break serving. Only the string 1 counts — true and yes do nothing.
NODE_ENVproductionSeveral safety relaxations key on "not production", the custom-domain verify soft pass among them.
PORT4200 console, 4500 tenantCompose publishes these on loopback.
HOSTNAME0.0.0.0Listens inside the container's namespace.
NEXT_TELEMETRY_DISABLED1No build telemetry leaves your machine.

Stamping which build you are running​

VariableNeedWhenValue
COMMIT_REFOptionalBuildAn identifier for the build. /api/health reports it as commit, and client and server error reports and the staff config report all carry it. Best passed as a build argument, which changes per build: COMMIT_REF=$(git rev-parse HEAD) docker compose up --build. The build argument wins where both are set. Unset, the health body reports commit: null honestly rather than inventing one. The Dockerfiles read it in the build stage, so passing it at docker run is too late.
BUILD_IDOptionalBuildAn explicit build id, checked before COMMIT_REF. Use it if you stamp builds with something of your own.
PACKAGE_VERSIONSet by the buildBuildRead from package.json at build time and reported by /api/health as version. Setting it in the environment does nothing.
AGLYN_REGIONOptionalRuntimeLabels which replica answered, in health reports and rate-limit degradation markers. Checked before VERCEL_REGION, FLY_REGION, AWS_REGION and AWS_DEFAULT_REGION. Unset with none of those present, markers record no region, so a multi-replica operator cannot tell which instance shed load.
NEXT_PUBLIC_DEPLOY_ENVSet by the buildBuildWritten from VERCEL_ENV by each app's config, so it is undefined on a container. Undefined reads as "unknown deployment", which is the self-host default and permits analytics to emit on a production build. Setting it in the environment is overwritten at build.

Development and Aglyn-internal variables​

Read by the code, but not operator configuration. Listed so that finding one in the source does not send you looking for a value to put in it.

VariableWhat it is
AGLYN_TENANT_DEMOThe tenant host id served when the request host is the console apex or localhost:4500. Default demo. Aglyn's own demo deployment.
AGLYN_CANARY_SITE_HOST, AGLYN_CANARY_MARKETING_HOSTWhich hosts the render canary at /api/health/render renders. The site one falls through to AGLYN_TENANT_DEMO then demo, so an install with no demo host gets a canary reporting red on a host that was never meant to exist. The marketing one derives from your workspace domain and grades not-configured — a deliberate failure, not a pass — when it cannot.
AGLYN_TENANT_HOST_ID, AGLYN_TENANT_HOST_HOST, AGLYN_TENANT_HOST_HOSTNAME, AGLYN_TENANT_HOST_URLSingle-host tenant pinning, for a one-site deployment. Not used by the multi-tenant compose shape.
AGLYN_HOST, AGLYN_HOSTNAME, AGLYN_PORT, AGLYN_PROTOCOL, AGLYN_URLBuild-time origin overrides in the shared Next config. Leave at the defaults.
AGLYN_DISABLE_BOOT_WARMUPSkips the tenant's boot warm-up. A debugging aid.
AGLYN_DEBUG_MIDDLEWARESet to exactly 1 to write the tenant middleware's per-request trace (console.debug) to the logs. A debugging aid read at runtime. Unset by default, so the trace stays off and a log drain is not billed for a line per request.
CONSENT_DEV_COUNTRYOn a non-production build only, the country the consent endpoint answers when no geo header is present — the default is US, so the implied-consent path is testable on a laptop. Hard-gated off when NODE_ENV=production, so it can do nothing on a real deployment.
NEXT_PUBLIC_AGLYN_TENANT_LOCAL_ORIGIN, NEXT_PUBLIC_AGLYN_TENANT_PREVIEW_HOSTWhere "Live" links point from a local or preview console. Both default to Aglyn's own addresses, which matters only on a preview build.
NEXT_PUBLIC_ANALYTICS_ALLOW_NONPRODDevelopment only.
NEXT_PUBLIC_PLUGIN_DEV, NEXT_PUBLIC_PLUGIN_DEV_BUNDLES, PLUGIN_DEV_MANIFESTS, PLUGIN_REMOTE_SERVER, PLUGIN_REMOTE_SERVER_BUNDLESLocal plugin development — see Plugins.
FIREBASE_AUTH_EMULATOR_ENABLED, FIREBASE_FIRESTORE_EMULATOR_ENABLED, FIREBASE_DATABASE_EMULATOR_ENABLED, FIREBASE_STORAGE_EMULATOR_ENABLED, FIREBASE_AUTH_EMULATOR_HOST, FIRESTORE_EMULATOR_HOST, FIREBASE_DATABASE_EMULATOR_HOSTFirebase emulator wiring for local development, read by the SDK running in the server. Never set these on a deployment — they point the SDK at an emulator that is not there.
NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST, NEXT_PUBLIC_FIREBASE_DATABASE_EMULATOR_HOSTThe same wiring for the Firebase SDK running in the browser, which cannot read the server-side names above. Each is consulted only when the matching FIREBASE_*_EMULATOR_ENABLED flag is on, and each falls back to its default port — 8082, 9099, 9000 — when unset or malformed, so a stack on the default ports needs none of them. They exist so a second emulator stack can run on its own ports beside the first. One difference from their server-side twins is worth knowing: Next substitutes a NEXT_PUBLIC_ name into the bundle at build time, so a value baked into an image cannot be corrected by the environment at run time.
LINEAR_API_KEY, LINEAR_CUSTOMER_REPORTS_TEAM_ID, LINEAR_CUSTOMER_REPORTS_PROJECT_IDYour own issue tracker for the console's "Report an issue" dialog. See Self-hosting → Customer issue reports.
RECAPTCHA_ADMIN_KEY_NAMEThe reCAPTCHA Enterprise key that customer custom domains are allowlisted on, as projects/{project}/keys/{siteKey}. Its last segment must equal NEXT_PUBLIC_RECAPTCHA_PUBLIC_KEY — naming a different key writes happily and attests nothing. Only relevant if you run App Check with reCAPTCHA Enterprise.
AGLYN_DRIVE_MOUNTA workstation path: the directory containing the shared drives' folders (Platform Docs and its siblings), used by repository checks that cross-reference a repo file against its counterpart on the shared drive — the pricing source of truth, the decision log, the launch runbook, the legal originals, the generated feature matrix. No default, and unset is not an error: each of those checks skips its shared-drive leg and says so, because CI has no such mount and a check that failed without one would be red everywhere except one machine. Nothing here assumes Google Drive — any directory with Platform Docs inside it works. Not read by any app or container.
NEXT_CACHE_MAX_GBA workstation disk guard used by tools/scripts/clean-next.mjs: over this many gigabytes, an app's .next directory is deleted before the dev server starts. Default 10. Not a runtime cache setting and not read by any container.
LOCKDOWN_DRILLEnables the timing-sensitive cases of a lockdown test. Test-only; no runtime effect.
STRIPE_DUNNING_CHECK_KEYA Stripe key for a read-only CI script that compares your live dunning schedule against the committed one. Not read by any app.
E2E_*, WIRE_*, SMOKE_*, DEV_DISK_*, RULES_*, *_CHECK_ACCESS_TOKEN, FIRESTORE_DEPLOY_ACCESS_TOKEN, VERCEL_DEEP_CLONE, DEPLOY_BRANCH, DEPLOY_REMOTE, TYPECHECK_CONCURRENCY, NEXT_ANALYZE_BUNDLERepository tooling: end-to-end tests, smoke runs, CI gates, deploy scripts. Never read by a running container.