Configuration
Essential settings for development and production environments
Looking for the Features: flags? They have their own table, with both the code
default and the value that actually ships, on
Architecture → Feature Flags.
💡 The full stack — MongoDB or PostgreSQL / SQL Server, Redis, the API microservice, and the background worker — ships in one product. Run the pieces you need; MongoDB single-process is the simplest default.
Database Configuration
"Database": {
"DatabaseType": "mongodb", // mongodb (default) | postgresql | sqlserver
"ConnectionString": "mongodb://localhost:27017",
"PlatformDatabaseName": "blazorblueprint-platform",
"TenantDatabasePrefix": "bb-org-"
}
Pluggable backend: DatabaseType selects the store — mongodb (default), postgresql, or sqlserver — and ConnectionString points at the matching backend. The platform database is named by PlatformDatabaseName (default blazorblueprint-platform); each tenant gets its own database, auto-named TenantDatabasePrefix + the organisation name (bb-org-acme). On PostgreSQL both are schemas rather than databases, with hyphens mapped to underscores.
Both names are set once, at first deploy. TenantDatabasePrefix is read only when an organisation is created, and every read afterwards routes off the name stored on that organisation — so changing it renames nothing that already exists, it only affects organisations created later. It is capped at 23 characters (the 63-character database-name limit less the 40-character organisation-name limit), and because it is written straight into CREATE DATABASE DDL it must be lowercase letters, digits, - or _ only, starting with a letter. It must also not be a prefix of PlatformDatabaseName — otherwise an organisation could be given the platform database's own name. Any of these stops the host at startup rather than at the first organisation.
PostgreSQL exception: once Database:UseMigrations is on, PlatformDatabaseName must stay at its default. EF migrations are compiled code and carry the platform schema name inside them, so a renamed platform database would migrate into the original schema and leave the application reading an empty one. The host refuses that combination at startup. MongoDB and SQL Server are unaffected — there the platform name is a database, not a schema baked into a migration.
Production: Use MongoDB Atlas or a self-hosted MongoDB, PostgreSQL, or SQL Server instance with authentication.
⚠️ Worth knowing before you pick. All three backends are feature-complete behind the same repository seam and all three are exercised against real database servers in CI. But MongoDB is the shipping default and the one with production mileage — the relational backends have not carried real-world load. They are not experimental, and every host boots on them; they are simply newer. If you choose PostgreSQL or SQL Server, plan a load test of your own rather than inheriting ours, and note that a switch after go-live is a data migration, not a config change.
Redis Configuration
"ConnectionStrings": {
"redisCache": "localhost:6379"
},
"Caching": {
"UseRedisDistributedCache": true,
"UseRedisSignalRBackplane": true
}
What Redis carries — five things, not one:
- The shared cache tier and the cross-instance eviction bus, so a save on one instance is visible to the rest immediately rather than after a TTL.
- The SignalR backplane, so Blazor circuits and hubs work across more than one instance.
- The Data Protection key ring — which is why users stay signed in across a deploy, and why losing Redis loses every
[Encrypted]field permanently. Back it up. See Security. - The job streams that carry background work to the worker, under
Messaging:Transport=Redis. - The shared rate-limit, job-claim and webhook-dedupe stores, which are what stop a second instance handing an attacker a second full budget or double-processing a webhook.
⚠️ Two settings on the Redis server matter as much as these keys. It must be
Redis 6.2 or newer (the reply channel uses GETDEL), and the instance
carrying the job streams must run maxmemory-policy noeviction — any
allkeys-* policy evicts queued jobs under memory pressure and loses the work silently.
Turning both flags off is the correct single-instance choice and is exactly what Lite does. It is not a valid multi-instance choice: without Redis every one of the five above falls back to a process-local implementation, so a second replica sees none of the first's writes.
Authentication Configuration
Configure the shared auth cookie domain and external login providers. Set Cookie:Domain to your platform's parent domain (with a leading dot) so the auth cookie is shared across tenant subdomains. It is mandatory whenever Features:SubdomainOrganisations is on — which is how the base configuration ships: outside Development the Web host refuses to boot with that flag on and this value blank, rather than silently issuing host-scoped cookies that log users out on every organisation switch.
"Authentication": {
"Cookie": {
"Domain": ".yourdomain.com"
},
"Facebook": {
"AppId": "your-facebook-app-id",
"AppSecret": "your-facebook-app-secret"
},
"Google": {
"ClientId": "your-google-client-id",
"ClientSecret": "your-google-client-secret"
},
"Microsoft": {
"ClientId": "your-microsoft-client-id",
"ClientSecret": "your-microsoft-client-secret"
}
}
Provider setup: Create an app in the Facebook Developer Portal, Google Cloud Console, or the Azure Portal → App Registrations. Register your platform domain as the OAuth redirect URI:
- Facebook: Valid OAuth Redirect URI =
https://yourdomain.com/signin-facebook - Google: Authorized Redirect URI =
https://yourdomain.com/signin-google - Microsoft: Redirect URI =
https://yourdomain.com/signin-microsoft(register as a Web platform in the Azure app registration). Uses the/commonendpoint by default, so the button accepts both personal Microsoft accounts and work/school accounts. - Facebook Data Deletion: Data Deletion Request URL =
https://yourdomain.com/api/Facebook/DeleteUserData
Only one redirect URI per provider is needed — tenant subdomains route their login flows through the platform host automatically. Credentials from appsettings are seeded into the platform organisation's External Authentication settings during initial setup and can be managed via Platform → External Authentication.
Organisations on their own custom domain must register their own Facebook/Google/Microsoft app and configure it via Admin → External Authentication. OAuth credentials are domain-bound, so the platform's app will not work on a custom domain — the login page simply hides those buttons until the org configures its own. This page is only visible to organisations accessed through a verified custom domain; on the platform host and tenant subdomains, external auth is managed at the platform level.
Email Configuration
"DefaultOrganisationSettings": {
"Email": {
"Enabled": true,
"Provider": "MailerSend", // MailerSend, SendGrid, SMTP, Mailgun
"ApiKey": "your-api-key",
"SenderEmail": "support@yourdomain.com",
"SmtpHost": "", // SMTP only
"SmtpPort": 587, // SMTP only
"SmtpUseSsl": true, // SMTP only
"SmtpUsername": "", // SMTP only
"SmtpPassword": "", // SMTP only
"Domain": "", // Mailgun only
"Region": "" // Mailgun only
}
}
AI & Automation Integrations
Four providers ship: OpenAI, OpenRouter, Ollama and
n8n. OpenRouter and Ollama are OpenAI-compatible and run through the same client, so
"OpenAI support" understates it — you can point the whole AI surface at a local Ollama and never call a
hosted API. Credentials are platform-shared (one set across every tenant) and managed at
/Platform/ApiIntegrations; the keys below only set addresses and the optional auto-seed.
"ApiIntegrations": {
"OpenAI": {
"BaseUrl": "https://api.openai.com",
"DefaultIntegration": { "Enabled": true, "ApiKey": "", "OrganizationId": "" }
},
"OpenRouter": { "BaseUrl": "https://openrouter.ai/api" },
"Ollama": { "BaseUrl": "http://localhost:11434" },
"N8N": {
"BaseUrl": "http://localhost:5678",
"DefaultIntegration": { "Enabled": true, "ApiKey": "" }
}
}
- Base URLs are config-only — they are deliberately not editable per integration in the admin UI. This is the key to set for a self-hosted Ollama or an OpenAI-compatible gateway on your own address.
- Only OpenAI and n8n auto-seed an integration row at first boot, and only when
DefaultIntegration.Enabledis true and an API key is present. OpenRouter and Ollama are added by hand at/Platform/ApiIntegrations— until you do, they simply do not exist. - Ollama needs no API key (
RequiresApiKey = false); it just needs to be reachable at its base URL. - Content moderation is OpenAI-only — the
/moderationsendpoint has no equivalent on the others. A deployment running Ollama alone has no moderation pass. - API keys are encrypted at rest, and every call goes through the shared HTTP policy stack (timeout, retry, circuit breaker, per-provider rate limit, audit row).
Time Zones & New-Organisation Defaults
Each organisation carries its own TimeZone (default UTC), set on its site
settings. Timestamps are stored in UTC and presented in the organisation's zone — so a report boundary, a
scheduled item or a daily bucket lands where that organisation's users expect it, not where the server
happens to be.
⚠️ Store UTC, present local. When you add a date-bucketed report or a scheduled feature, convert to the organisation's zone at the presentation boundary rather than reading the server's local time. A background sweep in particular has no ambient organisation and will otherwise silently bucket everything in the host's zone.
Culture is pinned deployment-wide rather than left to the host OS. A Linux container
without a LANG set otherwise falls back to the invariant culture, which quietly changes how
dates, decimal separators and string comparisons behave — the kind of difference that shows up as a parsing
bug in production and nowhere else. Pinning it means dev, CI and production agree.
A new organisation is seeded from a template row edited at
/Platform/OrganisationDefaults: theme mode, theme colour, time zone, launcher and
private-by-default. It is copied once, at creation — editing it later moves nothing that already
exists.
💡 "Default" means two different things here — keep them apart. A deployment setting's platform row is read live: change it and every organisation that hasn't overridden it moves with you. The organisation defaults above are a template: changing them affects only organisations created afterwards. The template deliberately carries no file references (logos, icons, OG images) — those resolve against the current tenant, so an inherited one would point at a row the new organisation does not have.
Relational Schema Management
MongoDB needs nothing here. On PostgreSQL or SQL Server there are two schema modes, and the choice is a boot-time gate rather than a preference:
"Database": {
"DatabaseType": "postgresql",
"UseMigrations": true // REQUIRED outside Development
}
⚠️ The host refuses to boot on a relational backend outside Development unless
UseMigrations is true. The default model-driven path creates tables once against
an empty schema and cannot evolve a live one, so a later model change would be silently skipped —
failing at startup is the deliberate alternative. Note the gate is "not Development", not "Production":
a Staging or otherwise-named environment is caught too.
With migrations on, the DbMigrator applies EF migrations to the platform database and to every tenant. History is per-tenant, so tenants migrate independently. Run it as a gated one-shot ahead of the app — the shipped pipelines already do, and a failure blocks the deploy before the running app is touched.
Per-Organisation SSO (OIDC)
Individual organisations can connect their own OpenID Connect identity provider (Okta, Auth0, Entra ID, etc.)
via Admin → SSO (/Admin/Sso). Credentials are stored on the Organisation
document so they can be resolved before tenant context is established.
Each configured organisation gets its own real authentication scheme —
oidc-org:{orgId}, with callback /signin-oidc-org-{orgId} — registered at runtime
from the organisation record. Users signing in via the org's SSO are matched to existing accounts by
email or provisioned on first login.
⚠️ One scheme per organisation, by necessity. A single shared scheme whose options are
swapped per request cannot work: OpenIdConnectHandler resolves its configuration manager
once, from the authority registered at startup, before request-time events run — so every challenge
fetches the placeholder authority and fails. If you extend this area, register a scheme per
organisation; never mutate options on a shared one.
Custom Domains & Verification
When Features:CustomDomainOrganisations is enabled, an organisation admin can set a custom
hostname on their organisation — one per organisation — and prove ownership via a DNS TXT record before the
domain is considered verified. Requests on a verified custom domain resolve directly to that organisation.
"DomainVerification": {
"TxtRecordPrefix": "site-verification=",
"TokenHmacSecret": "your-long-random-secret"
}
An organisation admin sets the domain under Admin → Organisation Settings, then publishes a
TXT record of the form {TxtRecordPrefix}{orgToken} on that domain. Verification is checked via
public DNS resolvers (Cloudflare, Google) both immediately on request and by a background job that re-checks
every few hours, with a small failure threshold before a previously verified domain is demoted.
💡 Cloudflare for SaaS is supported as an alternative. Configure it and custom
hostnames are provisioned through Cloudflare's SaaS API — which also gets you certificate issuance for
tenant domains, the part DNS verification alone does not solve. Leave it unconfigured and the provider
falls back to the DNS-TXT flow described here, so this works out of the box either way. Managed at
/Platform/CustomDomains and on each organisation's identity page.
The orgToken is deterministic: HMAC-SHA256(TokenHmacSecret, orgName + ":" + domain),
truncated to 24 hex chars. Re-creating the same org with the same domain after a dev-environment DB reset
produces the same token, so an existing DNS TXT record keeps verifying without touching the zone. Different
orgs get different tokens for the same domain (because orgName is in the hash), which means a
leftover TXT record from a previous tenant of a domain does not let a different org
auto-verify. TokenHmacSecret is required — set it to a long random string
(32+ chars of entropy) so tokens can't be pre-computed without the secret. It fails closed: if the
secret is blank, token generation throws rather than falling back to an unkeyed hash.
User Deletion & Grace Period
User account deletion is soft by default: a deletion request marks the user and blocks sign-in, but the
account is only purged once the grace window expires. During the grace window the user can sign in to
/Account/DeletionStatus and cancel the request. This also powers the Facebook Data Deletion
Request callback at /api/Facebook/DeleteUserData.
"BackgroundServices": {
"UserDeletion": {
"GracePeriodDays": 30
}
}
Background Messaging
One switch decides where background work runs. Messaging:Transport is either
Redis — jobs are published to a Redis stream and executed by the background worker, with the
answer handed back to whichever web instance is waiting — or InProcess, where the same handlers
run in the web process. That is the whole difference between the multi-process and single-process shapes;
there is deliberately not one flag per pipeline.
"Messaging": {
"Transport": "Redis", // Redis | InProcess
"ConsumerGroup": "workers",
"StreamConsumerConcurrency": 30, // messages a worker consumes at once
"LocalExecutorConcurrency": 10, // in-process execution cap
"ClaimIdleMs": 300000, // before another consumer may reclaim an in-flight message
"MaxDeliveryAttempts": 5,
"ReplyTtlSeconds": 300,
"WebhookDedupeTtlSeconds": 604800, // 7 days
"MessageSigningKey": "your-shared-secret-here",
"ConsumerGroupStartId": "0", // 0 drains an existing backlog; $ skips it
"Streams": {
"ApiJobsExpiring": { "Name": "api:jobs:expiring", "MaxLen": 25000, "MaxAgeHours": 24 },
"ApiJobsDurable": { "Name": "api:jobs:durable", "MaxLen": 100000, "MaxAgeHours": 24 },
"WebhookInbound": { "Name": "webhook:inbound", "MaxLen": 100000, "MaxAgeHours": 24 },
"DeadLetter": { "Name": "api:jobs:dead", "MaxLen": 10000, "MaxAgeHours": 720 }
}
}
Two job streams, and the split matters. api:jobs:expiring carries work that is
pointless once stale — AI generation, status polls, reads. api:jobs:durable carries work that
must never be silently dropped: charges, refunds, outbound email, statutory filings. They are siblings, not
a default and a variant. Which stream a job takes is decided by the handler's own max-age declaration, not
by a config key — so shortening a staleness window here can never quietly move a queued refund onto the
evictable stream. Each host logs the resolved durable-job list at boot.
MaxLen is not a memory budget being spent. Consumers delete each entry as they
finish it, so steady-state depth is roughly the in-flight count. It is outage insurance: the point at which
a write starts trimming the oldest entries — which, during a backlog, are the unprocessed ones.
Size it against the worst worker outage you intend to survive.
MessageSigningKey is required under the Redis transport outside Development,
and every host must carry the same value (32+ characters of entropy) along with the same
Caching:RedisKeyPrefix. A job envelope carries the organisation id that drives tenant routing, so
an unsigned stream would let anything with write access target any tenant. Each host logs a short fingerprint
of the key and its resolved stream names at startup, so a mismatch is visible by comparing two logs rather
than by noticing that nothing is ever processed.
Redis 6.2 or newer, and the instance carrying these streams must run
maxmemory-policy noeviction: any allkeys-* policy evicts job streams under memory
pressure, which loses queued work silently.
Initial Setup Access
Who may create the platform organisation and the first platform admin, on a deployment that has none yet. Both lists ship empty and the token is generated per process, so out of the box setup is reachable from loopback or with the token the app logs when it refuses an attempt.
"Security": {
"Setup": {
"Token": "",
"TrustedProxies": [],
"TrustedNetworks": []
}
}
Token— pin the setup token instead of using the generated one. Needed if you run more than one web replica, since a generated token lives in a single process.TrustedNetworks/TrustedProxies— addresses allowed to reach setup without a token. The deploy compose files set172.16.0.0/12here so first run in a container is one click; see Security → Initial setup for what that trusts.
All three stop having any effect once an organisation exists.
Application Service API Credentials
The Blazor Web app talks to the separate Application Service API (Services/BlazorBlueprint.InternalApi,
default port 8081) using a JWT client-credentials flow. Three values, and all three ship
blank — supply them by environment variable, container secret or
dotnet user-secrets, never in a committed file. The client secret is one shared value
written into both hosts: the Web app presents it, the API compares against it, so they must match.
💡 On a pipeline deploy you set one variable, not two. The keys below
are application configuration, not pipeline variables — no variable group or Actions secret is
ever named Services:InternalApi:ClientSecret. You supply
WEBAPP_CLIENT_SECRET, and scripts/render-deploy-env.sh writes it to
both Security__WebApp__ClientSecret and
Services__InternalApi__ClientSecret in the generated deploy.env. One
source, so the pair cannot drift — which is the whole point, since a mismatch is a 401 on every
internal call rather than an error anyone sees at deploy time. (__ is how an
environment variable spells a : to .NET configuration.) Setting them by their
config names is the local-development path, and
init-user-secrets.ps1 writes both from one value for the same reason.
// Read by the WEB host — it refuses to boot without this whenever
// Services:InternalApi:Enabled is true (i.e. every topology except Lite).
"Services": {
"InternalApi": {
"ClientSecret": "shared-client-secret" // same value as Security:WebApp:ClientSecret below
}
},
// Read by the INTERNAL API host — it refuses to boot without either.
"Security": {
"WebApp": {
"ClientSecret": "shared-client-secret" // the value it expects callers to present
},
"Jwt": {
"SecretKey": "jwt-signing-key" // 32+ characters, HMAC-SHA256
}
},
"Services": {
"InternalApi": {
"Url": "https+http://localhost:8081"
}
}
File Storage
💡 These are seed values only. They populate the single platform storage-settings
row the first time it is read; after that the row is the source of truth and is edited at
/Platform/StorageSettings. Changing a value here does not move an existing
deployment.
"Storage": {
"Provider": "", // LEAVE BLANK unless pinning AzureBlob or S3
"ServeMode": "Proxy", // Proxy | Direct
"MaxFileSizeMb": 50,
"DirectLinkValidityHours": 168, // Direct mode only; default and cap are both 7 days
"AzureBlob": { "ConnectionString": "", "ContainerName": "" },
"S3": { "Bucket": "", "Region": "", "AccessKeyId": "", "SecretAccessKey": "", "ServiceUrl": "" }
}
⚠️ Provider must stay blank unless you are deliberately choosing cloud
storage. Blank means follow the database backend — GridFS on MongoDB,
bytea on PostgreSQL, varbinary on SQL Server — and it is also what keeps
the active-provider reconciliation alive if Database:DatabaseType later changes.
Writing a literal disables that permanently, so pinning GridFs would make the very
first upload throw on a relational backend, where that provider is not even registered. Set it
only for AzureBlob or S3, which are opt-in and unreachable otherwise.
ServeMode—Proxy(default) streams every file through the app;Directreturns a time-limited signed URL instead. Only a member-visibility file on a cloud provider is ever redirected; GridFS and public files always stream. The signed URL bounds a copied raw link only — the stable/api/files/{id}link never expires.MaxFileSizeMb— enforced for seekable and non-seekable streams alike, so a chunked upload cannot slip past it.- Cloud credentials are secrets. Supply them by environment variable or user-secrets, or enter them at
/Platform/StorageSettings— never in a committed file.
Web Push (VAPID)
"Push": {
"VapidPublicKey": "",
"VapidPrivateKey": "", // secret — encrypted at rest once seeded
"ContactEmail": "" // the VAPID "sub"; a mailto address a push service can reach you at
}
One VAPID pair per deployment, not per organisation — there is no per-org concept to
inherit, which is why push is absent from the platform-branding list above. Like storage, these are
seed values read once into the platform settings row and managed at
/Platform/PushSettings afterwards.
⚠️ A partial configuration is treated as absent and logged. A mismatched public/private pair signs messages that no browser ever subscribed to, so every send fails — silently, from the user's point of view. Set all three or none.
Security Headers & Content Security Policy
"Security": {
"Headers": {
"Enabled": true,
"ContentSecurityPolicy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; ...",
"FrameOptions": "SAMEORIGIN",
"ReferrerPolicy": "strict-origin-when-cross-origin",
"EmbedFrameAncestors": "", // opt-in; see below
"EmbeddableRoutePrefixes": [] // opt-in; see below
},
"WebhookEgress": { "AllowPrivateNetworks": false }
}
The shipped CSP is the one you will edit first. It allows 'self' plus
the analytics host, so any third-party script, font, style or XHR endpoint you add — a
chat widget, a map, a payment element, a CDN font — is blocked until you name its origin here. The
symptom is a console-only CSP violation with nothing in the server log, so check the browser console
first when an added script "does nothing".
⚠️ Security:WebhookEgress:AllowPrivateNetworks is an SSRF control, not a
convenience. It guards every operator- or tenant-supplied outbound URL —
outbound webhook subscriptions and per-organisation SSO / OIDC authorities. Left
false (the default) it blocks loopback, link-local and RFC1918 targets, so a tenant
cannot point a webhook at an internal service and have this host fetch it on their behalf. Set it
true only for local development, and never in a deployment where
organisations can supply URLs.
Embedding is off everywhere by default. To let one route be framed on a customer's
site — a plugin's support widget, say — list its path prefix in
EmbeddableRoutePrefixes and put the allowed embedding origins in
EmbedFrameAncestors as a space-separated CSP source list
(https://customer-a.com https://*.customer-b.com). Only matched routes get the relaxed
frame-ancestors and lose X-Frame-Options; every other route keeps the
locked-down defaults. Either key alone changes nothing.
Rate Limiting, Lockout & Claims
"RateLimiting": { "PermitLimitPerMinute": 100 }, // global budget
"Authentication": {
"AuthRateLimitPerMinute": 60, // /Account/* — IP-only partition
"PasskeyRateLimitPerMinute": 30,
"Lockout": { "MaxFailedAttempts": 5, "LockoutMinutes": 15 },
"CacheClaims": true,
"SecurityStampValidationIntervalMinutes": 15,
"ForceHttpsScheme": true
}
RateLimiting:PermitLimitPerMinute— the global per-request budget for everything that is not a static asset, a Blazor/SignalR frame or a health probe. Partitioned by client IP and User-Agent, so it bounds one browser rather than one address. It is a DoS backstop; your edge proxy or CDN is the real control.AuthRateLimitPerMinute— the auth pages get their own, tighter, IP-only partition, precisely so that rotating the User-Agent cannot mint a fresh budget against a login form.Lockout— per-account lockout on repeated failures, counted in the shared rate-limit store so scaling out does not multiply an attacker's attempts.ForceHttpsScheme— leave on. Turn it off only for the rare deployment with no reverse proxy in front of it.
⚠️ CacheClaims buys throughput and costs revocation latency — know which you
need. On (the default) it caches per-organisation role claims on the auth cookie instead
of re-reading membership on every request, so a role or membership change is not reflected
until the claims refresh. A deployment that needs immediate revocation sets it
false and pays a per-request database read. This is a different question from
SecurityStampValidationIntervalMinutes, which governs how quickly a
sign-out or password change propagates — not role changes.
Cache Durations & Outbound HTTP Policy
"Caching": {
"DefaultCacheDurationSeconds": 60, // OutputCache only
"RedisKeyPrefix": "BlazorBlueprint",
"Policies": {
"VolatileSeconds": 60, // entitlements, private-workspace flag, plugin lookups
"SettingsSeconds": 300, // per-org configuration documents
"ReferenceSeconds": 3600, // platform-wide reference data
"MaxLocalSeconds": 60 // ceiling on serving from one instance's own memory
}
}
MaxLocalSeconds is the real bound on staleness when a cross-instance eviction broadcast
is missed, so keep it well below the three durations above it. RedisKeyPrefix is a
first-deploy decision: it is part of the Data Protection key-ring name, so changing
it on a running deployment points the app at an empty ring — see
Security, which also explains why the prefix is namespacing
rather than isolation between two deployments sharing one Redis.
"ApiClients": {
"Default": { "TimeoutSeconds": 30, "RetryCount": 3, "RateLimitPerSecond": 20, "CircuitBreaker": true },
"openai": { "TimeoutSeconds": 180, "AttemptTimeoutSeconds": 120, "RateLimitPerSecond": 5 },
"health": { "TimeoutSeconds": 10, "RetryCount": 0, "CircuitBreaker": false },
"MailerSendEmailProvider": { "RetryCount": 0 }
}
Every outbound provider call runs through this shared stack — timeout, retry, circuit breaker, per-provider rate limit and an audit row. Three of the shipped values are load-bearing rather than tuning: AI needs minutes, the health probe must have no breaker, and email must never retry, because a retried send is a duplicate email. The same rule applies to anything non-idempotent you add.
💡 Resolution order is most-specific-first: ApiClients:{name} →
the registering code's own defaults → ApiClients:Default. The middle tier is what
lets a plugin state "this provider must never retry" in compiled code rather than relying on a
JSON entry in a host it does not own — a JSON file can go missing silently, a compiled default
cannot. BaseUrl is the one key that does not inherit from
Default, since one shared address would point every client at the same host.
The Remaining Host Settings
"Localization": { "DefaultCulture": "en-GB" },
"Cors": { "AllowedOrigins": [] },
"AllowedHosts": "*",
"DataProtection": { "KeyRingPath": "", "AllowEphemeralKeys": false },
"Database": {
"CommandTimeout": 10,
"RetryPolicy": { "MaxRetries": 3, "DelayMilliseconds": 5000, "ExponentialBackoff": true, "MaxDelayMilliseconds": 15000 },
"SchemaVersionGate": { "Enabled": true },
"SuppressModelDriftCheck": false
}
Localization:DefaultCulture— pins currency and date formatting across every host and OS. It must be set: a Linux container with noLANGruns the invariant culture, which renders the generic currency box glyph instead of a pound or dollar sign. An unset or unrecognised value falls back toen-GB.Cors:AllowedOrigins— empty is the shipped state and means no cross-origin caller is allowed, which is correct for a Blazor Server app whose only API consumer is itself. Add an origin only if a browser client on another origin must call this host directly.AllowedHosts—"*"ships because the app resolves tenants by hostname and cannot know yours. Narrow it to your real hostnames once you know them; it is a cheap defence against Host-header games that your reverse proxy should also be doing.DataProtection:KeyRingPath— the no-Redis fallback (Lite sets/keys), and it must be a durable, backed-up volume. Leave it empty when Redis holds the ring.Database:SchemaVersionGate:Enabled— the per-tenant schema check on a relational backend. Leave it on: turning it off does not fix a schema problem, it just replaces a maintenance page with a rawrelation does not existon whichever page touches the missing table first.
⚠️ DataProtection:AllowEphemeralKeys is a development-only escape
hatch. An in-memory key ring is regenerated on every restart, so everything
written under the previous one — every [Encrypted] field, every auth cookie —
becomes permanently unreadable. It ships false; leave it there.
Keys That Belong To The Licensor, Not To You
Two sections in the shipped appsettings.json exist for the licensor's own
marketing site. Nothing strips them automatically, so check them before your first deploy:
"Legal": { "ContactEmail": "..." }, // the LICENSOR's address, shown on /licence
"Checkout": { "RepoUrl": "", "CommercialUrl": "", "TeamUrl": "", "AgencyUrl": "" }
Checkout:*drives the pricing buttons on/download. They ship blank on purpose — blank renders a disabled "Coming soon" placeholder rather than pointing your visitors at somebody else's checkout. Since the licence does not permit you to pass the template on as a template, the honest move for your own deployment is to delete/download,/licenceand the marketing home content and put your own product pages there.Legal:ContactEmailis the address of whoever granted the licence, shown only on/licencewhen the licence file cannot be read. It is deliberately not your support address —/documentation/supportreadsDefaultOrganisationSettings:SiteEmailfor that, because a deployment's support queries belong to whoever runs it. Leave it pointing at the licensor.
💡 Your own identity goes in DefaultOrganisationSettings:* — site
name, email, social links, analytics tag, sender address. Those are seeds, read only when
an organisation is created, so editing them later moves nothing that already exists. Change a live
organisation at /Admin/OrganisationSettings instead.
Ready to Build?
Configure it once and deploy with confidence. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.
🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included