StateLens StateLens

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.

Back to top

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.

Back to top

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.

Back to top

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.

Back to top

Pointing it at production data

Perfectly reasonable, with two precautions:

  • Use a read-only account. The tool writes nothing, so SELECT on 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 top
Back to top