Skip to main content

Environment variables

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

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

Read this before you set anything

Some of these are frozen when the image is built

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

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

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

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

Every row carries a When column:

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

And a Need column:

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

Firebase — identity and data

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

Client SDK config

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

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

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

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

Admin service account

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

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

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

docker run --env-file mangles the private key

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

Seven template lines that nothing reads

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

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

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

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

Firestore and Storage

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

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


The addresses this install calls its own

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

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

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

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

Your reverse proxy, the client IP, and geo

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

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

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

Set this if you run more than one proxy

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

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

What this protects:

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

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

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

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

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

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

Geo headers

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

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

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

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

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

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

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

What geo drives, and what happens with no signal:

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

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


Secrets this deployment signs with

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

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

Requiring SSO for a domain you own

VariableNeedWhenValue
AGLYN_SSO_REQUIRED_DOMAINSFeatureRuntimedomain=gcipTenantId pairs, comma- or space-separated: example.com=bramble-tenant-a1b2c. Empty governs nothing, which is the right default. Anything unparseable is dropped rather than throwing — this sits on the sign-in path and a malformed value must not take authentication down.
AGLYN_SSO_DOMAIN_ENFORCEMENTFeatureRuntimeExactly on starts refusing sign-ins that violate the rule above. Anything else is off. Both halves are needed: this switch alone governs nothing.
Keep an account the rule cannot refuse

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

Session and auth-link settings

VariableNeedWhenValue
AUTH_ACTION_ALLOWED_ORIGINSOptionalRuntimeComma-separated extra origins a password-reset or verify-email link may be built on when the request supplies one. Empty — the default — means request-supplied origins are always ignored and the link is built on NEXT_PUBLIC_CONSOLE_URL, which is the safe state. This is a security boundary: a wrong entry lets a request-supplied host receive a live reset code. Intended for preview deployments.
NEXT_PUBLIC_AUTH_IDLE_TIMEOUT_MINUTESOptionalBuildIdle window before the console signs a user out. Default 60. 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.comDevelopersAPI keys for the two keys, DevelopersWebhooks for the signing secrets, and Product catalog → each price → its price id.

VariableNeedWhenValue
STRIPE_SECRET_KEYFeatureRuntimesk_live_… or sk_test_…. Enough on its own for storefront commerce checkout.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYFeatureBuildpk_live_… / pk_test_…. Needed for platform plan checkout; the secret key alone is not enough.
STRIPE_WEBHOOK_SECRETFeatureRuntimewhsec_… for the endpoint you create at https://console.example.com/api/billing/webhook. Without it, webhook-driven state — subscription changes, payment settlement — is never applied: the money moves and your database never hears about it.
STRIPE_WEBHOOK_SECRET_TESTOptionalRuntimeA second accepted signing secret so test-mode deliveries verify while live keys are in force. Each is tried in turn; the webhook route answers 501 only when all three secrets are unset.
STRIPE_CONNECT_WEBHOOK_SECRETFeatureRuntimewhsec_… for the Connect destination, which feeds Connect readiness. Unset, those deliveries fail signature verification with 400 behind a green "Active" badge in the Stripe dashboard.
STRIPE_WEBHOOK_URLOptionalRuntimeThe endpoint URL the billing health check expects to find registered in your Stripe account. Default is Aglyn's URL, so a self-host operator who leaves it unset gets a permanent endpoint-missing red on /api/health/billing. Must match the URL exactly as Stripe stores it.
STRIPE_LIVEMODEOptionalRuntimeOverrides whether this deployment considers itself the live one. Normally inferred from the key prefix. Exactly true or false — anything else, 1 included, falls through to inference.
Localhost with live keys

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

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 fifteen:

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

The last five 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:

STRIPE_PRICE_STARTER STRIPE_PRICE_STARTER_YEARLY
STRIPE_PRICE_PRO STRIPE_PRICE_PRO_YEARLY
STRIPE_PRICE_BUSINESS STRIPE_PRICE_BUSINESS_YEARLY
STRIPE_PRICE_SCALE STRIPE_PRICE_SCALE_YEARLY
STRIPE_PRICE_ADVANCED STRIPE_PRICE_ADVANCED_YEARLY
STRIPE_PRICE_AGENCY STRIPE_PRICE_AGENCY_YEARLY

