CMS configuration
Every setting of the CMS API with its default, range and meaning, how to set it (appsettings, environment variables, Docker Compose), and the values Docker Compose reads from `.env`. For admins and developers.
On this page
- How settings are read
- Required settings
- Settings by section
- Storage
- ContentRepository
- Converter
- Seed
- App
- Passkeys
- Cors
- DataProtection
- KeyVault
- Invites
- RateLimiting:MagicLink
- Documents
- Publishing
- Webhooks
- Tokens
- Git:Remote and GitHub
- Remotes
- Trash
- Assets
- MarkdownImport
- Anthropic and Suggestions
- AiPricing
- Startup
- Monitoring
- Health endpoints
- Docker Compose
- The .env file
- The services
- Production notes
- What’s next
The CMS API is an ASP.NET Core 10 application. It reads its settings from the usual .NET configuration sources. This page lists every setting, what it defaults to, and what the API does at startup when a value is missing or out of range. For a first installation, start with Install the CMS. This page is the reference you come back to.
How settings are read
The API combines these sources. Later ones win.
appsettings.json: defaults for everything that has a safe default (storage containers, token expiry, the AI price list).appsettings.Development.json: values for a laptop (SQL Server and Azurite onlocalhost, Mailpit on port 1025).- User secrets (development only): for secrets such as the SQL password.
appsettings.Development.jsonholdsSET_IN_USER_SECRETSas a placeholder. - Environment variables.
- Azure Key Vault, only when
KeyVault:Uriis set. See KeyVault.
Section names are written with a colon in this page, for example Storage:ConnectionString. As an environment variable, replace each colon with two underscores: Storage__ConnectionString. Lists use an index: Cors__Origins__0, Webhooks__Urls__0. A dictionary key is a path segment: Webhooks__WorkspaceUrls__acme__0.
The classes that read settings use data annotations and check them when the application starts (ValidateOnStart). A bad value stops the API at startup with a message that names the setting. It does not fail on the first request.
Tip
Run docker compose logs cms-api after changing a setting. A validation error is the first thing in the log.
Required settings
These have no usable default. The API does not start without them.
| Setting | Why |
|---|---|
ConnectionStrings:Cms | The SQL Server connection. A missing value stops startup with “Connection string ‘Cms’ is missing.” |
Storage:ConnectionString or Storage:ServiceUri | How to reach Blob Storage. Set exactly one: the connection string (Azurite locally) or the Blob endpoint, used with the managed identity (Azure). With both or neither the API does not start. |
Storage:PublicBaseUrl | The public address of the public container. It must be a URL. |
Email:From | The sender address. It must be an email address. |
Converter:BaseUrl | The MarkItDown converter. It must be a URL. |
App:EditorBaseUrl | The address of the editor. It must be a URL. Used in links in mail. |
Passkeys:ServerDomain | The domain that passkeys are bound to. |
Email:Resend:ApiKey is needed when Email:Provider is Resend.
Settings by section
Storage
Where uploaded files live. Two containers: a private one for assets that are not published, and a public one for published assets.
| Setting | Default | Meaning |
|---|---|---|
Storage:ConnectionString | none | Blob Storage connection string. UseDevelopmentStorage=true with Azurite. Contains a key; in Azure prefer Storage:ServiceUri. Exactly one of the two is set. |
Storage:ServiceUri | none | The Blob service endpoint, for example https://mdlakeprodst.blob.core.windows.net. The API signs in with Microsoft Entra ID and holds no key. The identity needs the role Storage Blob Data Contributor on the account. Exactly one of this and Storage:ConnectionString is set (blank counts as not set); it must be an absolute https URL. |
Storage:ManagedIdentityClientId | none | Client ID of the user-assigned managed identity to use with Storage:ServiceUri. Empty: DefaultAzureCredential decides (AZURE_CLIENT_ID, the system identity, your own sign-in). Only valid together with Storage:ServiceUri. |
Storage:PrivateContainer | private-assets | Container for unpublished files. |
Storage:PublicContainer | public-assets | Container for published files. |
Storage:PublicBaseUrl | none | The public address of the public container. It ends up in each asset’s sidecar as url and is what sites download from. See Lake format. |
ContentRepository
| Setting | Default | Meaning |
|---|---|---|
ContentRepository:Path | data/git | The folder that holds one bare Git repository per workspace: the lake. In Docker it is /data/git, mapped to ./data/git. |
ContentRepository:Branch | main | The branch the CMS publishes to. Letters, digits, dot, underscore and hyphen, up to 100 characters. |
Magic links and invitations are sent by mail.
| Setting | Default | Meaning |
|---|---|---|
Email:Provider | Smtp in code, Resend in appsettings.json | Smtp or Resend. Chosen once at startup. |
Email:From | none | The sender. |
Email:ReplyTo | none | Optional. The Reply-To address on every mail the CMS sends (magic links, invites). It must be an email address; empty counts as not set. For example Email:From noreply@your-domain and Email:ReplyTo support@your-domain. |
Email:Smtp:Host | localhost | SMTP server. Locally, Mailpit. |
Email:Smtp:Port | 1025 | SMTP port. |
Email:Resend:ApiKey | empty | The Resend API key, for the Resend provider. Keep it in a secret store. |
In Docker Compose the CMS uses Smtp with the mailpit service, and every message lands in the Mailpit inbox at http://localhost:8025.
Converter
| Setting | Default | Meaning |
|---|---|---|
Converter:BaseUrl | http://localhost:8000 in appsettings.json; http://localhost:8100 in Development; http://markitdown:8000 in Compose | The MarkItDown service that turns Word, PowerPoint, PDF and Excel files into Markdown. The call times out after two minutes. |
Seed
| Setting | Default | Meaning |
|---|---|---|
Seed:AdminEmail | none | The first administrator. When no administrator exists, the CMS creates this user (or promotes the existing user with this address). The administrator has no password: sign in with a magic link sent to this address, then register a passkey. |
Seed:DemoWorkspace | false in code; true in Compose | Import the demo workspace “mdlake” once. |
Seed:DemoContentPath | none | A folder with demo content to import, instead of the built-in one. |
App
| Setting | Default | Meaning |
|---|---|---|
App:EditorBaseUrl | none | The address of the editor, for example http://localhost:5173. Links in mail point here. It is also an allowed origin for passkeys. |
App:TrustForwardedHeaders | false | Trust X-Forwarded-For and X-Forwarded-Proto from the proxy in front of the API. Turn it on behind Azure’s proxy, so rate limits see the visitor’s address. Leave it off when the API is reachable directly, because a client could forge the header. |
Passkeys
| Setting | Default | Meaning |
|---|---|---|
Passkeys:ServerDomain | none | The domain that passkeys are registered for, without scheme and port: localhost, or cms.example.com. Changing it later invalidates every registered passkey. |
The allowed origins of passkey ceremonies come from App:EditorBaseUrl and Cors:Origins, reduced to scheme, host and port. When the browser’s origin is not in that list, the log says “Passkey ceremony refused … Check App:EditorBaseUrl and Cors:Origins”.
Cors
| Setting | Default | Meaning |
|---|---|---|
Cors:Origins | none | A list of editor origins that may call the API from a browser: Cors__Origins__0=http://localhost:5173. Needed when the editor runs on another origin, as the Vite server does locally. Not needed in production, where the API serves the editor and both share one origin. |
DataProtection
| Setting | Default | Meaning |
|---|---|---|
These keys protect the session cookie, the passkey ceremony state and the invite and confirmation tokens. Lost keys sign everyone out. Set exactly one of KeysPath and BlobUri. |
| Setting | Default | Meaning |
|---|---|---|
DataProtection:KeysPath | unset | A folder for the key ring. Compose mounts a volume at /data/keys. The keys are stored in the clear, so the volume is the protection. |
DataProtection:BlobUri | unset | The absolute https address of the blob that holds the key ring, for example https://<account>.blob.core.windows.net/dataprotection/keys.xml. The container must exist. The API creates the blob on first use. The identity needs Storage Blob Data Contributor. In Development an http address is accepted. |
DataProtection:KeyIdentifier | unset | The https address of the Key Vault key that protects the key ring. Required with BlobUri outside Development. It is an error without BlobUri. The identity needs the key permissions get, wrapKey and unwrapKey (role Key Vault Crypto User). |
DataProtection:ManagedIdentityClientId | unset | The client ID of a user-assigned managed identity. Unset: DefaultAzureCredential chooses (AZURE_CLIENT_ID, the system identity, your developer sign-in). |
Outside Development, the API stops at startup when neither KeysPath nor BlobUri is set, or when both are. In Development, with neither, the framework default applies. The application name is fixed, so every instance and revision reads the same payloads. /health/ready checks that the key ring loads.
Warning
A folder is for local use, or for a volume that every instance mounts. In Azure use BlobUri with KeyIdentifier, so that the keys survive a rebuild and are shared by all instances.
KeyVault
| Setting | Default | Meaning |
|---|---|---|
KeyVault:Uri | unset | The https address of a Key Vault, for example https://mdlake-kv.vault.azure.net/. When set, the API reads the vault’s secrets as its last configuration source, so they win over environment variables. Anything else than an https address stops startup. |
KeyVault:ManagedIdentityClientId | unset | The client ID of a user-assigned managed identity for the vault. Unset: DefaultAzureCredential chooses. |
A secret name uses -- for the colon: the secret GitHub--PrivateKey is the setting GitHub:PrivateKey. The secrets are read once at startup. After you change one, start a new revision or restart the API. The identity needs permission to read secrets.
Invites
| Setting | Default | Range | Meaning |
|---|---|---|---|
Invites:ExpiryDays | 7 | 1 to 90 | How long an invitation stays valid. |
Invites:ResendCooldownSeconds | 60 | 0 to 3600 | The wait before an invitation can be sent again. |
Invites:MaxEmailsPerRequest | 20 | 1 to 100 | Most addresses in one invite request. |
RateLimiting:MagicLink
Limits requests for a sign-in link.
| Setting | Default | Range | Meaning |
|---|---|---|---|
RateLimiting:MagicLink:PerEmailPermits | 3 | 1 to 1000 | Requests per email address in a window. |
RateLimiting:MagicLink:PerIpPermits | 10 | 1 to 1000 | Requests per client address in a window. |
RateLimiting:MagicLink:WindowMinutes | 15 | 1 to 1440 | The window. |
Documents
| Setting | Default | Range | Meaning |
|---|---|---|---|
Documents:LockSeconds | 120 | 15 to 3600 | How long an edit lock lasts without a heartbeat. After this time another editor can take over. |
Publishing
Publishing writes the lake. A background worker in the API does it.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Publishing:CommitterName | mdlake CMS | The Git author name of publish commits. | |
Publishing:CommitterEmail | cms@mdlake.local | The Git author email. | |
Publishing:PollIntervalMs | 2000 | 50 to 60000 | How often the worker looks for queued publishes. |
Publishing:StaleAfterMinutes | 10 | 1 to 240 | A running publish whose heartbeat, written by any process, is older than this is treated as abandoned and failed, so the queue moves on. |
Publishing:HeartbeatSeconds | 15 | 1 to 300 | How often the process that runs a publish writes its heartbeat. Keep it well below StaleAfterMinutes. |
Publishing:RunWorker | true | Run the background workers in this process: publish, the trash purge, the Markdown import and, with a Git host, the mirror push. false switches all of them off, not only the publish worker, so nothing queued is processed by this process. Tests turn it off. | |
Workers:WaitForMigrations | true | The background workers (publish, import, mirror push, trash purge) claim no work until the database has every migration of this build, so a new revision that starts before the migration job has run does not fail jobs. /health/ready reports it (workersWaitingForMigration) and the log says it once. Switch off only for a schema that is not migrated with EF. | |
Workers:MigrationCheckSeconds | 20 | 1 to 300 | How often a waiting worker looks at the migration history again. |
Webhooks
After a publish the CMS posts a small JSON event to each address you list. It is fire and forget: failures are logged and never affect the publish.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Webhooks:Urls | empty | Addresses called for every workspace. Each must be an absolute http or https URL. | |
Webhooks:WorkspaceUrls | empty | A map from a workspace slug to a list of addresses. | |
Webhooks:MaxAttempts | 3 | 1 to 5 | Tries per call. |
Webhooks:RetryDelaySeconds | 2 | 0 to 600 | Wait between tries. |
Webhooks:TimeoutSeconds | 10 | 1 to 120 | Time allowed for one call. |
An address that is not an absolute http or https URL stops startup. After a failed try the CMS waits RetryDelaySeconds times the attempt number before the next one, and logs an error when it gives up.
The body is JSON with these fields: event, workspaceId, workspaceSlug, publishId, documentId, title, path, commitId and occurredAt.
Note
A site builds from the commit in lake.lock, so a publish does not change a live site by itself. A webhook is for your own tooling, for example to tell you that new content is waiting to be refreshed.
Tokens
Workspace tokens for sites and Claude Code.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Tokens:DefaultExpiryDays | 90 | 0 to 3650 | Lifetime of a new token. |
Tokens:RequestsPerMinute | 300 | 1 to 100000 | Rate limit per token. |
Tokens:SignedUrlMinutes | 15 | 1 to 1440 | Lifetime of signed links to unpublished assets, as used in previews. |
Tokens:BuilderMarketplace | https://github.com/Floydan/mdlake | 1 to 300 characters | The address the Connect setup guides show for adding the builder plugin marketplace. |
See Workspace tokens.
Git:Remote and GitHub
The lake mirror. With Git:Remote:Host set to None (the default), lakes live only in ContentRepository:Path.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Git:Remote:Host | None | None or GitHub | GitHub mirrors every workspace’s lake to a private repository. |
Git:Remote:Owner | empty | The GitHub user or organization that owns the repositories. 1 to 39 characters: letters, digits and hyphens, starting with a letter or digit. Required when the host is GitHub. | |
Git:Remote:RepoPrefix | lake- | up to 40 characters | The repository name is the prefix plus the workspace slug. |
Git:Remote:PollSeconds | 5 | 1 to 300 | How often the mirror worker looks for work. |
Git:Remote:RetryInitialSeconds | 30 | 1 to 3600 | First wait after a failed push. |
Git:Remote:RetryMaxMinutes | 30 | 1 to 1440 | Longest wait between retries. |
Git:Remote:GitTimeoutSeconds | 120 | 5 to 3600 | Time allowed for one git operation. |
GitHub:AppId | empty | The GitHub App’s id. Required for GitHub. | |
GitHub:InstallationId | 0 | The installation’s id. Required for GitHub, greater than 0. | |
GitHub:PrivateKeyPath | empty | A path to the app’s private key (.pem) inside the container. The file must exist. | |
GitHub:PrivateKey | empty | The key itself, as an alternative to the path. One of the two is required. | |
GitHub:ApiBaseUrl | https://api.github.com | For GitHub Enterprise Server. | |
GitHub:WebBaseUrl | https://github.com | For GitHub Enterprise Server. |
The startup check reports every missing value together (“GitHub:AppId is required.”, “GitHub:PrivateKeyPath does not point to a file.”). See Connect GitHub.
Remotes
| Setting | Default | Meaning |
|---|---|---|
Remotes:RequireMirror | false | When true, publishing is refused for a workspace until it is connected to its Git mirror (Workspace settings, Lake repository). The same goes for anything else that commits to the lake. The API stops at startup when this is true and Git:Remote:Host is None. Turn it on in production, where the mirror is the second copy of the lake. |
The mirror also reports its lag as the metric mdlake.mirror.lag_seconds (see Monitoring).
Trash
| Setting | Default | Range | Meaning |
|---|---|---|---|
Trash:RetentionDays | 30 | 1 to 3650 | How long deleted items stay in the trash. |
Trash:PurgeIntervalMinutes | 60 | 1 to 1440 | How often expired items are removed for good. |
Assets
Limits for uploads to the media library.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Assets:MaxUploadBytes | 25 MiB | 1 KiB to 200 MiB | Largest file. |
Assets:MaxImageDimension | 4096 | 256 to 16384 | Longest side of a stored image, in pixels. Larger images are scaled down. |
Assets:ThumbnailSize | 480 | 64 to 2048 | Longest side of thumbnails. |
Assets:MaxPixels | 100,000,000 | 1 million to 500 million | Most pixels in one image. Protects against decompression bombs. |
Assets:ImageQuality | 90 | 50 to 100 | Quality of re-encoded images. |
MarkdownImport
Limits for the Markdown folder import.
| Setting | Default | Range | Meaning |
|---|---|---|---|
MarkdownImport:MaxZipBytes | 100 MiB | 1 MiB to about 2 GB | Largest zip. |
MarkdownImport:MaxEntries | 2000 | 1 to 50000 | Most files in a zip. |
MarkdownImport:MaxTotalBytes | 300 MiB | 1 MiB to about 4 GB | Total size after unpacking. |
MarkdownImport:MaxCompressionRatio | 200 | 2 to 10000 | Largest ratio between unpacked and packed size. Protects against zip bombs. |
MarkdownImport:StagingMinutes | 60 | 1 to 1440 | How long a checked import waits for you to confirm. |
MarkdownImport:StaleAfterMinutes | 60 | 1 to 1440 | A running import whose heartbeat, written by any process, is older than this is treated as abandoned and failed. |
MarkdownImport:HeartbeatSeconds | 15 | 1 to 300 | How often the process that runs an import writes its heartbeat. |
MarkdownImport:PollIntervalMs | 2000 | 50 to 60000 | How often the import worker looks for work. |
See Importing content. The web server’s body limit is raised to match MaxZipBytes.
Anthropic and Suggestions
AI suggestions (summaries, tags, titles, alt text). They need an API key.
| Setting | Default | Range | Meaning |
|---|---|---|---|
Anthropic:ApiKey | empty | The Claude API key. When empty, the environment variable ANTHROPIC_API_KEY is used. | |
Anthropic:Model | claude-haiku-5-5 | The model. | |
Anthropic:MaxTokens | 600 | 64 to 4096 | Largest answer. |
Anthropic:TimeoutSeconds | 30 | 5 to 120 | Time allowed for one call. |
Anthropic:Enabled | true | A master switch. With no key the feature is inactive whatever this says. | |
Suggestions:RequestsPerMinute | 30 | 1 to 10000 | Rate limit. |
Suggestions:MaxDocumentCharacters | 12000 | 500 to 200000 | How much of a document is sent. |
Suggestions:VocabularySize | 200 | 0 to 500 | How many existing tags are offered so that suggestions reuse them. |
Suggestions:ImageCacheMinutes | 10 | 0 to 120 | How long a prepared image is kept for repeated suggestions. |
Suggestions:MaxBulkTagFiles | 50 | 2 to 200 | Most files in one bulk tag request. |
See AI suggestions. A workspace also has its own switch, in the workspace settings.
AiPricing
The price list the usage ledger uses to estimate cost. appsettings.json ships it with a Source (the pricing page), an AsOf date and a Models table. Each model has InputPerMTok, OutputPerMTok, CacheReadPerMTok, CacheWritePerMTok and CacheWrite1hPerMTok, in US dollars per million tokens.
To add a model or correct a price, override the entries under AiPricing:Models:<model id>. The estimate is only as good as this table. Update AsOf when you do. The AI usage page shows the source and the AsOf date under the figures (“Prices from …, checked …”). A model that has no entry is not priced: its row shows “No price configured”, the total says how many tokens had no price and for which models (”+ 1,000 tokens without a price (claude-xyz)”), and those tokens are left out of the estimated cost. The cost is worked out from the prices set today, so a price change also changes past months.
Startup
| Setting | Default | Meaning |
|---|---|---|
Startup:Initialize | true | Run the startup initialisation: create the blob containers and seed the first admin. In Development it also applies the database migrations. In other environments the API never applies migrations; the deploy applies them first (the migration job in the Azure setup, see infra/cms/README.md), and /health/ready keeps a revision whose migrations are missing out of rotation. Integration tests turn it off. Leave it on. |
Monitoring
| Setting | Default | Meaning |
|---|---|---|
APPLICATIONINSIGHTS_CONNECTION_STRING | unset | The Application Insights connection string. When set, the API exports requests, dependencies, logs and the mirror metric mdlake.mirror.lag_seconds (seconds a workspace’s mirror has been behind its lake, 0 when up to date). When unset, nothing is exported. |
Health endpoints
These take no settings. GET /health/live answers 200 when the process is up. GET /health/ready runs five checks: database (reachable, and every migration of this build is applied; a database that is ahead is healthy), blobStorage, lakeFolder, converter and dataProtection. It answers 200 when all pass and 503 otherwise, and caches the answer for 5 seconds. Each check times out after 5 seconds. While a migration is missing the background workers claim no work and database says so (workersWaitingForMigration); /health/live is not affected. Anyone sees the status of each check by name. Signed-in admins also get descriptions. /health stays as the plain “process is up” endpoint.
Docker Compose
docker-compose.yml starts the dependencies. With --profile full it builds and starts the API and the editor as well.
docker compose up -d
docker compose --profile full up -ddocker compose up -d
docker compose --profile full up -dThe .env file
Compose reads .env in the repository root. Two values are required: Compose stops with a message if they are missing.
| Variable | Required | Meaning |
|---|---|---|
MSSQL_SA_PASSWORD | yes | The sa password of the SQL Server container and the API’s connection string. |
SEED_ADMIN_EMAIL | yes (profile full) | The first administrator, as Seed__AdminEmail. |
ANTHROPIC_API_KEY | no | The Claude API key for suggestions. |
SEED_DEMO_WORKSPACE | no, default true | Import the demo workspace once. Set false to skip. |
GIT_REMOTE_HOST | no | GitHub to mirror lakes. Empty keeps them local. |
GIT_REMOTE_OWNER | no | The owner of the mirror repositories. |
GITHUB_APP_ID | no | The GitHub App id. |
GITHUB_INSTALLATION_ID | no, default 0 | The installation id. |
GITHUB_PRIVATE_KEY_FILE | no | The host path of the app’s .pem. It is mounted read-only into the container. |
GITHUB_PRIVATE_KEY_PATH | no | The path inside the container, for example /run/secrets/github-app.pem. |
The services
| Service | Address | Role |
|---|---|---|
sql | localhost,1433 | SQL Server with full-text search (a custom image, because the stock one has none). Developer edition. |
azurite | http://localhost:10000/devstoreaccount1 | Blob Storage emulator. |
mailpit | inbox http://localhost:8025, SMTP 1025 | Catches mail. Magic links land here. |
markitdown | http://localhost:8100 | The converter. |
cms-api | http://localhost:5080 | The API (profile full). OpenAPI at /openapi/v1.json. |
cms-web | http://localhost:5173 | The editor (profile full). |
The Vite development server takes its API address from VITE_API_BASE_URL (http://localhost:5080 in Compose). A production build, as made by cms/Dockerfile, leaves it unset and calls /api on its own origin, because the API serves the built editor.
Compose stores SQL data, Blob data and the Data Protection keys in named volumes (sql-data, azurite-data, cms-keys), and the lakes in ./data/git. docker compose down -v deletes the volumes. Take a backup first if the data matters.
Production notes
- Set
Email:ProvidertoResendand keepEmail:Resend:ApiKeyin a secret store. - Point Blob Storage at a real storage account:
Storage:ServiceUriwith the managed identity (the identity needs Storage Blob Data Contributor on the account, and no key is stored anywhere), orStorage:ConnectionString. SetStorage:PublicBaseUrlto the public container’s address, or a CDN in front of it. Sites download assets from that address, so it must be reachable from your CI. See The lake in CI, and caches. - Set
App:EditorBaseUrlandPasskeys:ServerDomainto the real address of the CMS. The API serves the editor, soCors:Originsis not needed. - Set
App:TrustForwardedHeaderstotruebehind a proxy you control. - Set
ASPNETCORE_ENVIRONMENTtoProduction. The development file then does not apply. The production image sets it. - Set
DataProtection:BlobUriandDataProtection:KeyIdentifier, so the keys survive a rebuild. - Set
Remotes:RequireMirrortotrueand configure the GitHub mirror. - Apply migrations before a new revision takes traffic. The API does not apply them outside Development.
- Back up the SQL database and the
ContentRepository:Pathfolder. The database holds drafts and an index that can be rebuilt; the Git repositories hold the published truth.
The supported Azure setup, with Bicep, pipelines and runbooks, is described in infra/cms/README.md. See also Running it on a server.
