Deployment: Docker & CI/CD — Blazor Blueprint
Login Register

💡 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 solution
dotnet build BlazorBlueprint.sln --configuration Release

# Run tests
dotnet test BlazorBlueprint.sln --configuration Release

# Publish for deployment
dotnet publish Web/BlazorBlueprint.Web/BlazorBlueprint.Web.csproj --configuration Release --output ./publish/web

Docker Build

# Build all images with Docker Compose (build context = repo root)
docker compose -f .azure/devops/docker-compose.yml build

# Or build an individual image
docker 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 stack
docker compose -f .azure/devops/docker-compose.yml run --rm blazorblueprint-dbmigrator
docker compose -f .azure/devops/docker-compose.yml up -d

# View logs
docker 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=blazorblueprint
export 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 reads
export 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 stack
docker compose -f .azure/devops/docker-compose.yml run --rm blazorblueprint-dbmigrator
docker compose -f .azure/devops/docker-compose.yml up -d

The 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 VERSION file plus the CI build number — e.g. 1.0.0.2517 (immutable, what Deploy pins), alongside floating 1.0.0 and latest tags. Bump VERSION for 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), then docker compose up and a /health check. 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 credential
JWT_SECRET_KEY # signs the internal API JWT
MESSAGING_SIGNING_KEY # HMAC key — identical on every host
DOMAIN_VERIFICATION_SECRET # HMAC for the DNS TXT verification token
COOKIE_DOMAIN # e.g. .example.com — scopes the auth cookie
DATABASE_PASSWORD # the database, whichever DATABASE_TYPE selects
REDIS_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-platform
TENANT_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 organisation
SOCIAL_FACEBOOK / SOCIAL_TWITTER / SOCIAL_LINKEDIN / SOCIAL_GITHUB
EMAIL_SENDER # outbound sender address
GOOGLE_ANALYTICS_TAG # optional, e.g. G-XXXXXXXXXX

COOKIE_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 start
export DOMAIN_VERIFICATION_TOKEN_SECRET=$(openssl rand -base64 32)
export DATABASE_USERNAME=blazorblueprint
export 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:1234
GHCR       ghcr.io/your-org       → ghcr.io/your-org/blazorblueprint-web:1234
Docker 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 lowercaseghcr.io/myorg, never ghcr.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 with write: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

  1. 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.
  2. 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.
  3. Variable group: Pipelines → Library → BlazorBlueprint-Production with REGISTRY_BASE (the image prefix, e.g. docker.io/<user> or myacr.azurecr.io), plus the secrets above (lock the secret ones).
  4. 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.
  5. Pipeline: New pipeline → Existing Azure Pipelines YAML → .azure/devops/build-pipeline.yml. Push to main to 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.mscvstsagent.*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

  1. No registry setup needed — images push to ghcr.io/<your-org> using the built-in GITHUB_TOKEN. To use Docker Hub or ACR instead, set the REGISTRY_SERVER, REGISTRY_USERNAME and REGISTRY_BASE variables plus a REGISTRY_PASSWORD secret.
  2. 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.
  3. Environment: Settings → Environments → production (add a required reviewer for a manual gate).
  4. Secrets & variables: Settings → Secrets and variables → Actions — add the secret keys above; put the non-secret config (client IDs, email/OpenAI toggles) under Variables.
  5. Deploy: push to main, or use "Run workflow" with an image_tag to 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_file handling 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 — any allkeys-* 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.

Your firewall must still block direct access to the published container port. Keep the app reachable only through the edge proxy. A client that can reach the port directly from a trusted address or subnet can spoof its own source IP and protocol, defeating rate limiting, IP pinning and HTTPS-only assumptions. This is the one assumption the hardening relies on that lives outside the code.

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 /health endpoint 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.

The app is architected for this, but the app-only compose file does not ship. You supply a compose without the database and Redis services, move 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:MessageSigningKey and Caching:RedisKeyPrefix on 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

An unhandled error has occurred. Reload

Connection lost

Trying to reconnect to the server…

Attempt 1 of 30, retrying in 0s

Could not reconnect

Check your internet connection, then try again.

Session expired

The server could not resume your session. Reload the page to carry on.