Configuration reference
Every setting the API reads, what it defaults to, and what it refuses.
How configuration is read
Standard ASP.NET Core configuration, in this order, later sources winning:
Nelsar.Axia.Api/appsettings.jsonNelsar.Axia.Api/appsettings.{Environment}.json, e.g.appsettings.Development.json- Environment variables
- Command-line arguments
Neither JSON file is in the repository; copy them from the .example files next to them. The files accept // comments.
Environment variables use a double underscore for each level of nesting, and an index for array elements:
Ldap__Server=dc01.corp.example.com
RoleMappings__GroupDns__Admin="CN=Axia-Admins,OU=Groups,DC=corp,DC=example,DC=com"
Cors__AllowedOrigins__0=https://axia.example.com
DirectoryProtection__ProtectedGroups__0="cn=axia-admins,ou=groups,dc=example,dc=org"
The environment name comes from ASPNETCORE_ENVIRONMENT (default Production). It decides whether appsettings.Development.json is read, whether Swagger is served (Development only), and whether HSTS is sent (everywhere but Development).
A container image never contains appsettings.json. .dockerignore keeps it, the database and the logs out of the image, so the secrets in them cannot travel with it. Configure a container through the environment or a mounted file.
Settings are checked at startup. Most mistakes stop the API before it accepts a request, with a log line that names the setting and the fix. See What stops startup.
Quick index
| Section | Required | Purpose |
|---|---|---|
Jwt | yes (Key) | Token signing |
Encryption | yes (Key) | Encrypting stored bind passwords |
Ldap | yes in practice | The default directory |
RoleMappings | yes (GroupDns:Admin) | Which groups get which role |
DirectoryProtection | on OpenLDAP | Groups only an Admin may change |
Database | yes (Provider) | Which database |
ConnectionStrings | yes with PostgreSQL | Where it is |
Auth | no | Self-registration |
Cors | when the console is not on localhost | Which websites may call the API |
ReverseProxy | behind a proxy | Whose X-Forwarded-* to believe |
Https | when the API holds the certificate | HTTP → HTTPS redirect |
Console | no | Serving the console from the API itself |
Observability | no | Log sinks and correlation ids |
Serilog | no | Log levels |
Jwt
| Key | Default | Description |
|---|---|---|
Jwt:Key | none, required | HMAC-SHA256 signing key for bearer tokens. At least 32 bytes (UTF-8). Generate with openssl rand -base64 48. |
Jwt:Issuer | Nelsar.Axia.Api | Written into each token and required on the way back. |
Jwt:Audience | Nelsar.Axia.Api | Same, for the audience claim. |
Jwt:ExpiresInMinutes | 60 | Token lifetime. Tokens cannot be revoked, so this is how long a stolen one stays usable. Switching the account off in the directory ends its session sooner, within 30 seconds (see Access control). |
Refused at startup: missing key, a key containing CHANGE_ME (the published placeholder), a key under 32 bytes, an empty Issuer or Audience, a lifetime under one minute.
Changing the key signs out everyone: existing tokens no longer validate.
Encryption
| Key | Default | Description |
|---|---|---|
Encryption:Key | none, required | Base64 of a 16, 24 or 32-byte AES key. Used with AES-GCM to encrypt the bind passwords of directories stored in the database. Generate with openssl rand -base64 32. |
Refused at startup: missing, CHANGE_ME placeholder, not valid base64, or the wrong length.
There is no rotation procedure. With a different key, every stored bind password becomes unreadable; the affected directories report it (rather than crashing) until each password is entered again.
Ldap
The default directory, used by every request that does not name another one.
| Key | Default | Description |
|---|---|---|
Ldap:Server | empty | Host name or address. For LDAPS and StartTLS it must match a name in the server’s certificate. Empty means there is no default directory (all directories come from the database); sign-in without a directory then answers 502 “no server configured”. |
Ldap:Security | Ldaps | Ldaps (TLS from the first byte), StartTls (plain connection upgraded; fails rather than continuing unencrypted), or None (plain LDAP; every password crosses the network readable; logged as a warning on each connection). |
Ldap:Port | 636 for Ldaps; 389 for StartTls/None | Leave unset for the standard port. A port that contradicts the mode stops startup (LDAPS on 389, StartTLS/None on 636). 3268/3269 (Global Catalog) are accepted but read-only: every write is refused. |
Ldap:TrustedCaCertificatePath | empty | PEM or DER file with a CA (or a self-signed server’s own certificate) to trust besides the OS store. Applies to every StandardLdap directory, including those in the database. The host name is still checked; verification cannot be turned off. An unreadable file stops startup. |
Ldap:BaseDn | empty | Where searches start. Also where new accounts are created (see Directories). |
Ldap:AdminUsername | empty | The bind account: a DN (cn=admin,dc=example,dc=org), or for AD a UPN (svc-axia@corp.example.com). Every operation is performed with it. It can never be changed through the application; that protection recognises it by DN or by its login attributes, so avoid the DOMAIN\name form, which AD accepts but the protection cannot match. |
Ldap:AdminPassword | empty | Its password. Keep it out of files in production: Ldap__AdminPassword. |
Ldap:UserSearchFilter | (&(objectClass=person)(\|(uid={0})(sAMAccountName={0})(userPrincipalName={0})(mail={0}))) | How a login typed at sign-in is looked up, tried first. {0} is replaced by the escaped login. The default covers OpenLDAP (uid), AD (sAMAccountName, userPrincipalName) and e-mail. When it finds nothing, the API also tries mail, userPrincipalName, sAMAccountName and uid on their own (see Directories), so narrowing this filter does not by itself stop people signing in by those. |
Ldap:ProviderType | StandardLdap | StandardLdap (any OS, any LDAP v3 directory) or ActiveDirectory (Windows only; refused at startup elsewhere; no group management). |
Ldap:UserSearchPageSize | 500 | Page size for directory-wide listings (RFC 2696 paged results). Keep it at or below the server’s own limit (AD: MaxPageSize, 1000 by default). |
Ldap:ConnectTimeoutSeconds | 10 | How long to wait for a directory to accept a connection, the TLS handshake included. 1 to 300; anything else stops startup. Applies to every directory, including those in the database; an Ldap:Providers:{id} section may set its own. |
Ldap:OperationTimeoutSeconds | 30 | How long to wait for a directory to answer any one operation — a bind, a page of a search, a write — and the time limit each search hands to the server. 1 to 300. Same scope as above. A directory that does not answer in time is reported as unavailable (502). |
Ldap:Providers:{id}
Extra directories declared in configuration rather than in the database, selected by X-LDAP-Provider-Id: {id}. Each takes the same keys as Ldap, plus:
| Key | Description |
|---|---|
Ldap:Providers:{id}:Servers | Comma-separated hosts tried in order (failover). Falls back to Server. |
These are checked only when a request names one; a broken one is reported then, not at startup. The console’s directory picker lists only directories stored in the database.
RoleMappings
| Key | Default | Description |
|---|---|---|
RoleMappings:GroupDns:{Role} | none; Admin required | Full DN of the directory group whose members get {Role}. Roles: Admin, UserCreator, UserEditor, UserDeleter. |
RoleMappings:AllowMappingsByName | false | Whether old mappings that name a group by CN alone count. Leave off: such a mapping matches every group of that name anywhere in the directory. |
At each start:
- Fresh database: one global mapping is created per entry in
GroupDns. - Existing database: any global mapping still written by name for a role listed here is converted to the DN given, once, and saved. Changing
GroupDnslater does not move a mapping that is already a DN; edit it under Role mappings or through the API. - An error is logged for every mapping still written by name, on every start.
- Startup is refused if no mapping grants Admin by DN (unless
AllowMappingsByNameis on).
Refused at startup: an unknown role name, or a value that is not a well-formed DN.
The API checks that each value is a well-formed DN, not that the group exists. A wrong DN starts fine and grants the role to nobody.
DirectoryProtection
| Key | Default | Description |
|---|---|---|
DirectoryProtection:ProtectedGroups | [] | DNs of groups that, together with their members, only a caller holding every permission may change. |
Active Directory needs nothing here: adminCount=1 already marks Domain Admins and the other protected groups and their members, and the application honours it. OpenLDAP marks nothing, so list the groups that make someone an administrator of the directory, and the group mapped to Admin.
Database and ConnectionStrings
Where the API keeps what is not in the directory: the directories it connects to, role mappings, role permissions, rate limits and the audit trail.
| Key | Default | Description |
|---|---|---|
Database:Provider | none, required | PostgreSql or Sqlite, in any case. |
ConnectionStrings:DefaultConnection | required with PostgreSql; Data Source=nelsar-axia.db with Sqlite | The connection string, in the form the provider’s driver takes. |
PostgreSQL is the database for a deployment. Any supported PostgreSQL version works; the quick start and CI use 17. The account needs to create tables in its database on the first start, and whenever an upgrade brings a migration:
Database__Provider=PostgreSql
ConnectionStrings__DefaultConnection="Host=db.internal;Database=nelsar_axia;Username=nelsar_axia;Password=…;SSL Mode=VerifyFull"
Use SSL Mode=VerifyFull whenever the server is on another host. GSS encryption is off unless the connection string names it (GSS Encryption Mode=Prefer): the API’s image carries no Kerberos library, and the driver would otherwise look for one on every new connection.
SQLite keeps everything in one file and needs no server. The unit tests run on it, and it is still a choice for a single small instance, or for running the API from source without a database server. Relative paths are relative to the working directory:
Database__Provider=Sqlite
ConnectionStrings__DefaultConnection="Data Source=/data/nelsar-axia.db"
There is no default provider. Versions before 0.3 used SQLite without being told to, so a configuration written for one of them names a SQLite file and no provider. Rather than guess, the API stops and says so: set Sqlite to go on with that file, or copy it into PostgreSQL first — see Moving to another database.
Migrations are applied automatically at startup, each database from its own set.
Auth
| Key | Default | Description |
|---|---|---|
Auth:AllowSelfRegistration | false | Whether POST /api/v1/auth/register creates a directory account for an unauthenticated caller. Off, and worth leaving off: only the rate limiter stands in front of it. Administrators create accounts with POST /api/v1/users. |
Cors
Which websites may call the API from a browser.
| Key | Default | Description |
|---|---|---|
Cors:AllowedOrigins | [] | Bare origins: https://host[:port], no path, no trailing slash, no wildcard. Empty means loopback only (localhost, 127.0.0.1, any port). Naming any origin replaces the loopback default; list both if you need both. |
Cors:AllowAnyOrigin | false | Admit every website. * in the list is refused; this switch is the only way, so that it is a deliberate choice. |
Refused at startup: an entry with a path, a trailing slash, a wildcard, or empty.
CORS decides which web pages can read a response in a browser. It is not a network control and does nothing against non-browser clients.
When the console is served from the same origin as the API, as in the quick start’s nginx, CORS is not involved at all.
ReverseProxy
| Key | Default | Description |
|---|---|---|
ReverseProxy:KnownProxies | [] | Proxy IP addresses whose X-Forwarded-For and X-Forwarded-Proto are believed. |
ReverseProxy:KnownNetworks | [] | The same, as CIDR ranges (10.0.0.0/8). |
Until the proxy is named, every request appears to come from the proxy’s address: one rate-limit bucket shared by everyone, and the proxy’s address in the audit log. Naming an address that is not really your proxy lets callers choose their own rate-limit bucket.
Https
| Key | Default | Description |
|---|---|---|
Https:RedirectToHttps | false | Redirect plain HTTP to HTTPS. Leave off behind a TLS-terminating proxy, which talks HTTP to the API and would be redirected to itself. Turn on when the API holds the certificate. |
HSTS is sent outside Development regardless.
The API listens where ASP.NET Core is told to: ASPNETCORE_URLS or ASPNETCORE_HTTP_PORTS (the container image defaults to port 8080), or the launch profile in development (5063).
Console
| Key | Default | Description |
|---|---|---|
Console:Path | unset | The folder holding the built console (index.html and assets/). Relative paths are relative to the folder the API’s binaries are in. |
Unset, the API serves the console when a wwwroot folder with an index.html is next to its binaries, and the API alone when there is none — the container image and a development checkout have none, and nothing changes for them. Set, the folder has to hold the console, or startup stops.
Serving the console from the API makes one process on one port the whole application, with no nginx — for an installed package or a Windows service. The console’s files get the headers the console image’s nginx sends (its Content-Security-Policy, Cross-Origin-Opener-Policy, Permissions-Policy and the rest); everything under /api/ keeps the API’s own. Files under /assets/ are named after their content and may be cached for good; index.html is revalidated on every load, so an upgrade reaches browsers at once.
The console has to be built to call the API on its own origin, then published with the API:
cd frontend && VITE_API_SAME_ORIGIN=true npm run build && cd ..
dotnet publish Nelsar.Axia.Api -c Release -o publish -p:AxiaConsoleDist=../frontend/dist
publish/wwwroot then holds the console.
Started as a Windows service or a systemd unit, the API reports to the service manager. As a Windows service its working directory is the folder of its binaries, so relative paths in configuration — Observability:File:Path, a SQLite database — are taken from there, not from System32.
Observability
| Key | Default | Description |
|---|---|---|
Observability:ApplicationName | Nelsar.Axia.Api | Attached to every log event. |
Observability:Console:Enabled | true | Log to stdout. |
Observability:Console:Json | true | Compact JSON (for collectors). false gives readable lines, with control characters in values written as escapes (\n, \u001b) so a value cannot start a line; the Development example uses that. |
Observability:File:Enabled | true | Rolling file sink, compact JSON, one file per day. |
Observability:File:Path | logs/axia-.json | File name pattern; the date goes before the extension. Mount it in a container. |
Observability:File:RetainedFileCountLimit | 14 | Days of files kept. |
Observability:File:FileSizeLimitBytes | 134217728 (128 MB) | Per-file cap; rolls to a new file when reached. |
Observability:Logstash:Enabled | false | Ship to Logstash’s HTTP input. |
Observability:Logstash:Uri | none | e.g. http://logstash:8080. |
Observability:Logstash:QueueLimitBytes | 33554432 (32 MB) | In-memory buffer; oldest events are dropped rather than blocking the API. |
Observability:Logstash:BatchSizeLimitBytes | 5242880 (5 MB) | Per request. |
Observability:Logstash:BatchPostingLimit | 1000 | Events per request. |
Observability:Logstash:PeriodSeconds | 2 | Flush interval. |
Observability:Loki:Enabled | false | Ship to Grafana Loki. |
Observability:Loki:Uri | none | Base URI, e.g. http://loki:3100; /loki/api/v1/push is appended. |
Observability:Loki:Labels | {} | Static labels. Keep them few and low-cardinality. |
Observability:Loki:Username / Password / Tenant | none | For a Loki behind a gateway. Supply the password through the environment. |
Observability:Correlation:HeaderName | X-Correlation-ID | Request header read for an incoming correlation id, and response header it is returned in. |
More than one sink may be on. Keeping the file sink under a shipper means the log survives the shipper being down.
Serilog
Only log levels are read from here; sinks are chosen under Observability.
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
The three overrides are also the built-in defaults. EF Core’s SQL is held at Warning because directory bind DNs and the encrypted-secrets column pass through those statements.
Unused keys
LdapBootstrap:EncryptedBindPasswordExample in the Development example is not read by anything. AllowedHosts is ASP.NET Core’s host filtering (* by default).
Settings stored in the database
Some configuration lives in the database and is changed through the API or the console, without a restart:
| What | Where | Picked up |
|---|---|---|
| Additional directories | /api/v1/providers | within 30 s (resolution cache) |
| Role mappings | /api/v1/role-mappings | within 30 s (permission cache) |
| What each role may do | /api/v1/roles | within 30 s |
| Rate limits | /api/v1/rate-limit-settings | within 30 s, as new windows start |
What stops startup
| Log message (abridged) | Fix |
|---|---|
Jwt:Key is not set / …still the placeholder… / …shorter than 32 bytes… | Generate one: openssl rand -base64 48. |
Encryption:Key is not set / …not valid base64 / …decodes to N bytes… | Generate one: openssl rand -base64 32. |
Database:Provider is not set | PostgreSql with a connection string, or Sqlite to keep a database from 0.2 or earlier. See Database. |
Database:Provider is PostgreSql, and ConnectionStrings:DefaultConnection is not set | Name the server. |
Ldap:Security is Ldaps … and Ldap:Port is 389… | Use 636, or Security: StartTls, or (knowingly) None. |
Ldap:ProviderType is ActiveDirectory, which cannot use StartTls | Use Ldaps, or StandardLdap. |
ActiveDirectory … Windows | Use StandardLdap on Linux/macOS. |
RoleMappings:GroupDns names the role 'X', which does not exist | Roles are Admin, UserCreator, UserEditor, UserDeleter. |
RoleMappings:GroupDns:X is '…', which is not a distinguished name | Give the full DN. |
No role mapping grants the Admin role by distinguished name… | Set RoleMappings:GroupDns:Admin. |
a Cors:AllowedOrigins entry quoted with its corrected form | Use a bare origin. |
a TrustedCaCertificatePath problem | Point it at a readable PEM/DER file. |
Console:Path is '…', and '…' has no index.html | Point it at the built console (frontend/dist), or remove it to serve the API alone. |
Troubleshooting has more, including failures after startup.