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
Next.js replaces the literal text process.env.NAME with its value at build
time. It does that for three groups:
- Anything named
NEXT_PUBLIC_*, in both server and browser code. - Anything listed in a
next.config.jsenvblock —with-aglyn.nextjs.config.js,apps/console/next.config.jsandapps/tenant/next.config.jseach declare one. - 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:
| When | Meaning |
|---|---|
| Runtime | Read on each request or at process start. Change it and restart the container. |
| Build | Frozen into the image. Change it and run docker compose build before up. |
And a Need column:
| Need | Meaning |
|---|---|
| Required | The deployment does not work without it. |
| Feature | One named feature is off without it. The row says which, and what you see. |
| Optional | A default applies. The row gives it. |
| Aglyn-only | Read 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.
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_FIREBASE_PROJECT_ID | Required | Build | The 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_KEY | Required | Build | apiKey. Starts AIza…. |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN | Required | Build | authDomain, e.g. bramble-platform.firebaseapp.com. |
NEXT_PUBLIC_FIREBASE_DATABASE_URL | Required | Build | databaseURL — Realtime Database, not Firestore. Presence and live collaboration use it. |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET | Required | Build | storageBucket, e.g. bramble-platform.firebasestorage.app. Bare bucket name, no gs://. |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID | Required | Build | messagingSenderId — a numeric string. |
NEXT_PUBLIC_FIREBASE_APP_ID | Required | Build | appId, e.g. 1:123456789012:web:abc123def456. |
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID | Optional | Build | measurementId, 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_HOST | Optional | Build | A 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_KEY | Feature | Build | The 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. |
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.
| Variable | Need | When | Value |
|---|---|---|---|
FIREBASE_PRIVATE_KEY | Required | Runtime | The 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_EMAIL | Required | Runtime | The client_email field, e.g. firebase-adminsdk-x1y2z@bramble-platform.iam.gserviceaccount.com. |
FIREBASE_PROJECT_ID | Required (setup scripts) | Runtime | The 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 keydocker 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
| Variable | Need | When | Value |
|---|---|---|---|
FIRESTORE_DATABASE_ID | Optional | Runtime | Points 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_URL | Required (setup scripts) | Runtime | The Realtime Database URL again, for the setup scripts. The apps read NEXT_PUBLIC_FIREBASE_DATABASE_URL. |
FIREBASE_STORAGE_BUCKET | Required (setup scripts) | Runtime | The bucket name again, for the setup scripts. The apps read NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET. |
FIRESTORE_EXPORT_BUCKET | Optional | Runtime | Destination 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_PROJECT | Optional (scripts only) | Runtime | Read 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. |
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.
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_CONSOLE_URL | Required | Build | Full 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_DOMAIN | Required | Build | The 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_DOMAIN | Required | Build | The 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_CNAME | Required | Build | The 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_CNAME | Required | Build | The 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_ADDRESSES | Set it | Build | Comma-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_ADDRESSES | Set it | Runtime | The 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_URL | Optional | Build | Base 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_ORIGIN | Optional | Build | Where 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_URL | Deprecated | Build | The 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_HOST | Optional | Build | Origin 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_HOSTS | Optional | Build | Comma-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:
| Variable | Set to | Note |
|---|---|---|
PORT | 4200 console, 4500 tenant | Compose publishes both on 127.0.0.1. |
HOSTNAME | 0.0.0.0 | Listens 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
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_TRUSTED_PROXY_COUNT | Recommended | Runtime | How 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.
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
ipAddresswritten 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.
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_GEO_COUNTRY_HEADER | Feature | Build | Name of the header carrying ISO 3166-1 alpha-2, lower-cased. Default x-vercel-ip-country. Behind Cloudflare: cf-ipcountry. |
AGLYN_GEO_REGION_HEADER | Feature | Build | Name 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_HEADER | Optional | Build | Name 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:
| Country | Region | City | |
|---|---|---|---|
| 1 | your configured name | your configured name | your configured name |
| 2 | cf-ipcountry | x-appengine-region | cf-ipcity |
| 3 | x-appengine-country | x-client-geo-region | x-appengine-city |
| 4 | x-client-geo-country | cloudfront-viewer-country-region | x-client-geo-city |
| 5 | cloudfront-viewer-country | x-geo-region | cloudfront-viewer-city |
| 6 | fastly-geo-country | x-region-code | x-geo-city |
| 7 | x-geo-country | x-city | |
| 8 | x-country-code | ||
| 9 | x-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:
| Feature | With a country | With no country |
|---|---|---|
| Sanctions / embargo gate — console pages, session minting, org creation | Blocks 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 tenant | Region-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 email | Names city, region and country. | The email says Unknown location, and that string is stored on the device record. |
| Staff breach-notification report | Buckets 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_.
| Variable | Need | When | Value |
|---|---|---|---|
TOKEN_SIGNING_SECRET | Required | Runtime | Signs 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_SECRET | Required | Runtime | Signs 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_SECRET | Set it | Runtime | The 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_SECRET | Feature | Runtime | HMAC 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_SECRET | Feature | Runtime | The 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_SECRET | Feature | Runtime | Authorizes 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_SECRET | Aglyn-only | Runtime | Signature secret for the Vercel log-drain receiver. Fails closed if unset, which is the correct state off Vercel. |
AGLYN_PROBE_TOKEN | Optional | Runtime | Sent 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_BYPASS | Aglyn-only | Runtime | Vercel'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_HOST | Optional (scripts only) | Runtime | Host 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_FORMS | Optional (scripts only) | Runtime | How 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_BASE | Optional (scripts only) | Runtime | Overrides 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_BASE | Optional (scripts only) | Runtime | Overrides 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_TARGET | Set by the platform | Runtime | Set 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_EMAIL | Optional | Runtime | Address 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_PASSWORD | Optional | Runtime | That 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
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_SSO_REQUIRED_DOMAINS | Feature | Runtime | domain=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_ENFORCEMENT | Feature | Runtime | Exactly 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_EMAILS | Feature | Runtime | Addresses 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. |
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
| Variable | Need | When | Value |
|---|---|---|---|
AUTH_ACTION_ALLOWED_ORIGINS | Optional | Runtime | Comma-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_MINUTES | Optional | Build | Idle 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.
| Variable | Need | When | Value |
|---|---|---|---|
STRIPE_SECRET_KEY | Feature | Runtime | sk_live_… or sk_test_…. Enough on its own for storefront commerce checkout. |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Feature | Build | pk_live_… / pk_test_…. Needed for platform plan checkout; the secret key alone is not enough. |
STRIPE_WEBHOOK_SECRET | Feature | Runtime | whsec_… 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_TEST | Optional | Runtime | A 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_SECRET | Feature | Runtime | whsec_… 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_URL | Optional | Runtime | The 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_LIVEMODE | Optional | Runtime | Overrides 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_ENABLED | Optional | Runtime | Exactly 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_ID | Optional | Runtime | The 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. |
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.
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-on | Name pattern |
|---|---|
| Extra manager seat | STRIPE_PRICE_{STARTER|PRO}_EXTRA_SEAT[_YEARLY] |
| Extra members | STRIPE_PRICE_{…}_EXTRA_MEMBER[_YEARLY] |
| Extra datasets | STRIPE_PRICE_{…}_EXTRA_DATASET[_YEARLY] |
| Extra hosts | STRIPE_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
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:
| Variable | Need | When | Value |
|---|---|---|---|
STRIPE_PRICE_METERED / STRIPE_PRICE_METERED_YEARLY | Feature | Runtime | Price ids for the metered component. |
STRIPE_METER_ID | Feature | Runtime | The 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_NAME | Optional | Runtime | The 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_BACKFILL | Optional | Runtime | boundary (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.
| Variable | Need | When | Value |
|---|---|---|---|
AUTO_LOCK_BILLING_FROM | Optional | Runtime | First month the sweep may auto-suspend organizations delinquent past a 30-day grace. Unset, nothing ever auto-locks. |
BILL_ASSIST_TOKENS_FROM | Optional | Runtime | First month Assist token cost appears on an invoice. Unset, assist usage is measured but never billed. |
BILL_ORG_LIBRARY_STORAGE_FROM | Optional | Runtime | First month media-library bytes are charged. Unset, storage is metered and cap-enforced but not charged. |
BILL_EMAIL_SEND_OVERAGE_FROM | Optional | Runtime | First 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_FROM | Optional | Runtime | First 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_OVERAGE | Optional | Runtime | The 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.
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_PROVIDER | What carries the mail |
|---|---|
resend | Resend, 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. |
webhook | Your 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 value | A 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.
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_MAIL_PROVIDER | Optional | Runtime | resend, webhook, or the name of a registered provider. See Which provider carries your mail. |
AGLYN_MAIL_WEBHOOK_URL | Feature, webhook only | Runtime | Where each outbound message is POSTed. Setting it with no RESEND_API_KEY selects the webhook provider on its own. |
AGLYN_MAIL_WEBHOOK_TOKEN | Recommended, webhook only | Runtime | Sent as Authorization: Bearer <token> on every POST, so your relay can refuse anything else. Any random string of 32 characters or more. |
RESEND_API_KEY | Feature, resend only | Runtime | re_…. 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_FROM | Feature | Runtime | The 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_SECOND | Optional | Runtime | How 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_THRESHOLD | Optional | Runtime, console only | The 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_SECRET | Optional | Runtime | Svix 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_KEY | Optional | Runtime | A 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_DOMAIN | in.aglyn.com | Runtime, console only | The 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_SECRET | Optional | Runtime, console only | Svix 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_SECRET | Optional | Runtime, console only | Svix 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_ID | Optional | Runtime | The 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_KEY | Optional | Runtime, console only | A 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_PROVIDER | Optional | Runtime, console only | resend 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_APEX | Optional | Runtime | The 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_SIZE | Optional | Runtime | How 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_PART | Optional | Runtime | The 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_CAPACITY | Optional | Runtime, console only | How 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_INCLUDE | Optional | Runtime | The 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_HOST | Optional | Runtime | The 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_CA | Optional | Runtime | The 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_DAYS | Optional | Runtime, console only | How 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_DAY | Optional | Runtime | How 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_DAYS | Optional | Runtime | Engagement-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_SECRET | Feature | Runtime | See Secrets. |
STAFF_ALERT_EMAIL | Recommended | Runtime | One 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_URL | Recommended | Runtime | An 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_SECRET | Optional | Runtime | Signs 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_ORIGIN | Optional | Runtime, console only | The 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_ORIGINS | Optional | Runtime, console only | Extra 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_PAGES | Optional | Runtime, console only | Real 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_ORIGIN | Optional | Runtime, console only | The 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_THRESHOLD | Optional | Runtime, console only | How 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.
| Variable | Default | Drives |
|---|---|---|
RATE_LIMIT_ALARM_MAX_CALLS | 0 | Rate-limiter fallback calls tolerated in the window before /api/health/rate-limits reports degraded. |
SERVER_ERROR_ALARM_MAX_ERRORS | 5 | Uncaught server errors tolerated in a 30-minute window. |
SIGNUP_ALARM_MAX_PER_HOUR | 10 | Organization 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_HOUR | 50 | Refused (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_TRAFFIC | 3 | Attempts 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_ENABLED | unset | Set 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_ENABLED | unset | Set 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_ENABLED | unset | Set 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_ACCOUNTS | 3 | Accounts 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:
- Enable the Gmail API in the project.
- 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.readonlyscope without Google's app verification or its annual security assessment. Anyone outside the organization needs an External screen, which Google has to verify first. - Create an OAuth client of type Web application and add one authorized
redirect URI:
{NEXT_PUBLIC_CONSOLE_URL}/api/outreach/mailboxes/oauth/callback, for examplehttps://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.
| Variable | Need | When | Value |
|---|---|---|---|
GOOGLE_OUTREACH_CLIENT_ID | Feature | Runtime, console only | The 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_SECRET | Feature | Runtime, console only | That 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_KEY | Feature | Runtime, console only | 32 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):
- Supported account types: accounts in any organizational directory, or
your own tenant only. Set
MICROSOFT_OUTREACH_TENANTto match. - 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 signedstatesays which one a connect went to. - API permissions → Microsoft Graph → Delegated permissions:
Mail.Send,Mail.ReadWrite,User.Read,offline_access,openid,emailandprofile. A tenant administrator can Grant admin consent for the tenant; otherwise each rep consents on their first connect. - 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.
| Variable | Need | When | Value |
|---|---|---|---|
MICROSOFT_OUTREACH_CLIENT_ID | Feature | Runtime, console only | The 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_SECRET | Feature | Runtime, console only | The client secret's Value (not its id). A secret Microsoft refuses — expired, or mistyped — surfaces as client_misconfigured on every send. |
MICROSOFT_OUTREACH_TENANT | Optional | Runtime, console only | Who may sign in: common (the default), organizations, or one tenant's id or domain. Anything else counts as unset. |
Link domains
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:
vercelattacheslinks.<domain>toVERCEL_CONSOLE_PROJECT_IDwhen a member selects Set up, and Vercel issues its certificate.webhooksends your endpoint anattachfor it, scopeconsole.wildcardandnoneregister nothing: pointlinks.<domain>at the console yourself — a DNS record, and a proxy route that terminates TLS with a certificate for the name and passes theHostheader 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).
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_LINK_HOST_TARGET | Optional | Runtime, console | The 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_CA | Optional | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
TWILIO_ACCOUNT_SID | Feature | Runtime | AC…, the Twilio account. |
TWILIO_AUTH_TOKEN | Feature | Runtime | Signs every API call, and verifies the X-Twilio-Signature on inbound texts. An unsigned inbound text is refused. |
TWILIO_MESSAGING_SERVICE_SID | Feature | Runtime | MG…, 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.
| Variable | Need | When | Value |
|---|---|---|---|
SHIPPO_API_TOKEN | Feature | Runtime | The platform's Shippo token. Set, Shippo is the provider. |
EASYPOST_API_KEY | Feature | Runtime | The platform's EasyPost key. The provider when SHIPPO_API_TOKEN is unset, or when SHIPPING_PROVIDER says easypost. |
SHIPPING_PROVIDER | Optional | Runtime | easypost to prefer EasyPost while both credentials are set. Anything else, or unset, prefers Shippo. |
SHIPPING_TOKEN_KEY | Feature | Runtime | 32 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_TOKEN | Feature | Runtime, console | The token Shippo's tracking webhook URL carries as ?token=. |
SHIPPO_WEBHOOK_HMAC_SECRET | Optional | Runtime, console | Shippo's HMAC secret. When set, the Shippo-Auth-Signature header is verified as well. |
EASYPOST_WEBHOOK_SECRET | Feature | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
INTUIT_CLIENT_ID | Feature | Runtime, console | The Intuit developer app's client id. |
INTUIT_CLIENT_SECRET | Feature | Runtime, console | The Intuit developer app's client secret. |
INTUIT_ENVIRONMENT | Optional | Runtime, console | sandbox (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_ID | Feature | Runtime, console | The Xero app's client id. |
XERO_CLIENT_SECRET | Feature | Runtime, console | The Xero app's client secret. |
XERO_SCOPES | Optional | Runtime, console | Space-separated scopes that replace the default request, for a Xero app made before Xero's granular scopes. |
CODAT_API_KEY | Feature | Runtime, console | The 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_KEY | Feature | Runtime, console | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
TAX_ENGINES_TOKEN_KEY | Feature | Runtime | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
REVIEW_PLATFORMS_TOKEN_KEY | Feature | Runtime | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
AD_CONVERSIONS_TOKEN_KEY | Feature | Runtime, console | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
MARKETING_PLATFORMS_TOKEN_KEY | Feature | Runtime, console | 32 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_ID | Optional | Runtime, console | The Mailchimp app's client id, for Connect with Mailchimp. Without it merchants connect with an API key. |
MAILCHIMP_CLIENT_SECRET | Optional | Runtime, console | The Mailchimp app's client secret. |
KLAVIYO_CLIENT_ID | Optional | Runtime, console | The Klaviyo app's client id, for Connect with Klaviyo. Without it merchants connect with a private API key. |
KLAVIYO_CLIENT_SECRET | Optional | Runtime, console | The Klaviyo app's client secret. |
ATTENTIVE_CLIENT_ID | Optional | Runtime, console | The Attentive partner app's client id. Attentive is offered only with it: its API takes no merchant key. |
ATTENTIVE_CLIENT_SECRET | Optional | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
HELP_DESKS_TOKEN_KEY | Feature | Runtime, console | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
ZAPIER_APP_URL | Optional | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
FULFILLMENT_NETWORKS_TOKEN_KEY | Feature | Runtime, console | 32 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_ID | Optional | Runtime, console | The ShipBob developer app's client id. ShipBob is offered only with it and its secret. |
SHIPBOB_CLIENT_SECRET | Optional | Runtime, console | The ShipBob app's client secret. |
SHIPBOB_ENVIRONMENT | Optional | Runtime, console | sandbox sends everything to ShipBob's sandbox, which takes test orders only. Unset is production. |
AMAZON_SP_API_APPLICATION_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The app's Login with Amazon client id. |
AMAZON_SP_API_LWA_CLIENT_SECRET | Optional | Runtime, console | The app's Login with Amazon client secret. |
AMAZON_SP_API_REGION | Optional | Runtime, console | na (default), eu or fe: the Selling Partner API region the deployment's sellers are in. |
AMAZON_SP_API_ENVIRONMENT | Optional | Runtime, console | sandbox sends everything to Amazon's sandbox. Unset is production. |
AMAZON_SP_API_DRAFT_APP | Optional | Runtime, console | true while the app is a draft, so the consent page is asked for with version=beta. |
SHIPMONK_ENABLED | Optional | Runtime, console | true offers ShipMonk. It needs no app: each merchant pastes their own ShipMonk API key and store id, sealed with the token key. |
SHIPMONK_ENVIRONMENT | Optional | Runtime, console | sandbox sends everything to ShipMonk's sandbox (sandbox.shipmonk.dev), which takes test orders only and needs sandbox keys. Unset is production. |
FLEXPORT_ENABLED | Optional | Runtime, console | true 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.
| Variable | Need | When | Value |
|---|---|---|---|
COURIERS_TOKEN_KEY | Feature | Runtime, console | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
INVENTORY_SYNC_TOKEN_KEY | Feature | Runtime, console | 32 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_REF | Optional | Runtime, console | The Brightpearl app's reference, its OAuth client id. Brightpearl is offered only with it and the developer reference. |
BRIGHTPEARL_DEV_REF | Optional | Runtime, console | The developer reference every Brightpearl API call carries. |
BRIGHTPEARL_CLIENT_SECRET | Optional | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
MARKETPLACES_TOKEN_KEY | Feature | Runtime, console | 32 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The app's Login with Amazon client id. |
MARKETPLACES_AMAZON_LWA_CLIENT_SECRET | Optional | Runtime, console | The app's Login with Amazon client secret. |
MARKETPLACES_AMAZON_REGION | Optional | Runtime, console | na (default), eu or fe. |
MARKETPLACES_AMAZON_ENVIRONMENT | Optional | Runtime, console | sandbox sends everything to Amazon's sandbox. Unset is production. |
MARKETPLACES_AMAZON_DRAFT_APP | Optional | Runtime, console | true while the app is a draft, so the consent page is asked for with version=beta. |
MARKETPLACES_EBAY_CLIENT_ID | Optional | Runtime, console | The eBay keyset's App ID. eBay is offered only with it, its Cert ID and the RuName. |
MARKETPLACES_EBAY_CLIENT_SECRET | Optional | Runtime, console | The keyset's Cert ID. |
MARKETPLACES_EBAY_RU_NAME | Optional | Runtime, console | The RuName whose accept URL is the redirect address below. |
MARKETPLACES_EBAY_ENVIRONMENT | Optional | Runtime, console | sandbox for eBay's sandbox. Unset is production. |
MARKETPLACES_EBAY_MARKETPLACE_ID | Optional | Runtime, console | The eBay site listings go to, EBAY_US by default. |
MARKETPLACES_ETSY_KEYSTRING | Optional | Runtime, console | The Etsy app's keystring. Etsy is offered only with it and the shared secret. Etsy has no sandbox. |
MARKETPLACES_ETSY_SHARED_SECRET | Optional | Runtime, console | The Etsy app's shared secret. |
MARKETPLACES_TIKTOK_APP_KEY | Optional | Runtime, console | The 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_SECRET | Optional | Runtime, console | The app's secret. |
MARKETPLACES_TIKTOK_SERVICE_ID | Optional | Runtime, console | The app's service id, from its authorization link in Partner Center. |
MARKETPLACES_TIKTOK_REGION | Optional | Runtime, console | us (default) or global: which consent page sellers are sent to. |
MARKETPLACES_WALMART_CLIENT_ID | Optional | Runtime, console | The Walmart solution-provider app's client id. Walmart is offered only with it and its secret. |
MARKETPLACES_WALMART_CLIENT_SECRET | Optional | Runtime, console | The app's client secret. |
MARKETPLACES_WALMART_ENVIRONMENT | Optional | Runtime, console | sandbox for Walmart's sandbox. Unset is production. |
MARKETPLACES_WALMART_CHANNEL_TYPE | Optional | Runtime, console | The WM_CONSUMER.CHANNEL.TYPE value Walmart issued the solution provider, when it issued one. |
MARKETPLACES_FAIRE_APPLICATION_ID | Optional | Runtime, console | The Faire app's application id. Faire is offered only with it and its secret. It has no sandbox. |
MARKETPLACES_FAIRE_APPLICATION_SECRET | Optional | Runtime, console | The 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).
Print on demand: Printful and Printify
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.
| Variable | Need | When | Value |
|---|---|---|---|
PRINT_ON_DEMAND_TOKEN_KEY | Feature | Runtime | 32 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.
| Variable | Need | When | Value |
|---|---|---|---|
DELIVERY_APPS_DOORDASH_DEVELOPER_ID | Optional | Runtime, console | The DoorDash developer id of the integration. DoorDash is offered only with it and the three below. |
DELIVERY_APPS_DOORDASH_KEY_ID | Optional | Runtime, console | The integration's access key id. |
DELIVERY_APPS_DOORDASH_SIGNING_SECRET | Optional | Runtime, console | The access key's signing secret, as DoorDash issues it (base64url). Signs each request's JWT. |
DELIVERY_APPS_DOORDASH_WEBHOOK_SECRET | Optional | Runtime, console | The token set on the integration's order webhook; a webhook without it is refused. |
DELIVERY_APPS_DOORDASH_PROVIDER_TYPE | Optional | Runtime, console | The provider type DoorDash assigned the integration, sent with each menu. |
DELIVERY_APPS_DOORDASH_ENVIRONMENT | Optional | Runtime, console | sandbox marks DoorDash orders as test orders. Unset is live. |
DELIVERY_APPS_UBER_EATS_CLIENT_ID | Optional | Runtime, console | The Uber Eats app's client id. Uber Eats is offered only with it and its secret. |
DELIVERY_APPS_UBER_EATS_CLIENT_SECRET | Optional | Runtime, console | The app's client secret. Also the key Uber signs each webhook with. |
DELIVERY_APPS_UBER_EATS_ENVIRONMENT | Optional | Runtime, console | sandbox marks Uber Eats orders as test orders (Uber's test stores). Unset is live. |
DELIVERY_APPS_GRUBHUB_CLIENT_ID | Optional | Runtime, console | The Grubhub partner client id. Grubhub is offered only with it and the two below. |
DELIVERY_APPS_GRUBHUB_SECRET_KEY | Optional | Runtime, console | The partner's MAC secret key, as Grubhub issues it (base64). Signs each request and verifies each webhook. |
DELIVERY_APPS_GRUBHUB_PARTNER_KEY | Optional | Runtime, console | The partner key sent as X-GH-PARTNER-KEY. |
DELIVERY_APPS_GRUBHUB_ENVIRONMENT | Optional | Runtime, console | sandbox 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).
| Variable | Need | When | Value |
|---|---|---|---|
POS_HARDWARE_SHOP_ENABLED | Optional | Runtime, console | Exactly true opens the shop. Anything else keeps it hidden. |
POS_HARDWARE_PRICES | Optional | Runtime, console | JSON 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_BPS | Optional | Runtime, console | The 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_SHIPPING | Optional | Runtime, console | JSON of a flat shipping amount in cents for each speed offered: standard, express, priority. The merchant pays it. |
POS_HARDWARE_AUTOMATIC_TAX | Optional | Runtime, console | Exactly true turns on Stripe automatic tax for hardware checkouts, on the platform's own tax registrations. |
POS_HARDWARE_IN_PERSON_ENABLED | Optional | Runtime, console | Exactly true lets your own store's register sell readers in person (below). Anything else keeps it off. |
POS_HARDWARE_SELLER_HOST_ID | Optional | Runtime, console | The site id of your own store whose register sells readers in person. |
POS_HARDWARE_SELLER_ORG_ID | Optional | Runtime, console | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
POS_CELLULAR_MONTHLY_COST_CENTS | Optional | Runtime, console and tenant | What 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_BPS | Optional | Runtime, console and tenant | The card processing percentage on the monthly charge, in basis points. Unset is the platform's card rate. |
POS_CELLULAR_BILLING_COST_FIXED_CENTS | Optional | Runtime, console and tenant | The card processing fixed fee on the monthly charge, in cents. Unset is the platform's. |
POS_CELLULAR_GRACE_DAYS | Optional | Runtime, console and tenant | How 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_CELLULAR | Optional | Runtime, console and tenant | The 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) | Need | Value |
|---|---|---|---|
AGLYN_CONSOLE_URL | consoleUrl | Required | Your 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 | — | Optional | The app's name and the product name its copy says. Default Aglyn. |
AGLYN_FIREBASE_API_KEY | firebaseApiKey | Required | The Firebase app config of the app you registered in your project, per bundle. |
AGLYN_FIREBASE_AUTH_DOMAIN | firebaseAuthDomain | Required | As above. |
AGLYN_FIREBASE_PROJECT_ID | firebaseProjectId | Required | As above. |
AGLYN_FIREBASE_APP_ID | firebaseAppId | Required | As above. |
AGLYN_FIREBASE_MESSAGING_SENDER_ID | firebaseMessagingSenderId | Optional | As above. |
AGLYN_FIREBASE_STORAGE_BUCKET | — | Optional | As above. |
AGLYN_GOOGLE_IOS_CLIENT_ID, AGLYN_GOOGLE_WEB_CLIENT_ID | googleWebClientId | Optional | Google 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_SECRET | Optional | The 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_ID | passkeyRpId | Optional | The 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_HOST | authEmulatorHost | Development | host:port of a local Auth emulator. Never set in a store build. |
AGLYN_FIRESTORE_EMULATOR_HOST | firestoreEmulatorHost | Development | host:port of a local Firestore emulator. Never set in a store build. |
The server side of push is configured in your containers:
| Variable | Need | When | Value |
|---|---|---|---|
MOBILE_PUSH_ENABLED | Optional | Runtime, console and tenant | The kill switch for every push sender. 0 stops every push; anything else, or unset, leaves push on. |
APNS_KEY_P8 | Feature | Runtime, console and tenant | Your 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_ID | Feature | Runtime, console and tenant | The 10-character id of that key. |
APNS_TEAM_ID | Feature | Runtime, console and tenant | Your Apple Developer team id. |
Passkeys set up from the Android app are checked against the app's signing certificate:
| Variable | Need | When | Value |
|---|---|---|---|
PASSKEY_ANDROID_APK_KEY_HASHES | Feature | Runtime, console | Comma-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:
| Variable | Need | When | Value |
|---|---|---|---|
NATIVE_APPLE_APP_IDS | Feature | Runtime, console and tenant | Comma-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_APPS | Feature | Runtime, 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
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.
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_ADS_CONVERSION_ID | Aglyn-only / your own | Build | Google Ads conversion id, AW- plus digits. Google Ads → Goals → Conversions → the tag's id. |
NEXT_PUBLIC_ADS_SIGNUP_LABEL | Aglyn-only / your own | Build | The opaque conversion label for the signup action, from the same page. Fires only when the id is also set. |
NEXT_PUBLIC_ADS_SUBSCRIBE_LABEL | Aglyn-only / your own | Build | The conversion label for the subscribe action. |
NEXT_PUBLIC_META_PIXEL_ID | Aglyn-only / your own | Build | Meta (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_ID | Aglyn-only / your own | Build | LinkedIn 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_ID | Aglyn-only / your own | Build | Google 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_ID | Optional | Runtime | G-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_SECRET | Optional | Runtime | The 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_NONPROD | Development only | Build | Re-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.
| Variable | Need | When | Value |
|---|---|---|---|
META_AUDIENCE_SYNC_ACCESS_TOKEN | Aglyn-only / your own | Runtime, console | A system-user access token with ads_management on the ad account that owns the audience. A secret. |
META_AUDIENCE_SYNC_AUDIENCE_ID | Aglyn-only / your own | Runtime, console | The 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_ID | Aglyn-only / your own | Runtime, console | An 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_SECRET | Aglyn-only / your own | Runtime, console | That client's secret. A secret. |
GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN | Aglyn-only / your own | Runtime, console | A 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_ID | Aglyn-only / your own | Runtime, console | The Google Ads customer id that owns the list, with or without dashes. |
GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID | Optional | Runtime, console | A manager account the Google account reaches the customer through. Leave unset when it has direct access. |
GOOGLE_AUDIENCE_SYNC_USER_LIST_ID | Aglyn-only / your own | Runtime, console | The 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_TOKEN | Aglyn-only / your own | Runtime, console | A member access token with rw_dmp_segments, from a developer app LinkedIn has approved for Matched Audiences. A secret. |
LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID | Aglyn-only / your own | Runtime, console | The DMP segment's id (or its urn:li:dmpSegment: URN). |
META_AUDIENCE_SYNC_STAGE_FREE_UNPUBLISHED_AUDIENCE_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The 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_ID | Optional | Runtime, console | The DMP segment for the free, not published stage list. Unset, that stage is not sent to LinkedIn. |
LINKEDIN_AUDIENCE_SYNC_STAGE_FREE_PUBLISHED_SEGMENT_ID | Optional | Runtime, console | The DMP segment for the free, published stage list. Unset, that stage is not sent to LinkedIn. |
LINKEDIN_AUDIENCE_SYNC_STAGE_PAID_SEGMENT_ID | Optional | Runtime, console | The DMP segment for the paid stage list. Unset, that stage is not sent to LinkedIn. |
LINKEDIN_AUDIENCE_SYNC_STAGE_LAPSED_SEGMENT_ID | Optional | Runtime, console | The 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_DOMAINand every subdomain of it,NEXT_PUBLIC_CONSOLE_URL,NEXT_PUBLIC_DOCS_ORIGINandNEXT_PUBLIC_PLATFORM_HOME_URL— plus any asuperstaff 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_IDwhen 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
consoletarget inDOCS_STATUS_TARGETS, or else the consoleDOCS_ERROR_BEACON_ENDPOINTreports 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.
| Variable | Need | When | Value |
|---|---|---|---|
ANTHROPIC_API_KEY | Feature | Runtime | sk-ant-…, from the vendor's console under API keys. The key of the anthropic adapter. |
AI_OPENAI_COMPAT_BASE_URL | Feature | Runtime | The 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_KEY | Feature | Runtime | The bearer token for that endpoint. |
AI_PROVIDER | Optional | Runtime | Which 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_MODEL | Optional | Runtime | A 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_PROJECT | Feature | Runtime | The 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_LOCATION | Optional | Runtime | The 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_MODEL | Optional | Runtime | The 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_PHOTOS | Optional | Build | on 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_KEY | Optional (console) | Runtime | A 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_URL | Optional (console) | Runtime | The 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_TOKEN | Optional (console) | Runtime | A 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_MODEL | Optional | Runtime | The 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_LIMIT | Optional | Runtime | Messages per free workspace per UTC day. Default 10. |
ASSIST_ENTITLED_MONTHLY_LIMIT | Optional | Runtime | Messages per entitled workspace per month. Default 1000. |
AI_FREE_DAILY_REQUESTS | Optional | Runtime | AI 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_USD | Optional | Runtime | Ceiling 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_HOURS | Optional | Runtime | Hours 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_USD | Optional | Runtime | Dollar 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_USD | Optional | Runtime | Dollar 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_HOURS | Optional | Runtime | How 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_REQUESTS | Optional | Runtime | Free 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_USD | Optional | Runtime | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
R2_ACCOUNT_ID | Feature (console) | Runtime | The 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_ID | Feature (console) | Runtime | The access key id of an R2 API token scoped to Object Read & Write on the one bucket. |
R2_SECRET_ACCESS_KEY | Feature (console) | Runtime | That token's secret access key. |
R2_VIDEO_BUCKET | Feature (console) | Runtime | The bucket's name. Keep the bucket private, with no public r2.dev URL: the Worker reads it through its binding. |
MEDIA_VIDEO_DELIVERY_HOST | Feature | Runtime | The 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_SECRET | Feature | Runtime | Signs 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.
| Variable | Need | When | Value |
|---|---|---|---|
CRON_SECRET | Feature | Runtime | Shared 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_URL | Feature (cloud functions) | Runtime | The tenant origin the every-minute beat POSTs: https://sites.example.com/api/plugins/run-jobs. No default, deliberately. |
AGLYN_CONSOLE_URL | Feature (cloud functions) | Runtime | The 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_FUNCTIONS | Optional | Deploy | Set 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_ZONE | Optional | Runtime | The 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.
| Cron | Path | What it does |
|---|---|---|
* * * * * | tenant /api/plugins/run-jobs | Scheduled publishing, booking-hold expiry |
*/15 * * * * | /api/campaigns/process-scheduled | Scheduled campaign sends |
*/15 * * * * | /api/admin/finish-domain-attachments | Completes custom domains once DNS settles |
*/5 * * * * | /api/admin/render-monitor | Fetches 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/tick | Asks 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-usage | Meters the closed month into Stripe |
0 3 * * * | /api/admin/audit-archive | Archives audit rows |
0 4 * * * | /api/admin/run-erasures | Executes due erasure requests |
0 7 * * * | /api/billing/report-usage?month=current | Current-month usage |
0 8 * * * | /api/billing/usage-alerts | Budget warnings, auto-lock sweep |
0 * 1-2 * * | /api/billing/usage-email | Monthly usage summaries |
0 13 * * * | /api/crm/daily-digest | Daily CRM digest — 08:00 in CRM_DIGEST_TIME_ZONE; move the hour with the zone |
0 * * * * | /api/crm/task-reminders | CRM 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-export | Weekly export |
30 5 * * 1 | /api/admin/reap-plugin-artifacts | Orphaned artifact reaping |
0 6 * * 1 | /api/admin/reverify-plugin-versions | Re-checks published plugin verdicts |
30 6 * * 1 | /api/admin/backfill-scope | Scope drift backfill |
30 7 * * 1 | /api/admin/reverify-sso-domains | Re-verifies SSO domain ownership |
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.
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
| Variable | Need | When | Value |
|---|---|---|---|
PLUGIN_ARTIFACTS_BUCKET | Feature | Runtime | The 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_BASE | Optional | Runtime | Origin 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_ORIGIN | Feature | Build | Full 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_KEY | Feature | Runtime | Signs 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_KEY | Feature | Runtime | The 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_KEY | Feature | Build | The same public key again, for browser-side verification of realm bundles. A mismatch with the server-side name refuses every bundle. |
PLUGIN_JOBS_SECRET | Feature | Runtime | See Secrets. |
PLUGIN_CONNECTIONS_TOKEN_KEY | Feature | Runtime | 32 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:
| Variable | Need | When | Value |
|---|---|---|---|
PLUGIN_LOADER_CONSOLE_URL | Required (loader) | Runtime | Your console's origin, scheme and host, no path. |
PLUGIN_LOADER_TENANT_DOMAIN | Required (loader) | Runtime | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
GOOGLE_MERCHANT_CLIENT_ID | Feature | Runtime | The 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_SECRET | Feature | Runtime | That client's secret. Unset, the same as the id. |
META_CATALOG_APP_ID | Feature | Runtime | The 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_SECRET | Feature | Runtime | That app's secret. Also signs every Graph call (appsecret_proof). Unset, the same as the id. |
META_GRAPH_API_VERSION | Optional | Runtime | The Graph API version, vNN.N. Default v26.0; a value in any other shape is ignored. |
SALES_CHANNELS_TOKEN_KEY | Feature | Runtime | 32 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:
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_OPERATOR_NAME | Set it | Build | Your 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_EMAIL | Set it | Build | The address those same pages tell people to write to. |
NEXT_PUBLIC_OPERATOR_LEGAL_EMAIL | Optional | Build | A separate mailbox for legal and copyright notices. Defaults to the support address, so one mailbox is a complete configuration. |
NEXT_PUBLIC_OPERATOR_LEGAL_ORIGIN | Optional | Build | Where 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_NAME | Optional | Build | Designated 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_ADDRESS | Optional | Build | Physical address. |
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_EMAIL | Optional | Build | Agent email. Omitting it leaves that line out rather than inventing one. |
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_PHONE | Optional | Build | Agent phone. §512(c)(2) enumerates all four. |
NEXT_PUBLIC_OPERATOR_DMCA_AGENT_REGISTERED | Optional | Build | Exactly 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 stateThe 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
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_PLATFORM_BRAND_NAME | Optional | Build | What 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_NAME | Optional | Build | The 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_URL | Optional | Build | Where "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_URL | Optional | Build | Destination 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_URL | Optional | Build | The 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_URL | Optional | Build | The 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_ADDRESS | Optional | Build | The 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
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.countryis any ISO 3166-1 alpha-2 code andstateany 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,stripeornone. Understripe, 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.
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_TAX_JURISDICTION | Bootstrap | Runtime | Where 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_ID | Bootstrap | Runtime | The 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_ID | Bootstrap | Runtime | The 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_NUMBER | Deprecated | Runtime | The 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_NUMBER | Deprecated | Runtime | The 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.
| Variable | Need | When | Value |
|---|---|---|---|
REVALIDATE_SECRET | Set it | Runtime | See 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. |
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.
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_DOMAIN_PROVIDER | Optional | Runtime | vercel, 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. |
| Driver | For |
|---|---|
vercel | Aglyn's own hosting, and anyone else on Vercel. |
wildcard | The ordinary Docker answer. A container behind a proxy that already answers for *.example.com. |
webhook | Anything else — Caddy, Traefik, cert-manager, a registrar's API, a shell script. |
none | Names are handled entirely outside the product. Not an error state, and not something logged forever. |
wildcard, and that is deliberateThe 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.
| Variable | Need | When | Value |
|---|---|---|---|
NEXT_PUBLIC_TENANT_APEX | Do not set | Runtime | Read 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_SUFFIXES | Optional | Runtime | Comma-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. |
*.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.
| Variable | Need | When | Value |
|---|---|---|---|
AGLYN_DOMAIN_WEBHOOK_URL | Required (webhook) | Runtime | The endpoint. Ignored by every other driver. |
AGLYN_DOMAIN_WEBHOOK_TOKEN | Optional | Runtime | Sent as Authorization: Bearer …. Omitted entirely when unset. |
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"
}
actionisattach,detachorstatus.scopesays which app should answer for the name:consolefor the admin app and its workspace subdomains,tenantfor published sites.redirectToappears 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, answerskippedrather 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.
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.
| Variable | Need | When | Value |
|---|---|---|---|
VERCEL_TOKEN | Required (vercel) | Runtime | API token. Its presence is also what detection keys on when AGLYN_DOMAIN_PROVIDER is unset. |
VERCEL_TENANT_PROJECT_ID | Required (vercel) | Runtime | The project the tenant scope attaches to. |
VERCEL_CONSOLE_PROJECT_ID | Required (vercel) | Runtime | The project the console scope attaches to — workspace subdomains. A separate project from the one above. |
VERCEL_TEAM_ID | Optional (vercel) | Runtime | Team 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:
| Driver | A customer's own domain |
|---|---|
webhook | Registered 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. |
vercel | Registered automatically, on your Vercel project. |
wildcard | Not 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. |
none | Not 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:
- Point the customer's DNS at your reverse proxy.
- Route that hostname to the tenant container by hand.
- The site is served.
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.
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.
| Variable | Need | Notes |
|---|---|---|
VERCEL, VERCEL_ENV, VERCEL_REGION, VERCEL_URL, VERCEL_BRANCH_URL, VERCEL_PROJECT_PRODUCTION_URL, VERCEL_GIT_COMMIT_SHA | Aglyn-only | Injected by Vercel, never present on a container. |
VERCEL_LOG_DRAIN_SECRET, VERCEL_LOG_DRAIN_VERIFY | Aglyn-only | Signature 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.
| Variable | Need | When | Value |
|---|---|---|---|
DOMAINS_REGISTRAR | Feature | Runtime, console | vercel, 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_REGISTRATION | Feature | Runtime, console | The 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.
| Variable | Need | Value |
|---|---|---|
DOCS_URL | Optional | The 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_NAME | Optional | Name 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_ID | Optional | Your own GA4 measurement id, G-XXXXXXXXXX. Blank loads no analytics tag at all. |
DOCS_ERROR_BEACON_ENDPOINT | Optional | Where 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_TARGETS | Optional | What /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_ID | Optional | Meta 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_ID | Optional | Google 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_ID | Optional | LinkedIn Insight Tag partner id — digits only. Same consent gate. Blank loads nothing. |
DOCS_GTM_CONTAINER_ID | Optional | Google 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_URL | Optional | An 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
| Variable | Set to | Why you must not override it |
|---|---|---|
AGLYN_STANDALONE | exactly 1, in the runner stage of both Dockerfiles | This 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_ENV | production | Several safety relaxations key on "not production", the custom-domain verify soft pass among them. |
PORT | 4200 console, 4500 tenant | Compose publishes these on loopback. |
HOSTNAME | 0.0.0.0 | Listens inside the container's namespace. |
NEXT_TELEMETRY_DISABLED | 1 | No build telemetry leaves your machine. |
Stamping which build you are running
| Variable | Need | When | Value |
|---|---|---|---|
COMMIT_REF | Optional | Build | An 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_ID | Optional | Build | An explicit build id, checked before COMMIT_REF. Use it if you stamp builds with something of your own. |
PACKAGE_VERSION | Set by the build | Build | Read from package.json at build time and reported by /api/health as version. Setting it in the environment does nothing. |
AGLYN_REGION | Optional | Runtime | Labels 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_ENV | Set by the build | Build | Written 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.
| Variable | What it is |
|---|---|
AGLYN_TENANT_DEMO | The 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_HOST | Which 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_URL | Single-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_URL | Build-time origin overrides in the shared Next config. Leave at the defaults. |
AGLYN_DISABLE_BOOT_WARMUP | Skips the tenant's boot warm-up. A debugging aid. |
AGLYN_DEBUG_MIDDLEWARE | Set 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_COUNTRY | On 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_HOST | Where "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_NONPROD | Development only. |
NEXT_PUBLIC_PLUGIN_DEV, NEXT_PUBLIC_PLUGIN_DEV_BUNDLES, PLUGIN_DEV_MANIFESTS, PLUGIN_REMOTE_SERVER, PLUGIN_REMOTE_SERVER_BUNDLES | Local 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_HOST | Firebase 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_HOST | The 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_ID | Your own issue tracker for the console's "Report an issue" dialog. See Self-hosting → Customer issue reports. |
RECAPTCHA_ADMIN_KEY_NAME | The 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_MOUNT | A 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_GB | A 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_DRILL | Enables the timing-sensitive cases of a lockdown test. Test-only; no runtime effect. |
STRIPE_DUNNING_CHECK_KEY | A 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_BUNDLE | Repository tooling: end-to-end tests, smoke runs, CI gates, deploy scripts. Never read by a running container. |
Related
- Self-hosting — the setup runbook this reference belongs to
docs/SELF_HOSTING.md— the repository runbook, with the Firebase and rules-deploy steps.env.selfhost.example— the template you copy- White-label