There is deliberately no STRIPE_PRICE_ENTERPRISE: Enterprise is quoted per deal and is not self-serve. free has nothing to sell.

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

Add-onName pattern
Extra manager seatSTRIPE_PRICE_{STARTER|PRO|BUSINESS|SCALE|ADVANCED|AGENCY}_EXTRA_SEAT[_YEARLY]
Extra membersSTRIPE_PRICE_{…}_EXTRA_MEMBER[_YEARLY]
Extra datasetsSTRIPE_PRICE_{…}_EXTRA_DATASET[_YEARLY]
Extra hostsSTRIPE_PRICE_{…}_EXTRA_HOST[_YEARLY]
Aglyn AISTRIPE_PRICE_{…}_AI_ADDON[_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
Half-configured add-ons sell and then read back empty

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

Metered usage, if you bill API usage:

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

Billing cutover dates

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

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

Email

Where to get these: resend.comAPI Keys, and Domains to verify the domain you send from.

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

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

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

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

VariableNeedWhenValue
RESEND_API_KEYFeatureRuntimere_…. Without it every outbound send — invites, receipts, password resets, campaigns, security alerts — is an inert no-op. Nothing errors; mail simply does not arrive.
USAGE_EMAIL_FROMFeatureRuntimeThe verified sender identity for your install's own mail — invites, billing, security alerts, console password resets — and the address the "is email configured" check every sender consults reads. A bare address or Bramble <billing@example.com>, on a domain you verified in Resend. A published site never sends from it, under any configuration: a tenant's list quality must not be charged against the domain your own account mail depends on, so tenant mail resolves its own identity and refuses rather than borrowing this one. Unset, every platform sender no-ops or answers 501 with an actionable message; nothing throws.
EMAIL_PROVIDER_REQUESTS_PER_SECONDOptionalRuntimeHow many API requests a second your mail provider accepts, as a whole number. Default 10, which is Resend's published per-team limit — counted across every key on the account, not per key and not per domain. A campaign sends one request per recipient, so a batch of five hundred is the only thing this deployment does that can approach it; the batch paces itself to one request less than this number, leaving the remainder for transactional mail that lands in the same second. Raise it if Resend has raised your account's limit; a value that is blank, negative or unparsable falls back to the default rather than removing the pace, and 1 paces at one request a second. 0 turns pacing off entirely — set it only when something in front of this process already limits the rate, since without it a large batch earns 429s. Refused requests are never lost either way: a 429 defers the rest of the batch to the next run rather than dropping those recipients.
RESEND_WEBHOOK_SECRETOptionalRuntimeSvix signing secret (whsec_…) verifying Resend's delivery, open, click, bounce and complaint webhooks. Unset, that endpoint answers 501 and nothing is recorded: no open/click statistics, no bounce suppressions, no per-recipient delivery history on the staff user page, and /api/health/auth-doors reports its verification-delivery arm as having no opinion — with nothing recording deliveries, an empty log says nothing about whether mail went out.
RESEND_READ_API_KEYOptionalRuntimeA full-access Resend key, used only by the staff Import delivery history action to read already-sent mail into the per-recipient delivery log. Deliberately separate from RESEND_API_KEY, which is sending-scoped and answers every read with 401 restricted_api_key — a leaked sending key must not be able to enumerate everyone you have ever emailed. Unset, the import answers 501 and says so; the live webhook feed is unaffected.
CRM_INBOUND_DOMAINin.aglyn.comRuntime, console onlyThe domain the CRM's email capture address is minted under: crm+<token>@<domain>. Set it to a receiving domain YOU own at Resend (with its MX record published) — the default is Aglyn's, and mail to it never reaches a self-hosted install. A value that is not a bare hostname falls back to the default.
CRM_INBOUND_WEBHOOK_SECRETOptionalRuntime, console onlySvix signing secret of the Resend webhook endpoint that subscribes email.received to /api/crm/inbound. Unset, the route also accepts RESEND_WEBHOOK_SECRET, for an install that subscribed the event on its delivery-events endpoint; with neither set the route answers 501 and nothing is captured. Reading the message itself needs RESEND_READ_API_KEY.
PLATFORM_MARKETING_HOST_IDOptionalRuntime, console onlyThe 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 and the platform suppression list — with a link to the contact and a warning when the two disagree. Unset, the card says no marketing site is configured.
RESEND_DOMAINS_API_KEYOptionalRuntime, console onlyA full-access Resend key, used only to create a customer's sending domain (POST /domains) and read back the DKIM record it issues. A third key on purpose: RESEND_API_KEY is sending-scoped and cannot create anything, and RESEND_READ_API_KEY is for reading message history. Set it on the console project only — a key that can create a domain can also list every domain in the account and mint further keys, and the tenant runtime serves published sites to the public. Unset, a requested domain stops at requested, has no records to publish, and the console says pendingProvider rather than showing an empty DKIM row. Transactional mail keeps moving either way: a site whose platform subdomain never got a key falls through to the shared pool, and only a domain the CUSTOMER owns refuses while unverified — because that one is an instruction the merchant gave. Marketing is refused in both cases. The pool itself needs no key, because you create its members by hand.
AGLYN_SENDING_DOMAIN_PROVIDEROptionalRuntime, console onlyresend or none. Unset, the deployment detects: resend when RESEND_DOMAINS_API_KEY is present, none otherwise. An unrecognized value logs [sending-domain-provider] unknown AGLYN_SENDING_DOMAIN_PROVIDER and falls back to detection. An explicit value always wins, none included. Naming resend without the key still issues nothing — the driver reports "not configured" rather than failing.
AGLYN_TENANT_MAIL_APEXOptionalRuntimeThe apex a site's own sending domain hangs off, bare: a site with the pinned label northwind sends from northwind.{this value}. Default mail.{NEXT_PUBLIC_TENANT_DOMAIN}, so an install that has set its tenant apex already has a mail namespace inside its own zone and needs nothing here. Set it only to put mail in a different zone — a separate registrable domain is the strongest form of the reputation split, since one site's list quality then cannot reach the domain your own account mail leaves on. A value equal to the web apex is ignored and the default used instead: a mail namespace that is also the web namespace lets a renamed site's freed web slug become a name another site's mail is signed for. Unset on an install that also left NEXT_PUBLIC_TENANT_DOMAIN unset, every sending name this install builds — the shared pool included — lands inside Aglyn's mail.aglyn.app, which you cannot publish records into, so nothing verifies and no tenant mail leaves at all.
AGLYN_TENANT_SHARED_POOL_SIZEOptionalRuntimeHow many domains the shared sending pool holds — shared1.{mail apex}, shared2.… and so on — for sites that have no domain of their own. A whole number 1–64; default 4. A blank, unparsable or sub-1 value falls back to the default, and anything above 64 is clamped so a typo cannot ask for thousands. The pool is fixed-size and does not grow with sites, so its whole cost is this many provider domain objects and three DNS records each, whether you host twelve sites or a hundred thousand. Four rather than one because every site on a member shares its reputation: one member in trouble takes a quarter of your transactional mail with it, not all of it. ⚠ Raising it requires creating the matching domain objects and their records at your provider first — the code derives the pool from this number, and a member that does not exist is an address nothing signs for. Each member also spends a provider domain slot counted against AGLYN_SENDING_DOMAIN_CAPACITY, and is a reputation that has to be earned by sending steadily. Changing the size does not reshuffle existing sites: assignment is rendezvous-hashed, so growing the pool moves only about 1/size of them onto the new member and nothing else observes the change, while shrinking it moves the removed member's sites and throws away the reputation that name had earned. Every pool label is reserved against tenants at any size, so a site can never be handed a name the pool is about to want. There is no configuration that produces no pool — a site with no domain of its own would then have no sending identity at all.
AGLYN_TENANT_SHARED_LOCAL_PARTOptionalRuntimeThe mailbox the shared pool sends as — the part before the @. Default notifications, giving notifications@shared2.{mail apex}. Normalized like any local part, and a value that normalizes to nothing falls back to the default rather than being placed in a header. Deliberately not hello, which is what a site's own domain defaults to: the two are different promises, and an address inviting replies to a mailbox belonging to no particular site invites mail nobody reads. Only the local part is configurable — the address always sits directly on a pool member, never beneath one, because under the published strict DKIM alignment the From: domain must be exactly the domain whose key signs the message and each member signs only for itself. A configurable domain here would produce mail that authenticates and still fails DMARC.
AGLYN_SENDING_DOMAIN_CAPACITYOptionalRuntime, console onlyHow many sending domains this deployment may hold at the mail provider. A whole number; default 10, and a blank or unparsable value falls back to it. Resend caps domains per account by plan (Free 3, Pro 10, Scale 1000) and every site's subdomain is its own domain object, so there is no wildcard that escapes the cap. It binds the dedicated per-site domains — a site on the shared pool is provisioned nothing and is never counted here, though the pool's own members each occupy a slot of their own. The default is the lowest paid tier's allowance, so a deployment that has not been told its plan refuses before the vendor does: over the ceiling a site's provisioning ends at-capacity with that reason stored on the record and logged as [provision-sending-domain] at the sending-domain ceiling, rather than failing at the provider with a message about somebody else's billing. The allowance is a bundled quota, not a per-domain price — Resend meters emails and contacts, never domains — so the cheap way past the ceiling is the flat domain add-on ($20/mo for 100 more domains, on Pro or Scale), and a tier upgrade buys the same domains for considerably more. Choose the tier by send volume, then raise this to the allowance that tier plus its add-ons carries. -1 switches the check off for a provider with no such limit. A domain that already holds a DKIM key is never re-counted, so a raised ceiling does not strand the sites already mid-provision. Reaching the ceiling is not an outage: a site refused here keeps sending its transactional mail on the shared pool, so what is lost is reputation isolation rather than receipts, and the fix is a purchase rather than an incident. Nothing draws on this automatically — a dedicated domain is claimed when a merchant asks for one from the sending card, never at signup and never on a plan change — so the count moves at the pace people request them. Watch the headroom rather than the limit: GET /api/admin/provision-sending-domains reports held, capacity, remaining, used (a 01 share, null when uncapped) and atCapacity without writing anything, and the sweep logs a warning from 80% spent so there is time to buy more allowance before a request is refused. Moving merchants onto domains they own is the other lever — a provider slot is spent either way, but a customer's own domain costs no records in your zone and no place in the re-verification sweep.
AGLYN_EMAIL_SPF_INCLUDEOptionalRuntimeThe SPF mechanism printed in the DNS instructions a customer follows to send from their own domain. Default amazonses.com, which is what Resend sends through. Change it only if you front a different provider. A blank value falls back to the default rather than being honored: an empty SPF include would print an instruction that authorizes nobody, and the customer would publish a record that silently fails their mail. Not taken from the provider's response even when it offers one: the SPF and return-path records come from these settings, and only the DKIM record comes from the provider.
AGLYN_EMAIL_RETURN_PATH_HOSTOptionalRuntimeThe host a customer's return-path (bounce) CNAME is pointed at, printed in the same instructions. Default feedback-smtp.us-east-1.amazonses.com — note the embedded region, which must match the region your provider actually sends from, or bounce processing goes to a host that is not listening. Blank falls back to the default, for the same reason as the SPF include.
AGLYN_EMAIL_TRACKING_CAOptionalRuntimeThe certificate authority named in the CAA record a customer publishes so the click-tracking host can be issued a TLS certificate. Default amazon.com, which is what Resend's link CDN uses. Change it only if you front a different provider. ⚠ The record is only ever shown to a domain that already publishes CAA which would refuse that authority — a domain publishing none needs nothing, because any authority may already issue, and handing it a CAA record to paste would be the change that starts restricting it and breaks whatever else renews on that name. A domain that does publish CAA must add this one alongside what it has, never in place of it. The check walks up from the tracking host exactly as a certificate authority does and stops at the first name publishing any record; a lookup nobody answers drops the record too, because "we could not tell" has to resolve toward the instruction that cannot hurt.
AGLYN_SENDING_TRACKING_RETENTION_DAYSOptionalRuntime, console onlyHow long a sending domain that carries a click-tracking host is held before its provider domain object is released. A whole number of days; default 30, and 0 switches the hold off. Every link in every message that domain has already sent points at its tracking host, and the provider deletes the host along with the domain — a tracking subdomain cannot even be removed on its own, precisely because live mail points at it. So releasing on the day a site is torn down does not merely stop future tracking, it retroactively breaks links for recipients who did nothing. The deadline is stamped on the record the first time the reaper sees the domain, so it terminates rather than restarting each pass, and the daily reap finishes the job once the window is up. An erasure never waits — a person asking to be erased outranks a link in somebody's inbox — and an untracked domain has no links to preserve and is released the same day, so the hold never spends a provider slot (see AGLYN_SENDING_DOMAIN_CAPACITY) on nothing.
AGLYN_EMAIL_MARKETING_CAP_PER_DAYOptionalRuntimeHow many marketing messages one person may receive from one site in a rolling 24 hours — a campaign, a member post, an abandoned-cart reminder, a back-in-stock alert and a workflow email all count toward it. Default 5, which is above the worst legitimate day and exists to stop a runaway (a workflow firing on every form submission, a member post published repeatedly). Whole number, 1–1000; a blank, unparsable or out-of-range value falls back to the default rather than switching the ceiling off, because a control a typo can disable is not a control. Over the ceiling, the send is skipped and nobody is removed — no contact is deleted, no audience trimmed, no unsubscribe recorded — and the cron sweeps retry on the next beat. Transactional mail is never counted or refused.
AGLYN_EMAIL_SUNSET_AFTER_DAYSOptionalRuntimeEngagement-based sunsetting, off unless you set it. A whole number of days, 30–3650. When set, an automated marketing message is skipped if this site has been mailing the address for longer than the window and the address has neither opened nor clicked anything inside it. A blank, unparsable or out-of-range value reads as off — the opposite of the cap above, because a typo there weakens a guard that is already on and a typo here would switch on a refusal nobody asked for. Nobody is removed: no unsubscribe, no suppression, no contact or list change, and the very next message after the person opens or clicks anything goes. Campaigns are exempt, like the frequency cap, because a campaign shows its recipient count before it is sent. Transactional mail is never affected.
EMAIL_UNSUBSCRIBE_SECRETFeatureRuntimeSee Secrets.
STAFF_ALERT_EMAILOptionalRuntimeOne internal inbox for platform-operations alarms — GDPR erasure due, Assist margin guard. One address, not a comma list. Unset, the alarms evaluate and mail nobody, with no error.

Alarm thresholds. All are integers, all fail to their default on a blank or unparsable value, and all are inert unless STAFF_ALERT_EMAIL, RESEND_API_KEY and USAGE_EMAIL_FROM are all set. Each accepts -1 as a deliberate forced-failure lever for proving the alert path works.

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

Sequences: a rep's own Google mailbox

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

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

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

VariableNeedWhenValue
GOOGLE_OUTREACH_CLIENT_IDFeatureRuntime, console onlyThe OAuth client id, …apps.googleusercontent.com. Unset — or with either variable below unset — Sequences → Mailboxes says connecting a Google mailbox is not configured on this deployment, and every mailbox route answers 503 with reason not-configured. Mailboxes already connected stop being able to send, because no token can be refreshed.
GOOGLE_OUTREACH_CLIENT_SECRETFeatureRuntime, console onlyThat client's secret. Used to redeem the authorization code at connect, to refresh each mailbox's access token before a Gmail call, and to revoke a grant when a mailbox is disconnected or its organization is erased. A secret Google refuses surfaces as client_misconfigured on every send rather than as a disconnected mailbox.
OUTREACH_TOKEN_KEYFeatureRuntime, console only32 random bytes, base64openssl 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.

Analytics and advertising

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

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

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

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

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

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

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

Sign-up attribution is built in, and needs nothing

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

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

See Adding a first-party surface.

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


AI

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

VariableNeedWhenValue
ANTHROPIC_API_KEYFeatureRuntimesk-ant-…, from the vendor's console under API keys. The key of the anthropic adapter.
AI_OPENAI_COMPAT_BASE_URLFeatureRuntimeThe base URL of an OpenAI-compatible endpoint (https://host/v1; a trailing /chat/completions is stripped). The openai-compatible adapter is ready only when this AND its key are set.
AI_OPENAI_COMPAT_API_KEYFeatureRuntimeThe bearer token for that endpoint.
AI_PROVIDEROptionalRuntimeWhich registered adapter is the platform default: anthropic or openai-compatible, or the id of a provider another plugin registers. Unset, the first ready adapter is the default. A workspace's pluginSettings/ai may pick its own.
AI_DEFAULT_MODELOptionalRuntimeA model id, served by the default provider, for every step kind. Unset, each step kind takes its catalog tier on that provider. An id absent from the built-in rate table falls back to approximate rates, so cost telemetry and the margin alarm become estimates — and the prompt-cache minimum moves with the model, so a swap can silently stop caching.
ASSIST_MODELOptionalRuntimeThe assistant's own override, above AI_DEFAULT_MODEL for the chat door alone — the incident-response lever the assistant has always honored.
ASSIST_FREE_DAILY_LIMITOptionalRuntimeMessages per free workspace per UTC day. Default 10.
ASSIST_ENTITLED_MONTHLY_LIMITOptionalRuntimeMessages per entitled workspace per month. Default 1000.
AI_FREE_DAILY_REQUESTSOptionalRuntimeAI requests one account may make per UTC day across all the Free workspaces it owns, counted at every AI door. Default 30. 0 means no free requests; junk and empty values take the default.
AI_FREE_DAILY_PLATFORM_CEILING_USDOptionalRuntimeCeiling on one UTC day of Free-tier provider spend across the whole deployment. At 80% staff are emailed; at 100% every Free workspace is refused AI generation until the day rolls, and paid workspaces are unaffected. Default 25. Empty, zero, negative and junk values take the default. There is no off value, so set a figure you are willing to spend. The email goes to STAFF_ALERT_EMAIL and is skipped when that is unset; the pause applies regardless.
AI_FREE_MIN_ACCOUNT_AGE_HOURSOptionalRuntimeHours an account must exist before a Free workspace it belongs to may generate with AI. Paid workspaces never check it. Default 24. 0 turns the check off, for example on an invite-only deployment; junk and empty values take the default.
ASSIST_ORG_MONTHLY_COGS_LIMIT_USDOptionalRuntimeDollar ceiling per workspace per month, measured against metered cost rather than an assumed cost per message. Default 40. The literal word off removes the ceiling. Junk, empty, zero and negative values all read as unconfigured and take the default, so a typo can neither open the ceiling nor close it to $0.
ASSIST_ORG_MONTHLY_COGS_ALERT_USDOptionalRuntimeDollar figure at which a workspace's spend raises a staff margin alarm, below the hard ceiling. Default 25. Delivery needs STAFF_ALERT_EMAIL and USAGE_EMAIL_FROM.
AI_FREE_MIN_ACCOUNT_AGE_HOURSOptionalRuntimeHow old an account must be, read off its Auth record, before a Free workspace it belongs to may generate. Default 24. Junk and an empty value take the default.
AI_FREE_DAILY_REQUESTSOptionalRuntimeFree requests one account may make per UTC day across its workspaces, counted at every reservation at either door. Default 30.
AI_FREE_DAILY_PLATFORM_CEILING_USDOptionalRuntimeThe platform-wide ceiling on one UTC day of Free-tier provider spend. Default 25. At 80% staff are mailed (STAFF_ALERT_EMAIL); at the ceiling every Free workspace is refused generation until the UTC day rolls, and paid workspaces are unaffected. There is no off: a deployment that wants no ceiling sets a figure it is content to spend.

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


Video delivery

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

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

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

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

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


Scheduled jobs

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

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

What CRON_SECRET being unset silently switches off:

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

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

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

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

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

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

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

Never let two schedulers run the same job

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


Plugins and the sandbox

VariableNeedWhenValue
PLUGIN_ARTIFACTS_BUCKETFeatureRuntimeThe separate GCS bucket holding executable plugin bundles — the code refuses to store executable code in the app bucket. Unset, plugin publishing and artifact serving both answer 501. A bare bucket name. Note the bucket is invisible to the Firebase console; manage it in the Google Cloud console.
PLUGIN_ARTIFACTS_BASEOptionalRuntimeOrigin the server-side remote-bundle loader fetches from. Falls back to NEXT_PUBLIC_PLUGIN_ORIGIN. Only relevant with remote server bundles enabled.
NEXT_PUBLIC_PLUGIN_ORIGINFeatureBuildFull origin the plugin sandbox iframe and bundles are served from, and the entry added to the CSP frame-src. Must be a different origin from the app — the cross-origin boundary is the sandbox. Unset, realm plugins do not load and installed executable plugins render as nothing, because the CSP has no entry for them.
PLUGIN_TRUST_PRIVATE_KEYFeatureRuntimeSigns realm trust grants. Base64 PKCS8 DER Ed25519, generated by tools/scripts/generate-plugin-trust-key.mjs. Unset, the grant action answers 501. Console only — never deploy it to a tenant runtime.
PLUGIN_TRUST_PUBLIC_KEYFeatureRuntimeThe server-side verification key, base64 raw Ed25519. Required with remote server bundles enabled; unset there, no bundle loads at all — it fails closed rather than degrading to a hash check.
NEXT_PUBLIC_PLUGIN_TRUST_PUBLIC_KEYFeatureBuildThe same public key again, for browser-side verification of realm bundles. A mismatch with the server-side name refuses every bundle.
PLUGIN_JOBS_SECRETFeatureRuntimeSee Secrets.

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

The sandbox loader service

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

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

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


Who runs this install

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

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

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

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

Renaming the product

VariableNeedWhenValue
NEXT_PUBLIC_PLATFORM_BRAND_NAMEOptionalBuildWhat this deployment calls itself: browser-tab titles, the installable app on a home screen, the relying-party name the OS shows when a user saves a passkey, transactional email, and the generator / x-powered-by fingerprint on every published site. Default Aglyn. Setting it also drops the Aglyn trademark line from the console footer — the source is Apache-2.0 and yours, the names are not.
NEXT_PUBLIC_PLATFORM_BRAND_LEGAL_NAMEOptionalBuildThe legal entity for copyright lines. Defaults to <brand> LLC, which is a US company form — set it explicitly if you are not one.
NEXT_PUBLIC_PLATFORM_SUPPORT_URLOptionalBuildWhere "need help?" links point. Falls back to NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL as a mailto: before it ever falls back to Aglyn's support page.
NEXT_PUBLIC_PLATFORM_HOME_URLOptionalBuildDestination of the "Made with …" badge on published free-tier sites. Unset on a renamed brand, the badge renders as plain text with no link.
NEXT_PUBLIC_PLATFORM_MARK_URLOptionalBuildThe square logo mark in that badge — a site-relative path is preferable, so it resolves on custom domains too. It sits on a dark pill, so supply a light variant. Unset on a renamed brand, the badge is text only.

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


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

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

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

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

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

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

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

Collection is a real feature, and jurisdiction-neutral

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

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

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

Filing follows the jurisdiction you configure

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

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

Setting it up

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

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

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

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

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


Caching, ISR, and running more than one replica

Single-container is the supported shape.

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

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

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

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

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

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


Domains: how a hostname becomes reachable

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

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

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

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

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

wildcard

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

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

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

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

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

webhook

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

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

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

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

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

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

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

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

For status, a state:

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

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

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

A typo must never read as success

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

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

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

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

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

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

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

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

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

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

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

vercel

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

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

Customer custom domains, per driver

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

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

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

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

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

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

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

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

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

Upgrade if your image predates the verification fix

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

Other Vercel variables

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

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

Documentation site build

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

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

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

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

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


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

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

Stamping which build you are running

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

Development and Aglyn-internal variables

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

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