💡 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.
Building
Local Build
# Build the entire solutiondotnet build BlazorBlueprint.sln --configuration Release# Run testsdotnet test BlazorBlueprint.sln --configuration Release# Publish for deploymentdotnet publish Web/BlazorBlueprint.Web/BlazorBlueprint.Web.csproj --configuration Release --output ./publish/webDocker Build
# Build all images with Docker Compose (build context = repo root)docker compose -f .azure/devops/docker-compose.yml build# Or build an individual imagedocker build -f Web/BlazorBlueprint.Web/Dockerfile -t blazorblueprint-web .docker build -f Services/BlazorBlueprint.InternalApi/Dockerfile -t blazorblueprint-internalapi .Deployment
Quick Deployment (Docker Compose)
# Render secrets first (see "Manual Deployment" below), then:# Only when your images come from a registry — set WEB_IMAGE / APP_IMAGE /# WORKER_IMAGE / MIGRATOR_IMAGE first. Skip this if you built them locally.docker compose -f .azure/devops/docker-compose.yml pull# Gated migration, then start the stackdocker compose -f .azure/devops/docker-compose.yml run --rm blazorblueprint-dbmigratordocker compose -f .azure/devops/docker-compose.yml up -d# View logsdocker compose -f .azure/devops/docker-compose.yml logs -f# Stop services (NEVER add -v in prod — it deletes the key ring + data)docker compose -f .azure/devops/docker-compose.yml down✅ What the stack includes: the complete production stack — MongoDB, Redis, Web, API, Background Worker, and a gated DbMigrator — with persistent named volumes and an isolated network. (In development the Aspire AppHost provisions the database container for you, whichever backend you selected; in production the database is yours to run or to point at.)
Manual Deployment (no CI/CD)
You can run the full production stack (MongoDB, Redis, Application Service, Background Worker, and Web) on a single host without a pipeline. Build the images, render the secrets file, then bring the stack up — the same steps the pipeline automates.
# 1. Datastore credentials. Export these FIRST — every compose command, `build`# included, interpolates the whole file and refuses without them.export DATABASE_USERNAME=blazorblueprintexport DATABASE_PASSWORD=$(openssl rand -base64 32 | tr -d '+/=')export REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '+/=')# 2. Build the images locally (or pull them from your registry)docker compose -f .azure/devops/docker-compose.yml build# 3. Export your app secrets, then render the git-ignored deploy.env the stack readsexport WEBAPP_CLIENT_SECRET=... JWT_SECRET_KEY=... MESSAGING_SIGNING_KEY=...bash scripts/render-deploy-env.sh .azure/devops/deploy.env# 4. Run the DB migrator (gated), then start the stackdocker compose -f .azure/devops/docker-compose.yml run --rm blazorblueprint-dbmigratordocker compose -f .azure/devops/docker-compose.yml up -dThe datastore credentials are shell environment variables, not deploy.env keys.
render-deploy-env.sh writes application configuration; these two are read by docker
compose itself to configure the database and Redis containers, so they have to be in the
environment for every command above. Keep them somewhere durable — Mongo applies them on first
start only, and losing them locks you out of your own data.
The tr -d '+/=' is load-bearing, not tidiness. Each password is
interpolated raw into a connection string that then gets parsed, and every format has its own
delimiters: / @ : are reserved in the
mongodb:// URI, ; ends a value in the PostgreSQL and SQL Server
keyword strings, and , ends one in the Redis configuration string. Plain
openssl rand -base64 32 emits a / in roughly half of its values, so
half of all setups would produce a Mongo password the driver refuses — and it fails
after Mongo has baked that password into its account on first start, so the fix is
wiping the data volume rather than choosing a new one. Stripping the three base64 extras leaves
41 alphanumeric characters, which every consumer takes verbatim.
⚠️ Set strong, unique secrets before going to production. scripts/render-deploy-env.sh fails closed if any required key is empty, and the app refuses to boot on the shipped dev-only-change-me-… placeholders:
Messaging:MessageSigningKey— HMAC key stamped on every job published to a Redis stream. A job envelope carries the organisation id that drives tenant routing, so anything that can write to the stream with a known key could target any tenant. Must be identical on every host.Security:Jwt:SecretKey— signing key for the JWT the Web app uses for InternalApi API calls.Security:WebApp:ClientSecret— client credential the Web app presents when requesting that JWT.- External auth provider secrets (
Authentication:Facebook:AppSecret,Authentication:Google:ClientSecret,Authentication:Microsoft:ClientSecret) — from each provider's dashboard.
Generate fresh values with openssl rand -base64 48 (or equivalent) and inject them via environment variables or your secret store — never commit real secrets to source control. The rendered deploy.env is git-ignored.
Production Considerations
- Persistent Storage: Docker volumes preserve data across deployments
- Environment Variables: Configure production settings via environment
- SSL/TLS: Add reverse proxy (nginx/Apache) for HTTPS
- Monitoring: Use built-in health checks and infrastructure monitor
- Backups: Regular database backups, configuration backups, and the Data Protection key ring — losing the ring makes every encrypted field permanently unreadable
- Scaling: single-node by default; run multiple instances behind a load balancer with a shared data tier, sticky sessions, and a single worker
- Background Processing: .NET Worker Service handles background tasks including push notifications, data cleanup, and AI workflows
CI/CD Setup
How the pipeline works
One multi-stage pipeline builds, tests, and deploys — no separate release step. The flow is identical on Azure DevOps and GitHub Actions, and the container registry is a setting you choose:
- Test — unit + relational integration tests and migration guards.
- Build — builds the four images (Web, InternalApi, BackgroundWorker, DbMigrator) and pushes them to your registry with a readable version tag from the repo-root
VERSIONfile plus the CI build number — e.g.1.0.0.2517(immutable, what Deploy pins), alongside floating1.0.0andlatesttags. BumpVERSIONfor a release. - Deploy — renders secrets into a git-ignored
deploy.env, pulls the exact image tag, runs the DbMigrator as a gated one-shot (a failure blocks the deploy before the app is touched), thendocker compose upand a/healthcheck. Rollback = re-deploy a prior image tag.
Where the stages run: the pipeline ships with Test + Build on a self-hosted Default agent pool and Deploy on your server (an Environment VM resource). To run Test/Build on free Microsoft-hosted Linux agents instead, set their pool: to vmImage: 'ubuntu-latest'. The shell steps are cross-platform (a Git-Bash guard handles Windows), so a Windows or Linux self-hosted agent works either way — each just needs Docker, and its account authorised for Docker (LocalSystem or the docker-users group on Windows; the docker group on Linux).
📋 What You Need: a container registry — Docker Hub, Azure Container Registry, GHCR, or a private one (your choice; keep the repositories private, since these images contain your compiled app) — and a server with Docker installed, registered as a self-hosted agent.
Secrets — one variable set, both platforms
Deploy-time secrets live in an Azure DevOps variable group (or GitHub Actions secrets), never in the pipeline files. The Deploy stage maps them into scripts/render-deploy-env.sh, which writes the git-ignored deploy.env that docker compose reads. Required (fail-closed) keys:
This list is the full stack. Lite runs neither the pipeline nor render-deploy-env.sh, and needs two of these — one of them under a different variable name. See Lite mode below.
WEBAPP_CLIENT_SECRET # Web ⇄ InternalApi client credentialJWT_SECRET_KEY # signs the internal API JWTMESSAGING_SIGNING_KEY # HMAC key — identical on every hostDOMAIN_VERIFICATION_SECRET # HMAC for the DNS TXT verification tokenCOOKIE_DOMAIN # e.g. .example.com — scopes the auth cookieDATABASE_PASSWORD # the database, whichever DATABASE_TYPE selectsREDIS_PASSWORD # a separate datastore — every backend, see below
Plus one non-secret variable: DATABASE_USERNAME. It is required on
MongoDB and PostgreSQL and has no default; SQL Server does not take one, because the image cannot
rename sa.
One credential pair, not one per backend. DATABASE_TYPE already
says which database this stack runs, so the credential describes the deployment rather than the
engine. Five names for what is always exactly one credential meant five chances to set the wrong
one, and switching backend meant renaming a secret rather than changing a value. Each image
keeps its own contract inside the compose file —
MONGO_INITDB_ROOT_PASSWORD, POSTGRES_USER /
POSTGRES_PASSWORD, MSSQL_SA_PASSWORD are all still spelled the way
their image demands, with the generic variable on the right-hand side.
REDIS_PASSWORD is separate, and required on every backend — not just
MongoDB. Redis carries the job streams and the Data Protection key ring, and
that key ring is stored unencrypted — so an unauthenticated Redis is every
[Encrypted] field in the deployment, plus the ability to forge auth cookies, plus a
FLUSHALL away from making all encrypted data permanently unreadable. It has no
username: --requirepass sets the password of Redis's implicit
default ACL user.
DATABASE_USERNAME is required and has no default, and that is deliberate.
It is half of one credential, not a label — and both images apply their account settings on an
empty data directory only, so the account name is baked into the store on first start
and no variable changes it afterwards. Left optional with a default, it was a blank field in the
deploy configuration, which is an invitation to fill in later — and filling it in is precisely
what breaks: the connection string and the health check start naming an account the server does
not have while the data directory keeps the original, the container never reports healthy, and
everything waiting on it waits for ever. Point at an external server with a real account by
overriding Database__ConnectionString or
ConnectionStrings__redisCache outright.
The datastore credentials are checked by a Validate deploy configuration job that runs alongside the tests, before anything is built or pushed — so a missing or unusable value costs seconds rather than a full test pass and four image builds, and every problem is named at once. It checks more than presence: the character rules above, whitespace, a minimum length, values that look like the placeholders this repository ships, and SQL Server's own complexity rule. It is a separate job rather than a step in the build precisely so it can run that early: the deploy secrets stay out of the jobs that run restore, the test suites and third-party analysers, and it skips itself on a pull request, where a fork can read no secrets at all.
The app secrets are checked one step later, by scripts/render-deploy-env.sh, which fails
closed on its own required list. The split is by kind, not importance: one checks what docker compose
interpolates to configure the database and Redis containers, the other checks what the app
itself reads from configuration.
The two database names — set once, at the first deploy
Both are optional pipeline variables (not secrets), and both default to what the stack has always used. Leave them unset unless you have a reason:
PLATFORM_DATABASE_NAME # default blazorblueprint-platformTENANT_DATABASE_PREFIX # default bb-org- — each org gets {prefix}{orgName}You need them when one Postgres or SQL Server instance carries more than one deployment, or when a house naming convention has to be honoured. Every compose file feeds both to all four services from the one variable, which is the point: the name the model qualifies its tables into and the name the repository routes reads to have to be the same string, and one service left on a different value is an empty database rather than an error.
These are first-deploy decisions with no second chance, like the volume names and
Caching:RedisKeyPrefix. Both are read only where an organisation's
DatabaseName is first assigned; everything after that routes off the stored
name. So changing either later moves nothing that already exists — the app simply starts
reading empty databases, and nothing reports an error at any point. Renaming an organisation does
not rename its database either, by the same rule.
Two constraints the app enforces at startup, and the deploy pipeline now names before it pulls an
image. The prefix must be at most 23 characters and match
^[a-z][a-z0-9_-]*$ — 23 is the 63-character database-name limit minus the
40-character organisation-name limit, and because a live tenant name is never truncated, an
over-long prefix fails at the first organisation creation, after the platform row has
been written. The two names must also not be able to meet: prefix
blazorblueprint- plus an organisation named platform composes the
platform database name exactly, which would put that tenant's data in the platform database and
make deleting the organisation drop it.
On PostgreSQL the platform name is not configurable at all. A migration is
compiled C# and bakes its schema in as a literal, so a renamed platform database would boot
clean, migrate "successfully" into blazorblueprint_platform, and then answer every
platform query against a schema with no tables. The host refuses that combination at startup
rather than doing it. On MongoDB and SQL Server the platform name is a database rather than a
baked schema, so it stays free.
Most other keys (OAuth, email, OpenAI, OpenRouter, N8N) fall back to appsettings.json when omitted. Site identity does not — it ships blank so a copy of this repository carries none, so set these to your own values:
SITE_EMAIL # seeded contact address for a new organisationSOCIAL_FACEBOOK / SOCIAL_TWITTER / SOCIAL_LINKEDIN / SOCIAL_GITHUBEMAIL_SENDER # outbound sender addressGOOGLE_ANALYTICS_TAG # optional, e.g. G-XXXXXXXXXXCOOKIE_DOMAIN is required, not optional. It is read at startup to scope the
authentication cookie, not seeded into a row. The full stack ships
Features:SubdomainOrganisations enabled, and outside Development
the Web host refuses to boot when that flag is on with a blank cookie domain — so
leaving it unset is a container crash-loop, not a host-scoped cookie.
render-deploy-env.sh now fails the deploy with that message instead. Set it to the
parent of every host the deployment answers on, with the leading dot
(.example.com); without the dot the cookie is scoped to that one host and tenant
subdomains sign users out. The only deployment that may omit it is one that also turns
Features:SubdomainOrganisations off on every host — which is exactly what
Lite mode (below) does. The rest of the keys above are seeds: they are read only when
an organisation is created, so changing one moves nothing that already exists — edit a live
organisation at /Admin/OrganisationSettings instead.
Lite mode — the single-process shape
The banner at the top of this page says "run the pieces you need". Lite mode is how:
docker-compose.lite.yml at the repo root declares two services — the Web app and
its database — and nothing else. No Redis, no separate API service, no separate worker. The
same job handlers run inside the web process, and the recurring sweeps run there too. Same code, one
container to operate — and one product feature turned off, below.
# Lite needs three values set before the stack will startexport DOMAIN_VERIFICATION_TOKEN_SECRET=$(openssl rand -base64 32)export DATABASE_USERNAME=blazorblueprintexport DATABASE_PASSWORD=$(openssl rand -base64 32 | tr -d '+/=')docker compose -f docker-compose.lite.yml up -d⚠️ DOMAIN_VERIFICATION_TOKEN_SECRET is required, and it is not the same variable
as the full stack's DOMAIN_VERIFICATION_SECRET above. The full stack renders that
one through scripts/render-deploy-env.sh; Lite does not use that script and reads the
environment directly, so it takes the container's own variable name. Both set the same underlying
DomainVerification:TokenHmacSecret, and outside Development the app refuses to
boot on the shipped placeholder — anyone holding a copy of this template could otherwise forge a
domain-ownership TXT-record token for any organisation. docker-compose.lite.yml uses the
${VAR:?message} form so docker compose up stops with a clear error if you
forget, rather than crash-looping on a .NET exception buried in the container's own logs.
DATABASE_PASSWORD and DATABASE_USERNAME are required the same way, and are the other two.
Lite publishes no port for its database, but "not published" is not "not reachable" — anything else
you later attach to that compose network reads and writes every tenant's data without a credential.
Neither has a default — the username is half of one credential, not a label. Mongo applies
MONGO_INITDB_* on first start only, so pick both once: changing either later
means changing the account inside Mongo too, and until you do, nothing connects.
Lite runs Features:SubdomainOrganisations off. It is the only feature
Lite disables, and only because the prerequisite is missing: serving each organisation on its own
org.platform.com subdomain needs wildcard DNS, a wildcard certificate and a
parent-scoped auth cookie, and a single container on a single hostname has none of them. Organisations
are selected through the org cookie on that one host instead, and custom domains still
work. If you do have wildcard DNS and a wildcard certificate, add both
Features__SubdomainOrganisations=true and
Authentication__Cookie__Domain=.yourdomain.com to the web service — the flag on its own
will not boot.
You do not set Messaging:Transport by hand. The compose file sets
ASPNETCORE_ENVIRONMENT=Lite, and appsettings.Lite.json supplies
InProcess along with the rest of the single-process configuration — one source of truth, so the
transport cannot disagree with the containers actually running.
🔑 With no Redis, the Data Protection key ring is not in Redis. Lite persists it to a
volume mounted at /keys, and that volume is the key ring. Losing it makes every
[Encrypted] field permanently unreadable and logs out every signed-in user. Back it up on a
schedule — a Redis-snapshot backup plan copied from the full stack backs up nothing at all here. Never
docker compose down -v in production.
Back it up by its real name: bb_lite_dp_keys. The compose file calls it
dp_keys internally, but that is only the service-local alias — the volume is pinned to
bb_lite_dp_keys on the host (the database is bb_lite_mongo_data). A backup job
keyed on the literal string dp_keys matches nothing. Check with
docker volume ls before you write it.
⚠️ Upgrade in place — do not unpack the new version into a new folder. Compose derives
its project name from the containing directory and prefixes every volume with it. Unpacking
1.0.1 into blazorblueprint-1.0.1/ beside blazorblueprint-1.0.0/,
which is the obvious way to upgrade, would hand you a brand-new empty key-ring volume while you
point at the same database — and in Lite that volume is the only copy of the key ring, so every
[Encrypted] field becomes unreadable and every session is invalidated, with nothing
reporting an error at any point. A directory rename or a git clone into a differently-named
folder does the same.
docker-compose.lite.yml defends against this by pinning all three names:
the project (blazorblueprint-lite) and both volumes
(bb_lite_dp_keys, bb_lite_mongo_data), so the stack adopts your existing data
whatever directory it is run from. Do not rename any of the three. Verify with
docker volume ls before your first deploy, so you find out now rather than after an upgrade.
Move to the full stack when you want background work isolated from request serving, want to scale the two independently, or want more than one app instance. Lite is a single-instance profile in a stronger sense than just "one container is enough": with no Redis there is no shared cache-eviction bus, no SignalR backplane, no shared rate-limit counter and no shared key ring, so a second replica would not see the first one's writes. Multi-instance also needs the separate worker, so that timer sweeps fire once rather than once per instance.
⚠️ Lite sets ASPNETCORE_ENVIRONMENT=Lite, which is not
Development — so the production guards apply in full: encryption must be
Required, and a relational backend must have Database:UseMigrations=true. That
is deliberate. Lite is a smaller deployment, not a laxer one.
⚠️ On a relational backend, run the DbMigrator yourself when you upgrade. Lite's
compose declares no migrator and no worker. The platform schema and any newly created tenant
are migrated by the web host itself, but an existing tenant schema is only brought forward by
the tenant sweep, which runs in the DbMigrator or the BackgroundWorker
— neither of which Lite starts. Database:MigrateTenantsOnStartup does not help either: only
the BackgroundWorker reads it. So after deploying a release carrying a new migration, existing tenants
get the schema gate's 503 maintenance page until you run the one-shot
blazorblueprint-dbmigrator image against the same database (the recipe is in
docker-compose.lite.yml, NOTE 5); the gate clears within seconds of it finishing. On
MongoDB none of this applies.
Pick your database backend
The stack runs on MongoDB (default), PostgreSQL, or SQL Server. Set DATABASE_TYPE in the variable group / Actions variables to postgresql or sqlserver and the Deploy stage picks the matching compose file (all three are equally pipeline-ready: gated migrator, secrets, pinned volumes). Every backend needs the same two secrets — DATABASE_PASSWORD and REDIS_PASSWORD — plus DATABASE_USERNAME on MongoDB and PostgreSQL. Switching backend changes the VALUES, never the variable names. The app images are backend-agnostic (the backend is chosen at runtime), so switching needs no rebuild — but switching a live deployment is a data migration, not just a setting change.
⚠️ On PostgreSQL or SQL Server, set Database:UseMigrations=true. Outside
Development the host refuses to boot without it: 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. This catches Staging and Lite too, not just
Production. MongoDB is unaffected. See
Configuration → Relational Schema Management.
Choosing your container registry
The registry is a setting, not baked into the pipeline. Create the matching service connection (Azure DevOps) or set the REGISTRY_* variables (GitHub Actions), then point REGISTRY_BASE at the image prefix — the pipeline appends /<image-name>:<tag> to it:
ACR myregistry.azurecr.io → myregistry.azurecr.io/blazorblueprint-web:1234GHCR ghcr.io/your-org → ghcr.io/your-org/blazorblueprint-web:1234Docker Hub docker.io/your-user → docker.io/your-user/blazorblueprint-web:1234- ACR takes no owner segment — the registry itself is the namespace. GHCR and Docker Hub both need the org/username segment.
- No trailing slash, and GHCR must be lowercase —
ghcr.io/myorg, neverghcr.io/MyOrg, or the push fails. - Azure DevOps → GHCR uses a service connection of type Others: URL
https://ghcr.io, your GitHub username, and a PAT withwrite:packages+read:packages. - You don't pre-create repositories — the four images appear on first push. GHCR packages default to private; on Docker Hub check the visibility yourself, since its free tier allows only one private repository and this pipeline pushes four.
Azure DevOps setup
- Service connection: Project Settings → Service connections → New → Docker Registry, choosing your registry type — Docker Hub, Azure Container Registry, or Others (for GHCR and anything else). Name it exactly
registry-connection— the pipeline references that literal name, because Azure DevOps resolves service connections at compile time and a$(variable)there fails validation. The name is just a label: re-point this one connection to switch registries. - Environment — not a Deployment Group: Pipelines → Environments → New →
BlazorBlueprint-Production→ Add resource → Virtual machines, then run the generated script on your server (a few minutes, mostly downloading the agent). YAML pipelines cannot target a classic deployment group — if you are migrating from a classic Release, leave the old deployment group in place; this installs its own agent alongside it. Add an approval check here for a manual gate. - Variable group: Pipelines → Library →
BlazorBlueprint-ProductionwithREGISTRY_BASE(the image prefix, e.g.docker.io/<user>ormyacr.azurecr.io), plus the secrets above (lock the secret ones). - Authorize the connection: on the first run Azure DevOps asks you to permit the pipeline to use
registry-connection— approve it, or pre-grant access from the connection's Security tab. - Pipeline: New pipeline → Existing Azure Pipelines YAML →
.azure/devops/build-pipeline.yml. Push tomainto build + deploy.
⚠️ Windows agent + Docker Desktop — the most common first-deploy failure. The VM registration script runs the agent as a service, defaulting to NT AUTHORITY\NETWORK SERVICE, which normally cannot reach Docker Desktop (it is per-user, exposed over the \\.\pipe\docker_engine named pipe). After registering, open services.msc → vstsagent.* → Properties → Log On, set an account belonging to the docker-users group, and restart the service. Skip it and the deploy runs fine until the compose step, then fails with cannot connect to the Docker daemon — which looks like a pipeline bug but is purely the service account.
💡 Define only the variables you use. Unused optional variables can be left out of the group entirely — the render script treats an undefined $(VAR) macro as unset, so the app falls back to appsettings.json. The required secrets listed above fail the run immediately if missing — the datastore ones named all at once by the Validate deploy configuration job, before anything is built or pulled.
GitHub Actions setup
- No registry setup needed — images push to
ghcr.io/<your-org>using the built-inGITHUB_TOKEN. To use Docker Hub or ACR instead, set theREGISTRY_SERVER,REGISTRY_USERNAMEandREGISTRY_BASEvariables plus aREGISTRY_PASSWORDsecret. - Self-hosted runner: Settings → Actions → Runners → New self-hosted runner, installed on your server (with Docker + Git Bash). Open port 8080 (Web) — and only that: the API, worker, database and Redis publish no host port at all.
- Environment: Settings → Environments →
production(add a required reviewer for a manual gate). - Secrets & variables: Settings → Secrets and variables → Actions — add the secret keys above; put the non-secret config (client IDs, email/OpenAI toggles) under Variables.
- Deploy: push to
main, or use "Run workflow" with animage_tagto roll back to a prior build.
What gets deployed
- Web Application: Blazor Server app on port 8080
- API Service: the internal API, on port 8081 inside the compose network only — it publishes no host port in either compose file, so it is unreachable from outside the deployment. A public or partner API belongs in the Web application, which is the only service that publishes one
- Background Worker: .NET Worker for async tasks and queue processing
- DbMigrator: one-shot schema/index runner, gated ahead of the app
- Database: MongoDB, PostgreSQL or SQL Server with persistent storage — internal to the compose network, never published to the host
- Redis: cache, SignalR backplane, Data Protection key ring, and the job streams that carry background work. Persistent storage, internal-only — it holds the key ring, so it is never exposed to the host
- Networking: isolated Docker network; data preserved in pinned named volumes across deploys
Production Environment
This section describes the full stack. It sits after Lite mode above but does not describe it: Lite publishes one port and runs no Redis and no API service, so the Redis and API lines below do not apply to it. Where a line differs, the Lite answer is called out inline.
Infrastructure Requirements
- Server: 4+ CPU cores, 8GB+ RAM, 20GB+ storage
- Docker: Docker Engine 20.10+ or Docker Desktop, with Docker Compose v2.24+ (required for the
env_filehandling the deploy relies on) - Network: port 8080 (Web) plus 80/443 on your reverse proxy — that is the whole list. 8080 is the only published port in any compose file: the API, the worker, the database and Redis are reachable on the compose network only. Do not open 8081 on the firewall; nothing listens on it from the host, and Redis in particular holds the Data Protection key ring. Lite is the same, with fewer services behind it
- SSL Certificate: For HTTPS (Let's Encrypt recommended)
Security Configuration
- Environment Variables: Store secrets in environment, not appsettings.json
- Database Security: Enable authentication and encryption-at-rest on whichever backend you run
- Redis Security: Configure Redis AUTH and disable dangerous commands. The instance carrying the job streams must run
maxmemory-policy noeviction— anyallkeys-*policy evicts queued jobs under memory pressure and loses the work silently. Not applicable to Lite, which runs no Redis - HTTPS Only: Force HTTPS redirects and secure cookies
- CORS: Configure appropriate CORS policies
First run in a container
A fresh deployment redirects to /initial-setup, and that page is gated: on a container
the request never looks like loopback, because the port is published and the app sees the Docker
bridge gateway. The compose files therefore name that network on the web service, which keeps first
run a single click:
- "Security__Setup__TrustedNetworks__0=172.16.0.0/12"
Understand what you are keeping: the app cannot tell the operator from anyone else — both arrive
from the gateway — so this trusts every caller that can reach the published port for as long as no
organisation exists. That is the right trade when the port is firewalled or private, and the wrong
one for a fresh instance sitting on a public address unattended. Delete the line and setup falls
back to a token the app logs at Warning the first time an attempt is refused:
docker compose -f .azure/devops/docker-compose.yml logs blazorblueprint-web | grep setupToken
Open the /initial-setup?setupToken=… URL from that line. Set
Security:Setup:Token to pin a value instead — necessary if you run more than one web
replica, since a generated token lives in one process. Full detail on
the security page.
Setting up over a domain rather than on the box? Use the token. The compose line
above only covers a request whose Host is localhost — and before setup
runs there is no configured domain anyway, because the platform organisation is what setup
creates. So either browse the server over localhost:8080 and enter the domain on the
setup form, or open /initial-setup?setupToken=… over the domain. A valid token
satisfies both halves of the gate; the peer lists never satisfy the host half.
Reverse proxy & forwarded headers
Behind a reverse proxy the app only sees the proxy's address unless you tell it which
proxies to trust. It honours X-Forwarded-For, -Proto and
-Host, and trust is additive: loopback
(127.0.0.0/8, ::1) is trusted out of the box — enough for a
proxy sidecar on localhost — and you widen it yourself:
"Security": {
"ForwardedHeaders": {
"KnownProxies": [ "172.18.0.5" ],
"KnownNetworks": [ "172.18.0.0/16" ]
}
}A proxy on a separate host or container — a cloudflared container on a Docker bridge, for instance — must be listed here. Miss it and the client IP is never rewritten, so every visitor shares one rate-limit bucket under the proxy's address. The headers cannot be spoofed from an untrusted address: the rewrite only happens when the immediate connecting peer is on the trust list.
Monitoring & Maintenance
- Health Checks: Monitor /health endpoint
- Infrastructure Monitor: Use the built-in dashboard at
/Platform/Infrastructure(platform administrators only). Full stack only — it keeps its cross-replica status cache and leader lock in Redis, so under Lite the page reports that it is not available in this deployment mode and probes nothing. Lite's equivalent is the/healthendpoint plus the container logs - Logging: Configure structured logging and log aggregation
- Backups: Automated database backups and disaster recovery
- Updates: Regular security updates and dependency updates
Performance Optimization
- Caching: Redis distributed caching is enabled by default. Lite turns it off (
Caching:UseRedisDistributedCache=false) and uses an in-memory tier instead, which is correct on a single instance and wrong on more than one - CDN: Use CDN for static assets and images
- Database: Indexes are provisioned for you — the DbMigrator reconciles them on every deploy
- Scaling: Compose-based single-node by default; Kubernetes-ready if you supply your own manifests (none ship in the box)
- Background Processing: Queue-based background processing enables horizontal scaling of worker services
- Compression: Enable gzip compression in reverse proxy
Scaling out to multiple instances
The shipped compose files are single-node: one server runs Web, the API and the worker alongside its database and Redis, with data in local named volumes. That scales vertically a long way, and it is the right default. When you outgrow one box, the single architectural change is to separate the app from its data — several stateless app instances behind a load balancer, all pointing at one shared data tier.
Database__ConnectionString and ConnectionStrings__redisCache into
your environment file, and point them at the shared tier. It is a small change, deliberately
left for you to build and test against the real topology rather than shipped untested.
These are the rules that bite if you get them wrong:
- Redis must be shared. It holds the Data Protection key ring, so every instance has to be able to decrypt the others' auth cookies and encrypted fields, and it carries the SignalR backplane. A per-instance Redis breaks logins across instances.
- Sticky sessions for the Web host. Blazor Server keeps each user's circuit in memory on one instance, so the load balancer must pin a user to theirs. The API is stateless and needs no affinity.
- Run one background worker. Its recurring sweeps are timer-driven, so two workers double-fire them — duplicate emails and reminders. Queue consumers are already distributed by consumer group and the stream janitor takes its own leader lock; the timer sweeps are the catch. Add leader election before running more than one.
- Identical
Messaging:MessageSigningKeyandCaching:RedisKeyPrefixon every instance. A mismatch produces no error at all — just jobs that are never processed. - Migrate once per deploy, as a single pre-rollout step against the shared database, not once per app instance.
- The database needs redundancy too — a MongoDB replica set, or one Postgres / SQL Server primary with read replicas as you grow. A standalone node is a single point of failure once the app tier is redundant.
On a self-hosted hypervisor such as Proxmox this maps straight onto VMs: one per app instance, one for the load balancer, and the data tier on its own private network with no published ports. Your VM snapshots then become the key-ring and database safety net.
Ready to Build?
Deploy on one box, or scale out when you need to. 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