Documentation Deployment
StateLens: deployment
On this page
StateLens is published as a container image,
statelens.azurecr.io/statelens — see The container image — and you
host it yourself. The image is the one thing that ships: the source is not published — see the
licence — so there is nothing to build and nothing to publish, on any
host.
Read Security before deciding where to put it. Operators sign in through your OpenID Connect provider, or with accounts the tool keeps itself — see Signing operators in — and what each may do is decided by a role: mapped from a claim the provider sends, by each service's manifest for that service or by the configuration for every service; or held by the account, when the accounts are the tool's. Until one or the other names a claim an operator holds, they see no service; map the Administrator role only to people you would give shell access on the host to, because an Administrator uploads assemblies this process will load and execute.
Run it locally
The image, on your own machine, open — nobody signs in — and pointed at a store. The registry is private, so the sign-in comes first, with the pull token your account holds — see The container image:
docker login statelens.azurecr.io --username <token>
docker run -p 8080:8080 \
-v statelens-data:/data \
-e Authentication__Disabled=true \
-e ConnectionStrings__Memoria="Host=host.docker.internal;Port=5432;Database=memoria;Username=reader;Password=…" \
statelens.azurecr.io/statelens:1.0.0
Then open http://localhost:8080. The store is named from inside the container, so a database on
the machine itself is host.docker.internal rather than localhost; a SQLite file is reached
through a mount, -v "$PWD:/store" and Data Source=/store/memoria.db. Point it at your own
store — see Configuration — or at the sample one, which
Try it with sample data fills for you. Running it open is for localhost alone — see
Running it open.
What the host has to provide
| Requirement | Why |
|---|---|
| A container runtime | The image carries its own runtime, and is the one form the tool ships in |
| Network to the store | The only external dependency there is |
| A writable directory that outlives a restart | Uploaded archives and assemblies are written under the content root unless Extensions:Directory moves them elsewhere, the header's branding unless Branding:Directory does, the tool's own settings unless Settings:Directory does, the accounts it keeps when told to unless Accounts:Directory does, and the keys behind the sign-in cookie and the form tokens go where the framework puts them unless DataProtection:KeysDirectory names a place — see Keeping uploads across restarts. The image points all five under /data |
Nothing else. There is no cache, no message broker, no background worker and no scheduled job.
A deployment whose settings could not be read stays up and answers every address with a page saying which setting is missing, at status 503 — see When it is not configured. A host's own error page in its place (App Service's "Application Error", say) means the process itself is not running, and the reason is in the host's log rather than the tool's.
Every page renders statically — all of their state travels in the query string — so no component
declares an interactive render mode and no Blazor circuit is opened. Ordinary HTTP proxying is
enough; nothing here needs a WebSocket today. GET /healthz answers OK to anyone, signed in or
not, and asks no store: it is the probe for a host that wants one, and says only that the process
is up and serving.
HTTPS
The pipeline calls UseHttpsRedirection always, and UseHsts outside Development. Terminating TLS at
a reverse proxy is the usual arrangement, and then the application has to be told what the proxy
saw: set ASPNETCORE_FORWARDEDHEADERS_ENABLED=true on the application and have the proxy send
X-Forwarded-Proto and X-Forwarded-Host. Without them the application sees http:// on an
internal name, the redirect to HTTPS fights the proxy, and — worse — the address the sign-in asks the
provider to send the operator back to is built from that wrong scheme and host, so the provider
refuses it as one it was never told about. See
forwarded headers
for a proxy that sends different header names.
Keeping uploads across restarts
Everything in the extensions directory is read again at start-up, so uploads survive a restart as
long as that directory does. Without a volume, every deployment starts with nothing installed and
everyone has to upload again. The header's branding is
kept the same way, in its own directory, or it goes back to StateLens's own name and mark on every
deployment; so are the tool's own settings, or how long
figures are kept goes back to 30 seconds; so are the
accounts the tool keeps when it is told to,
under Accounts:Directory, or every account is gone and the next start is the setup page again;
and so are the keys behind the sign-in cookie and the form tokens, under
DataProtection:KeysDirectory, or a restart signs every operator out and refuses every form that
was open. The image points all five under /data, so one volume keeps them all — see
The container image. On a host that keeps some other path across
restarts, name the five directories under it in the configuration instead.
Run one instance
Type registration lives in the process. Two instances behind a load balancer each register their own uploads from their own extensions directory, so the request after an upload can land on an instance that has never seen the assembly. Run one instance unless you have a reason not to; if you must run more, share the extensions directory between them and accept that an instance only picks up another's upload when it is restarted or someone refreshes the types on it.
There is no horizontal-scale case to make here. The tool is read-mostly, its queries are the store's, and the load it puts on a host is one operator at a time.
The container image
Every release is published to the product's own registry as statelens.azurecr.io/statelens,
tagged with its version (1.0.0), its minor (1.0) and, for a release that is not a pre-release,
latest; a pre-release (1.0.0-alpha) carries its own tag alone. The image is built for
linux/amd64 and linux/arm64, once the tests have passed, and is the only build there is: the
source is not published, so there is no checkout to build one from.
The registry is private. Pulling takes a pull token, which every account on statelens.dev holds, the free Community edition's included: a name and a password that read the tool's image and the seeder's and nothing else. Sign in with the token's name and give the password when asked — not on the command line, where a shell keeps it — and pull; a host that pulls for you is given the same pair as its registry credentials. The token is yours to regenerate, which retires the old password.
docker login statelens.azurecr.io --username <token>
docker run -p 8080:8080 \
-v statelens-data:/data \
-e ConnectionStrings__Memoria="Host=db;Port=5432;Database=memoria;Username=reader;Password=…" \
-e Authentication__Oidc__Authority=https://login.example.com/realms/memoria \
-e Authentication__Oidc__ClientId=statelens \
-e Authentication__Oidc__ClientSecret=… \
-e Authorization__Roles__Administrator=memoria-admins \
statelens.azurecr.io/statelens:1.0.0
What the image settles, so the host need not: it listens on 8080; it runs as a user that is not
root; it reads the forwarded headers, since it is meant to run behind whatever terminates TLS —
see HTTPS; and it writes everything it writes under /data — the extensions under
/data/extensions, the branding under /data/branding, the settings under /data/settings, the
accounts it keeps when told to under /data/accounts and the data protection keys under
/data/keys — so one volume mounted there keeps it all. The keys are
written in the clear, as the framework writes a file-system key ring, so the volume is to be kept
as the connection strings are: whoever can read it can forge a sign-in cookie. Every setting
in Configuration is given as an environment variable, : written
__. GET /healthz is the probe. The About page shows the version the image was built from; the
commit it was built from is stamped into the assembly's informational version, after the +,
where a support conversation can read it, and the page does not show it.
Deploy it on Azure
What follows is one worked example of hosting the tool, on an Azure App Service for Linux, not a recommendation of where; any host that meets the requirements above is as good, and the container image runs on any of them. App Service runs the image directly, and the settings below are the same ones any host gets, spelt the way App Service takes them.
The hosted instance, demo.statelens.dev, is this example: an App
Service for Linux running the same image, its settings set once by hand as below, and moved to
each new version by a workflow that runs one az webapp config container set and nothing else.
The one difference is how it pulls: the registry is the product's own, so the demo's App Service
pulls through an identity of its own that holds pull there (acrUseManagedIdentityCreds), where
yours is given your pull token as its registry credentials.
What Azure needs
An App Service for Linux running the image, a registration at the provider operators sign in through, and a Key Vault for the two values that are secrets. No step stores a password anywhere it can be read back.
az group create --name statelens --location westeurope
az appservice plan create --name statelens --resource-group statelens --is-linux --sku B1
az webapp create --name <app> --resource-group statelens --plan statelens \
--container-image-name statelens.azurecr.io/statelens:1.0.0 \
--container-registry-user <token> --container-registry-password <password>
The registry is private — see The container image — so App Service is
given your pull token as the registry's credentials: the name and password above, which land as
the DOCKER_REGISTRY_SERVER_* application settings, the password among them. Move the password
to a Key Vault reference with the other secrets, below, and give the
setting the reference instead.
Keep the plan at one instance — see Run one instance. Then the settings. They
all live on the App Service, as application settings: they are what makes this deployment this
one. The first three are about App Service; the five directories put what the tool writes under
/home, the one path App Service keeps across restarts and new images, since a container's own
file system is gone with it; the rest are the same ones any host gets — see
Configuration. Two of them are secrets and are set as references to a Key
Vault, below, rather than as their values.
| Setting | Value | Why |
|---|---|---|
ASPNETCORE_FORWARDEDHEADERS_ENABLED |
true |
App Service terminates TLS in front of the application — see HTTPS |
WEBSITES_PORT |
8080 |
The port the image listens on, which App Service otherwise guesses at |
WEBSITES_ENABLE_APP_SERVICE_STORAGE |
true |
Mounts /home into the container, kept across restarts and new images |
Extensions__Directory, Branding__Directory, Settings__Directory, Accounts__Directory, DataProtection__KeysDirectory |
/home/data/extensions and so on |
What the tool writes, under the one path that outlives the container — see Keeping uploads across restarts |
Databases__{name}__Provider |
Npgsql, SqlServer or Sqlite |
Only when the connection string of that name does not say which engine it is for |
Authentication__Oidc__Authority |
your provider's issuer | See Signing in through Entra ID, or your own provider |
Authentication__Oidc__ClientId |
what the tool is registered as | Public by design; the provider shows it to every operator who signs in |
Authorization__Roles__* |
the claim values that grant each role | Policy, not secret — see Roles |
APPLICATIONINSIGHTS_CONNECTION_STRING |
set by connecting Application Insights | Every line the tool logs about a write is then found in the portal — see Who did what |
Authentication__Oidc__ClientSecret |
a Key Vault reference | What the tool proves its registration with |
ConnectionStrings__{name} |
a Key Vault reference, one per store | One under each name the installed manifests read — Memoria for the samples — unless it carries no password — see below |
az webapp config appsettings set --name <app> --resource-group statelens --settings \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
WEBSITES_PORT=8080 \
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true \
Extensions__Directory=/home/data/extensions \
Branding__Directory=/home/data/branding \
Settings__Directory=/home/data/settings \
Accounts__Directory=/home/data/accounts \
DataProtection__KeysDirectory=/home/data/keys \
Authentication__Oidc__Authority=https://login.example.com/realms/memoria \
Authentication__Oidc__ClientId=statelens \
Authorization__Roles__Administrator=memoria-admins
Without the first, the address the sign-in asks the provider to send the operator back to is built
as http:// and refused. Without the directories under /home, uploads land inside the container
and the next restart removes them.
The connection strings are application settings here, ConnectionStrings__{name}. The App
Service's own Connection strings blade works too: a string set there arrives under the same
name, with a _ProviderName entry beside it that the tool passes over — see
Configuration.
Signing in through Entra ID
Any provider that publishes a discovery document will do — see Signing operators in — and Microsoft Entra ID is the one the subscription already has. Registering the tool there produces the authority, the client id and the client secret the settings above need, and the app roles that grant operators a role for every service — or that a service's manifest names, for that service alone.
Register the tool as a confidential web client, with both addresses the tool sends operators back to. Entra checks the post-sign-out address against the same list as the sign-in one, so both go in as redirect URIs:
az ad app create --display-name statelens --sign-in-audience AzureADMyOrg \
--web-redirect-uris https://<app>.azurewebsites.net/signin-oidc \
https://<app>.azurewebsites.net/signout-callback-oidc \
--query appId -o tsv
The appId it prints is Authentication__Oidc__ClientId. AzureADMyOrg admits accounts from this
tenant only; a tool that reads a production store has no reason to accept anyone else's. If
operators reach the tool through a custom domain, register that domain's two addresses as well —
Entra compares character for character.
Issue the secret, straight into the file the vault step below reads, so it is never on the screen or in the shell's history:
az ad app credential reset --id <appId> --display-name statelens --years 1 \
--query password -o tsv > client-secret.txt
Entra secrets expire — two years at most — and an expired one fails every sign-in with an error
from Entra, not from the tool. Note the date; rotating it is one credential reset and one
keyvault secret set, and the tool picks the new value up on its next restart.
The authority is the tenant's v2.0 issuer:
az account show --query tenantId -o tsv
Authentication__Oidc__Authority=https://login.microsoftonline.com/<tenantId>/v2.0
Define the roles as app roles on the registration. Entra sends the value of every app role
an operator holds in the roles claim of the ID token, which is the claim the tool reads by
default, so no RoleClaimType and no extra scope is needed. The values are what the settings map:
cat > app-roles.json <<'EOF'
[
{ "allowedMemberTypes": ["User"], "displayName": "Administrator", "value": "memoria-admins",
"description": "Installs, removes and rereads uploaded assemblies: runs code on the host.", "isEnabled": true },
{ "allowedMemberTypes": ["User"], "displayName": "Reader", "value": "memoria-readers",
"description": "Reads every service.", "isEnabled": true }
]
EOF
az ad app update --id <appId> --app-roles @app-roles.json
az ad sp create --id <appId>
Authorization__Roles__Administrator=memoria-admins
Authorization__Roles__Reader=memoria-readers
Then assign people to the roles. That is done on the service principal the last command
created, in the portal: Entra ID → Enterprise applications → statelens → Users and groups →
Add user/group, pick the operator or a group they are in, pick the role. A group works as well as
a person, and is the usual choice: membership of the group is then the whole of who may upload an
assembly. A team's own role — orders-team, say — needs no mapping in the settings: the team's
service names it in its manifest, and Entra sends it in the same claim.
Decide who may sign in at all. As registered, every account in the tenant can sign in — and sees nothing until a manifest or the settings name a role they hold. If only the assigned operators should get that far, require an assignment:
az ad sp update --id <appId> --set appRoleAssignmentRequired=true
Anyone else is then turned away by Entra before the tool sees them.
The tool asks Entra for openid profile email by default, which is enough: the name shown in the
log lines comes from profile, and the roles ride along without being asked for. Sign-out ends
both sessions, the tool's cookie and Entra's, and lands on the post-sign-out address registered
above.
Where the secrets live
The client secret and the connection strings go into a Key Vault, and the App Service reads them
from there through an identity of its own. The application is none the wiser: it still finds
Authentication:Oidc:ClientSecret and each ConnectionStrings:{name} in its configuration. What
changes is who can see the values. Anyone who can read the App Service's settings — the deploy
identity included — sees a reference, not a secret; rotation is one write to the vault; and the
vault logs every read.
az keyvault create --name <vault> --resource-group statelens --location westeurope \
--enable-rbac-authorization true
az keyvault secret set --vault-name <vault> --name oidc-client-secret --file client-secret.txt
az keyvault secret set --vault-name <vault> --name memoria-connection-string --file connection-string.txt
--file rather than --value, so the secret is not in the shell's history; delete the files after.
Then give the App Service an identity and let it read the vault:
az webapp identity assign --name <app> --resource-group statelens # prints its principalId
az role assignment create --assignee <principalId> --role "Key Vault Secrets User" \
--scope /subscriptions/<subscription>/resourceGroups/statelens/providers/Microsoft.KeyVault/vaults/<vault>
Key Vault Secrets User reads secret values and nothing else — it cannot list the vault's other contents, create, or delete. Finally, the two settings, as references:
az webapp config appsettings set --name <app> --resource-group statelens --settings \
'Authentication__Oidc__ClientSecret=@Microsoft.KeyVault(VaultName=<vault>;SecretName=oidc-client-secret)' \
'ConnectionStrings__Memoria=@Microsoft.KeyVault(VaultName=<vault>;SecretName=memoria-connection-string)'
A reference without a version, as above, follows the secret's current version: rotate it in the
vault and the App Service picks the new value up on its next restart, or within a day of its own
accord. The settings blade in the portal shows each reference with a green tick when the App
Service can resolve it and a red cross with the reason when it cannot — a missing role assignment,
a vault name typed wrong. Check it after the first deployment; a reference that does not resolve
reaches the application as the literal @Microsoft.KeyVault(…) string, which the application
reports as a provider it cannot connect to, not as a missing secret.
A connection string with no password
Against Azure SQL the connection string need not be a secret at all. Give the App Service's identity — the one just created — a user in the database, read-only as Pointing it at production data recommends:
CREATE USER [<app>] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [<app>];
and let the driver obtain its own token:
Server=tcp:<server>.database.windows.net,1433;Database=memoria;Authentication=Active Directory Default;
That string holds nothing worth protecting, so it is a plain application setting rather than a vault reference, and there is one secret in the vault instead of two. Azure Database for PostgreSQL can authenticate the same identity, but the Npgsql driver expects the token to be handed to it as the password and the tool does not do that today, so a Postgres connection string keeps its password and stays in the vault.
Moving to a new version
A new version is a new image tag, and nothing else changes: the settings, the vault references and
everything under /home stay as they are.
az webapp config container set --name <app> --resource-group statelens \
--container-image-name statelens.azurecr.io/statelens:1.1.0
App Service pulls the image and restarts the container; the About page then shows the new version.
Pin a version rather than latest, so a restart never moves you to a release you did not choose.
Signing operators in
The application signs operators in itself, and answers nothing — no page, no form post — to anyone who has not: through whichever OpenID Connect provider it is pointed at, or with accounts it keeps itself. See Configuration for what each setting is.
With accounts the tool keeps
docker run -p 8080:8080 \
-e ASPNETCORE_URLS=http://+:8080 \
-e ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
-e ConnectionStrings__Memoria="Host=db;Port=5432;Database=memoria;Username=reader;Password=…" \
-e Authentication__Local=true \
-v statelens-data:/data \
statelens:latest
The accounts live in /data/accounts/accounts.db, on the volume that keeps the uploads: lose the
volume and every account is gone with the uploads, which is the one reason to back it up. Or they
live in a database of your own — PostgreSQL, SQL Server or SQLite — named in
Accounts__ConnectionString the way a service names its store, by the name of a connection
string; the tool makes Identity's tables there, so give it an empty database or a schema of its
own — see Configuration. The
first visit, with no account yet, is the setup page, which makes the first Administrator; from
then on an Administrator makes every account on the Users page, and there is no registration. No
provider has to be registered and no address has to be told to anyone. A session lasts twelve
hours without a request.
Through a provider
docker run -p 8080:8080 \
-e ASPNETCORE_URLS=http://+:8080 \
-e ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
-e ConnectionStrings__Memoria="Host=db;Port=5432;Database=memoria;Username=reader;Password=…" \
-e Authentication__Oidc__Authority=https://login.example.com/realms/memoria \
-e Authentication__Oidc__ClientId=statelens \
-e Authentication__Oidc__ClientSecret=… \
-e Extensions__Directory=/data/extensions \
-v statelens-extensions:/data/extensions \
statelens:latest
At the provider, register the tool as a confidential web client with one redirect URI and one post-logout redirect URI:
https://<the address operators use>/signin-oidc
https://<the address operators use>/signout-callback-oidc
The first is where the provider sends the operator back after they sign in; the second, after they sign out, from where the tool takes them to its own signed-out page. The provider checks each against what was registered character for character. Both are built from the scheme and host the application sees, which behind a proxy is the reason for the forwarded headers above.
Sign-out ends both sessions: the tool's cookie, and the provider's own, so the next visit asks for credentials again rather than signing the same operator straight back in.
Any provider that publishes a discovery document qualifies — Microsoft Entra ID, Amazon Cognito, Google, Auth0, Okta, Keycloak, Zitadel, Authentik. The application never learns which; the choice of provider, and with it of cloud, is yours.
Map the roles. Signed in, an operator sees nothing until a claim the provider sends is mapped
to Reader or Administrator, or named by a service's manifest — see
Roles. Map Administrator
only to the people you would give shell access on the host to: an Administrator uploads an
assembly this process will load and execute. The provider has to send the claim, too — a group
claim, an app role, whatever it calls it — and the Scopes setting may need to ask for it.
A proxy that authenticates in front of the application stays perfectly valid — as a second gate, not as the only one. It is no longer what stands between the internet and an upload form that runs code; the application is.
How long a session lives
Through a provider, exactly as long as the identity the provider issued: the session cookie expires when the ID token does, and is not renewed on the quiet because more than half of it has gone by. Every page is rendered per request and every request is authenticated afresh by that cookie — there is no long-lived connection that could keep a session open past it.
So the provider's ID token lifetime is the knob. An operator you remove at the provider is out at their first request after their current token ends; set the lifetime to minutes if that has to be quick. An operator whose session ends mid-visit is sent to the provider to sign in and comes back to the page they asked for — a form they were in the middle of posting is not replayed.
The tool does not yet go back to the provider mid-session to check whether the operator is still welcome; the token lifetime is the whole of that guarantee.
Running it open
Authentication:Disabled=true runs the application with nobody signed in, the way
Run it locally does. It is a choice that has to be written down — an
application told neither this nor a provider refuses — and every start-up while it is in force
logs a warning saying so. Use it on localhost, or behind a proxy that authenticates every
request including the form posts, and nowhere else.
Pointing it at production data
Perfectly reasonable, with two precautions:
- Use a read-only account. The tool writes nothing, so
SELECTon the tables and views the manifest names is all it needs, and a database account that cannot write is the guarantee that holds whatever else is configured. - Expect the queries to be the store's queries. Data pages read the same tables the application does. They page rather than fetching whole streams, and against a relational store the list queries are run once in the background at start-up so nobody's first page load pays to build the model and compile them — but a wide filter over a large store is still a query against your production database. The total under a list's title is counted once and kept for thirty seconds, so paging through a list costs one count; against a store being written to, that number can trail the store by up to that long. The rows themselves are always read fresh.
The tool creates nothing and deletes nothing. The only write it can make is refreshing a snapshot — see the one thing it writes.
Indexes for a large store
The tool works against a store exactly as Memoria creates it, and on most stores that is fast enough. Its list pages, though, sort on columns the store's own reads never sort on, and Memoria deliberately indexes only what its own reads need — an index is paid for on every write, by every application using the package, and a diagnostic tool's list page is not a reason to tax them.
So none of these are in the package. If a data page is slow against a large store, add the index
that serves it out of band, as a DBA would, and build it concurrently (CREATE INDEX CONCURRENTLY
on PostgreSQL, WITH (ONLINE = ON) on SQL Server editions that offer it) so the build does not
block the application's writes:
| Page | Table | Index | Why |
|---|---|---|---|
| Streamed → Events → Data, unfiltered | DomainEvents |
(CreatedDate) |
The log is read newest first by the date it was appended, across every stream. The column never changes once written, so this is the cheap kind of index: each append lands at the end of it. |
| Streamed → Aggregates or Projections → Data | DomainAggregates, DomainProjections |
(UpdatedDate) or (CreatedDate), whichever column the page is sorted on |
Worth it only with hundreds of thousands of models: there is one row per model instance, so these tables are usually small beside the events. UpdatedDate changes on every save, so an index on it is rewritten on every save too — measure before adding it. |
| DCB → Aggregates or Projections → Data | DcbSnapshots |
(SnapshotKind, ModelType, UpdatedDate) |
The same caution, for the same reason. |
Two things not to index:
DcbSnapshots.TagQuery. It is stored without a length, which SQL Server will not index at all, and the filter box matches it with a leading wildcard, which no index serves anyway. The DCB detail page does not need it: it reaches a snapshot by the row's own key.DcbEvents.CreatedDate. The DCB log is read in position order, which is the table's key, so it needs nothing.
Who did what
Every line the tool logs about a write — an upload, a removal, a reread of the extensions, a snapshot refresh, and each of their failures — is filed under an event of its own and names the operator who asked for it, as the name the provider showed and the subject it keys them by:
info: StateLens.Settings[1001] Installed orders.zip, asked by Ada Lovelace (3f1c…).
info: StateLens.Streamed[1011] Refreshed the snapshot for Order in stream order:42 with id order-42:1, asked by Ada Lovelace (3f1c…).
So "who put that assembly on the host" is answered by the log the host already keeps. Running open,
the line says nobody (running open). Nothing else the sign-in carried — no token, no other claim —
reaches the log.
On App Service, connect Application Insights to the app and every one of these lines is exported to it, with the event name and each named value as a column — the operator's name and subject each as one of their own, on every request as well as on every write — so the question is answered from the portal rather than from the host's console — see Application Insights for the setting and a query, and What each write logs for the events.
Back to topRelated
- StateLens — what each page shows
- StateLens: configuration — the settings a deployment needs