Starting & deploying a configuration
A Kandra configuration deploys as one ASP.NET Core application: the Acme.WebApi project hosts
the API and serves the Blazor WebAssembly client from the same origin. There is no
Kandra-specific deployment tooling and no hosting recipe for a particular platform (Azure, Docker,
IIS…) in this documentation — what follows is what dotnet publish actually produces, how it
behaves when run in Production, and a checklist of things the defaults will not do safely for
you.
The defaults are tuned for development. A freshly published instance running in Production
still seeds well-known user accounts, serves public API documentation, and accepts cross-origin
requests from any site. See Before you expose it.
Scaffolding a new configuration
This is covered step by step in How to start — the short version:
dotnet new install Kandra.ConfigTemplate --add-source https://nugets.kandra.tech/index.json
dotnet new kandra-config -n Acme --IncludeSqlite
or, with the interactive wizard installed
(dotnet tool install --global Kandra.ConfigCreateTool --add-source https://nugets.kandra.tech/index.json):
kandra-new-config
Kandra.* is served from Kandra's public feed, https://nugets.kandra.tech/index.json, which is
anonymous: no token is needed, on your machine or on your build server. The scaffold ships a
nuget.config that maps Kandra.* to that feed and everything else to nuget.org
(packageSourceMapping, which stops a same-named package on another source from winning a restore).
Keep that file in the repo, since the build server's dotnet restore reads it too; the full file and the
reasoning are in How to start.
Publishing
dotnet publish src/Acme.WebApi/Acme.WebApi.csproj -c Release -o ./publish
Acme.WebApi references the Blazor client project, so one publish produces both halves. The output
of publishing the reference configuration (KandraWms.WebApi; yours has the same shape) was about
190 MB in a Debug publish and contained:
| In the output | What it is |
|---|---|
Acme.WebApi.exe, Acme.WebApi.dll, *.runtimeconfig.json, *.deps.json | The server host |
Kandra.*.dll, Acme.*.dll, third-party assemblies | Engine, your configuration, dependencies |
wwwroot/_framework/ | The Blazor WebAssembly client (a few hundred files, each also pre-compressed as .br and .gz) |
wwwroot/css, _content/ | Static assets, including the UI library's |
appsettings.json (and appsettings.Development.json) | Configuration — see below |
db/ | Where the default SQLite database file will live (an empty folder in the output; see The data folder) |
web.config | Present for IIS hosting |
language folders (uk, de, …) | Satellite resources from dependencies |
A Release publish avoids the debug-only pieces (BlazorDebugProxy, .pdb files) that the Debug
publish captured above. The db/ folder is empty until the application first runs; the blob, key and backup folders
are created at runtime, under the data folder.
Running it
cd publish
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_URLS=http://localhost:5299 ./Acme.WebApi.exe
On start the application applies any pending migrations and seeds data (MigrateAndSeedAsync), so
the first run creates the database. The reference host started in Production and answered on the URL
above. The API (/api/v1/…), the interactive API documentation and login were confirmed working; the
client is served by the same host (UseBlazorFrameworkFiles plus a fallback to index.html).
ASPNETCORE_ENVIRONMENT=Production selects appsettings.Production.json overrides if you provide
one and turns on the exception handler and HSTS; it does not switch off any of the items in the
checklist below.
Health endpoints
The host exposes two anonymous probe endpoints, for container healthchecks, reverse proxies and Kubernetes/Azure probes:
| Endpoint | Answers 200 when |
|---|---|
/health/live | the process is up (no other checks run) |
/health/ready | the database is reachable and has no pending migrations |
Both reply with only the word Healthy or Unhealthy (503) — no details leak; the reason for an unhealthy
result goes to the application log. They are served over plain HTTP too: /health/* is exempt from the HTTPS
redirect, because probes can't follow one.
Chiseled/distroless images have no curl, so a command-based Docker HEALTHCHECK isn't an option there; point
the orchestrator's HTTP probe (Kubernetes livenessProbe/readinessProbe, Azure health probe) at these paths
instead.
Configuration
The host uses standard ASP.NET Core configuration, so every setting in appsettings.json can be
overridden by an environment variable, with __ for nesting — the usual way to keep secrets out of the
deployed files:
| Setting | Environment variable | Meaning |
|---|---|---|
Provider | Provider | Which database provider runs: Sqlite, SqlServer or PostgreSql. The other provider keys in the file (_Provider, __Provider) are just commented-out alternatives. |
ConnectionStrings:<Provider> | ConnectionStrings__PostgreSql | The connection string for the selected provider |
Kandra:DataPath | Kandra__DataPath | The one folder all local state lives under — see The data folder |
JwtOptions:Secret | JwtOptions__Secret | The signing key for login tokens. Leave it out and each install generates its own (see The JWT signing key) |
JwtOptions:Issuer / Audience | JwtOptions__Issuer / JwtOptions__Audience | Validated on every request |
JwtOptions:Expires / RefreshExpires | Token lifetimes (default 1 day / 14 days) | |
Kandra:Database:* | Kandra__Database__BackupsToKeep … | SQLite backups and recovery — see Backups before migrations |
BlobStorage:* | BlobStorage__LocalDiskRootPath … | Attachment storage — see File storage |
FeatureManagement:* | Feature flags | |
ReportResultCache:* | Report result cache TTL and size | |
Licensing:LicenseFilePath / LicenseValue | Licensing__LicenseFilePath | Where the signed license is read from |
Before you expose it
Items 2, 3, 4 and 6 were checked by running a published reference host in Production on a scratch
database. Items 5 and 7 are read from the shipped configuration and pipeline code.
-
Know where your signing key comes from.
appsettings.jsonships without aJwtOptions:Secret: on first start every install generates its own key intokeys/under the data folder, so two installs of the same build never share one. Anyone who holds a key can mint valid tokens for any user of that install, so keep the data folder private, and set your realIssuerandAudience. Running more than one node, or want a key you control? SetJwtOptions__Secretyourself — see The JWT signing key. -
Change or remove the seeded accounts. On start, the seeder creates any of these that are missing,
adminwith a fixed password that is part of the platform, the others with random ones:User Password Notes adminAdmin!1The administrator mcp-agent(random) A non-human caller account; it authenticates with an API key, not a password jobs-scheduler(random) Permanently locked out of interactive login, so it can't be signed into — it exists to attribute scheduled jobs Only
adminhas a known password;mcp-agentandjobs-schedulerget a random one nobody sees. A regular installation has no sample users: those come only with a demo installation, from your configuration's demo-user list (see Data import and demo data). A database created before that change keeps theuser0…user7accounts it already has, so check for them on an upgraded instance. Change theadminpassword immediately after first start. Because the seeder only creates a user if it is missing, changing a password sticks — but deleting a seeded user just gets it recreated at the next start (adminwith the default password). Lock or disable the accounts you don't want instead of deleting them, and never rely on their absence. -
Decide whether API documentation should be public. Swagger UI and
swagger.jsonare on by default in every environment, includingProduction, and answered without authentication. Switch them off withKandra__Swagger__Enabled=false, or restrict them at the reverse proxy. -
Tighten CORS if you host anything else on another origin. By default no cross-origin caller is allowed. The client is served from the same origin as the API, so it needs no CORS. To allow another origin, list it in
Kandra:Cors:AllowedOrigins(envKandra__Cors__AllowedOrigins__0,__1, …); only listed origins get credentials, and*is ignored. -
Terminate TLS properly. The pipeline calls
UseHttpsRedirection()and, outside development,UseHsts(), but the application does not configure a certificate. Use a reverse proxy or configure Kestrel's endpoint, and make sure the app can see the original scheme and HTTPS port behind a proxy, or the redirect has nothing to redirect to. -
Know which endpoints are anonymous.
GET /api/v1/About(installation id, version, license status) needs a signed-in user. The login endpoints and the EULA text stay anonymous. -
Choose a database you will actually run. SQLite is the everyday development provider and works for a single small instance. The SQL Server and PostgreSQL migrations are generated but rarely exercised against a live server in Kandra's own work, so rehearse the full path — migrations, seeding, your data — on that provider before relying on it. See Database migrations.
The data folder
All state a running instance keeps on its own disk lives under one folder, so a redeploy, an
update or a container restart has exactly one thing to preserve. Set it with Kandra:DataPath (in
appsettings.json, as the environment variable Kandra__DataPath, or on the command line). When it is
not set the folder is the application's own folder (the content root): dotnet run uses the project
folder, and a published app uses the publish folder no matter which folder it is started from. A
host that knows better can supply a default of its own through Kandra:HostDataPath (the desktop host
uses the per-user application-data folder). A relative value is placed under the application folder.
| Under the data folder | What it is |
|---|---|
db/ | The SQLite database — a relative Data Source in the connection string (default ./db/<name>.db) is placed here, not under the process's current folder. An absolute Data Source is used as written. Not used with SQL Server or PostgreSQL. |
blobs/ | Uploaded attachments (BlobStorage:LocalDiskRootPath, default blobs; a relative value is placed here; an install that already has App_Data/blobs and no blobs/ keeps using it). Losing it orphans every file reference in the database. |
keys/ | The generated JWT signing key. |
backups/ | The SQLite backups taken before migrations, and the reset record (below). |
The resolved folder is logged at start (Kandra data path: …). For a container, mount one volume and
point the setting at it: Kandra__DataPath=/data.
A host's Program.cs should create its builder with KandraWebApplication.CreateBuilder(args) instead of
WebApplication.CreateBuilder(args). ASP.NET Core takes the application folder from the process's
working directory, so a published app started from another folder (a shortcut, a service manager)
otherwise runs without its appsettings.json. The Kandra variant switches to the application's own
folder when the working directory has no appsettings.json and the application's folder does.
Also keep the license safe if you use LicenseFilePath. It is intentionally kept out of the
database, so restoring a backup on another machine does not carry a production license along with it.
With SQL Server or PostgreSQL the database itself lives on the server and must be backed up there.
The JWT signing key
If JwtOptions:Secret is not configured, the first start generates a random key and stores it in
keys/jwt-signing.key under the data folder (readable only by the application's user where the
operating system allows it). The same key is reused on every later start, so logged-in users survive a
restart. Delete the file to invalidate every issued token.
A configured value always wins: set JwtOptions__Secret to a long random string when several nodes
must accept each other's tokens (they would otherwise each generate a different key), or when you
manage secrets centrally. Never commit it.
Backups before migrations
On every start with the SQLite provider, if the database already exists and has migrations pending,
the application first writes a consistent copy to backups/kandra-<timestamp>-before-<migration>.db
(through SQLite's online backup, so changes still in the write-ahead log are included) and logs the path.
It keeps the newest five (Kandra:Database:BackupsToKeep; 0 turns backups off). A new database, or one
with nothing pending, gets none.
To restore: stop the application, copy the backup over the database file (and delete any
kandra.db-wal / kandra.db-shm next to it), start the previous version of the application. Other
providers: use the server's own backup tooling.
Migration history is a contract after release
Once installs hold real databases, the migration history you ship is what upgrades them. Do not squash the migrations after a public release — a database that recorded the old migrations cannot continue with a rewritten history. On start, such a SQLite database is detected (it contains migrations the build does not know), and the application refuses to start and says why, rather than failing half-way through a migration. Nothing is changed.
If you squash anyway — typically on a test or demo instance whose data you do not need — reset the SQLite
database by setting one of these where the application runs. They are ordinary settings, so they work
on a container you can no longer get into (Azure Application settings, a compose file, -e):
| Setting | Effect |
|---|---|
Kandra__Database__ResetToken=<any value not used before> | One-shot. On the next start the database is saved to backups/, deleted and recreated; the value is remembered in backups/reset-tokens.txt, so leaving the setting in place on later restarts does nothing. Use a new value to reset again. Works whether or not the database is incompatible. |
Kandra__Database__IncompatibleHistory=Reset | Standing policy. Whenever the database holds migrations the build does not know, it is saved to backups/, deleted and recreated. Remove the setting after the recovery unless you want this on every squash. |
Either way the old file is kept in backups/ (the newest backups are kept; copy it out if you need it
for good), and the blob and key folders are not touched. Both settings only act on SQLite and never run
for SQL Server or PostgreSQL.
Licensing
With no license configured, an instance runs as Free: everything works, within compiled-in usage
limits (the design document gives 5 users, 1,000 documents per month, 200 lines per document). Supply a
signed license through Licensing__LicenseFilePath (a path to a file holding the license string) or
Licensing__LicenseValue (the string itself, for environments where a file is awkward); the file path
wins when both are set. GET /api/v1/About shows the current status ("status":"free" in the checked run).
An admin can also manage the license from the Admin → License page.
Running more than one instance
Run one. Several pieces of state are per-process:
- the job scheduler keeps its schedule in memory and would fire every job on every instance (Job scheduler);
- the report result cache is an in-memory cache;
- blob storage defaults to local disk, which other instances cannot see;
- the JWT signing key is generated per install unless you set
JwtOptions__Secret— nodes must share one.
The platform has no multi-node story yet. Scale up before you scale out.
What is not documented here
Deliberately: Azure/AWS/IIS step-by-step, CI/CD pipelines and reverse-proxy configuration. Nothing in Kandra is specific to any of them — it is a standard ASP.NET Core application — and inventing a recipe that has not been run would be worse than saying so. When one exists that has been run end to end, it belongs on this page.
See also
- How to start — scaffolding and first run.
- Database migrations — how the database gets its schema, and
MigrateAndSeedAsync. - File storage and Job scheduler — the stateful pieces to plan for.
- Containerizing a configuration — the Dockerfile and compose files in a scaffold.
- Feature flags — turning capabilities on and off per environment.