Skip to content
mdlake

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

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.

  1. appsettings.json: defaults for everything that has a safe default (storage containers, token expiry, the AI price list).
  2. appsettings.Development.json: values for a laptop (SQL Server and Azurite on localhost, Mailpit on port 1025).
  3. User secrets (development only): for secrets such as the SQL password. appsettings.Development.json holds SET_IN_USER_SECRETS as a placeholder.
  4. Environment variables.
  5. Azure Key Vault, only when KeyVault:Uri is 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.

SettingWhy
ConnectionStrings:CmsThe SQL Server connection. A missing value stops startup with “Connection string ‘Cms’ is missing.”
Storage:ConnectionString or Storage:ServiceUriHow 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:PublicBaseUrlThe public address of the public container. It must be a URL.
Email:FromThe sender address. It must be an email address.
Converter:BaseUrlThe MarkItDown converter. It must be a URL.
App:EditorBaseUrlThe address of the editor. It must be a URL. Used in links in mail.
Passkeys:ServerDomainThe 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.

SettingDefaultMeaning
Storage:ConnectionStringnoneBlob Storage connection string. UseDevelopmentStorage=true with Azurite. Contains a key; in Azure prefer Storage:ServiceUri. Exactly one of the two is set.
Storage:ServiceUrinoneThe 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:ManagedIdentityClientIdnoneClient 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:PrivateContainerprivate-assetsContainer for unpublished files.
Storage:PublicContainerpublic-assetsContainer for published files.
Storage:PublicBaseUrlnoneThe 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

SettingDefaultMeaning
ContentRepository:Pathdata/gitThe folder that holds one bare Git repository per workspace: the lake. In Docker it is /data/git, mapped to ./data/git.
ContentRepository:BranchmainThe branch the CMS publishes to. Letters, digits, dot, underscore and hyphen, up to 100 characters.

Email

Magic links and invitations are sent by mail.

SettingDefaultMeaning
Email:ProviderSmtp in code, Resend in appsettings.jsonSmtp or Resend. Chosen once at startup.
Email:FromnoneThe sender.
Email:ReplyTononeOptional. 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:HostlocalhostSMTP server. Locally, Mailpit.
Email:Smtp:Port1025SMTP port.
Email:Resend:ApiKeyemptyThe 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

SettingDefaultMeaning
Converter:BaseUrlhttp://localhost:8000 in appsettings.json; http://localhost:8100 in Development; http://markitdown:8000 in ComposeThe MarkItDown service that turns Word, PowerPoint, PDF and Excel files into Markdown. The call times out after two minutes.

Seed

SettingDefaultMeaning
Seed:AdminEmailnoneThe 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:DemoWorkspacefalse in code; true in ComposeImport the demo workspace “mdlake” once.
Seed:DemoContentPathnoneA folder with demo content to import, instead of the built-in one.

App

SettingDefaultMeaning
App:EditorBaseUrlnoneThe address of the editor, for example http://localhost:5173. Links in mail point here. It is also an allowed origin for passkeys.
App:TrustForwardedHeadersfalseTrust 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

SettingDefaultMeaning
Passkeys:ServerDomainnoneThe 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

SettingDefaultMeaning
Cors:OriginsnoneA 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

SettingDefaultMeaning
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.
SettingDefaultMeaning
DataProtection:KeysPathunsetA 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:BlobUriunsetThe 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:KeyIdentifierunsetThe 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:ManagedIdentityClientIdunsetThe 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

SettingDefaultMeaning
KeyVault:UriunsetThe 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:ManagedIdentityClientIdunsetThe 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

SettingDefaultRangeMeaning
Invites:ExpiryDays71 to 90How long an invitation stays valid.
Invites:ResendCooldownSeconds600 to 3600The wait before an invitation can be sent again.
Invites:MaxEmailsPerRequest201 to 100Most addresses in one invite request.

Limits requests for a sign-in link.

SettingDefaultRangeMeaning
RateLimiting:MagicLink:PerEmailPermits31 to 1000Requests per email address in a window.
RateLimiting:MagicLink:PerIpPermits101 to 1000Requests per client address in a window.
RateLimiting:MagicLink:WindowMinutes151 to 1440The window.

Documents

SettingDefaultRangeMeaning
Documents:LockSeconds12015 to 3600How 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.

SettingDefaultRangeMeaning
Publishing:CommitterNamemdlake CMSThe Git author name of publish commits.
Publishing:CommitterEmailcms@mdlake.localThe Git author email.
Publishing:PollIntervalMs200050 to 60000How often the worker looks for queued publishes.
Publishing:StaleAfterMinutes101 to 240A running publish whose heartbeat, written by any process, is older than this is treated as abandoned and failed, so the queue moves on.
Publishing:HeartbeatSeconds151 to 300How often the process that runs a publish writes its heartbeat. Keep it well below StaleAfterMinutes.
Publishing:RunWorkertrueRun 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:WaitForMigrationstrueThe 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:MigrationCheckSeconds201 to 300How 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.

SettingDefaultRangeMeaning
Webhooks:UrlsemptyAddresses called for every workspace. Each must be an absolute http or https URL.
Webhooks:WorkspaceUrlsemptyA map from a workspace slug to a list of addresses.
Webhooks:MaxAttempts31 to 5Tries per call.
Webhooks:RetryDelaySeconds20 to 600Wait between tries.
Webhooks:TimeoutSeconds101 to 120Time 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.

SettingDefaultRangeMeaning
Tokens:DefaultExpiryDays900 to 3650Lifetime of a new token.
Tokens:RequestsPerMinute3001 to 100000Rate limit per token.
Tokens:SignedUrlMinutes151 to 1440Lifetime of signed links to unpublished assets, as used in previews.
Tokens:BuilderMarketplacehttps://github.com/Floydan/mdlake1 to 300 charactersThe 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.

SettingDefaultRangeMeaning
Git:Remote:HostNoneNone or GitHubGitHub mirrors every workspace’s lake to a private repository.
Git:Remote:OwneremptyThe 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:RepoPrefixlake-up to 40 charactersThe repository name is the prefix plus the workspace slug.
Git:Remote:PollSeconds51 to 300How often the mirror worker looks for work.
Git:Remote:RetryInitialSeconds301 to 3600First wait after a failed push.
Git:Remote:RetryMaxMinutes301 to 1440Longest wait between retries.
Git:Remote:GitTimeoutSeconds1205 to 3600Time allowed for one git operation.
GitHub:AppIdemptyThe GitHub App’s id. Required for GitHub.
GitHub:InstallationId0The installation’s id. Required for GitHub, greater than 0.
GitHub:PrivateKeyPathemptyA path to the app’s private key (.pem) inside the container. The file must exist.
GitHub:PrivateKeyemptyThe key itself, as an alternative to the path. One of the two is required.
GitHub:ApiBaseUrlhttps://api.github.comFor GitHub Enterprise Server.
GitHub:WebBaseUrlhttps://github.comFor 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

SettingDefaultMeaning
Remotes:RequireMirrorfalseWhen 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

SettingDefaultRangeMeaning
Trash:RetentionDays301 to 3650How long deleted items stay in the trash.
Trash:PurgeIntervalMinutes601 to 1440How often expired items are removed for good.

Assets

Limits for uploads to the media library.

SettingDefaultRangeMeaning
Assets:MaxUploadBytes25 MiB1 KiB to 200 MiBLargest file.
Assets:MaxImageDimension4096256 to 16384Longest side of a stored image, in pixels. Larger images are scaled down.
Assets:ThumbnailSize48064 to 2048Longest side of thumbnails.
Assets:MaxPixels100,000,0001 million to 500 millionMost pixels in one image. Protects against decompression bombs.
Assets:ImageQuality9050 to 100Quality of re-encoded images.

MarkdownImport

Limits for the Markdown folder import.

SettingDefaultRangeMeaning
MarkdownImport:MaxZipBytes100 MiB1 MiB to about 2 GBLargest zip.
MarkdownImport:MaxEntries20001 to 50000Most files in a zip.
MarkdownImport:MaxTotalBytes300 MiB1 MiB to about 4 GBTotal size after unpacking.
MarkdownImport:MaxCompressionRatio2002 to 10000Largest ratio between unpacked and packed size. Protects against zip bombs.
MarkdownImport:StagingMinutes601 to 1440How long a checked import waits for you to confirm.
MarkdownImport:StaleAfterMinutes601 to 1440A running import whose heartbeat, written by any process, is older than this is treated as abandoned and failed.
MarkdownImport:HeartbeatSeconds151 to 300How often the process that runs an import writes its heartbeat.
MarkdownImport:PollIntervalMs200050 to 60000How 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.

SettingDefaultRangeMeaning
Anthropic:ApiKeyemptyThe Claude API key. When empty, the environment variable ANTHROPIC_API_KEY is used.
Anthropic:Modelclaude-haiku-5-5The model.
Anthropic:MaxTokens60064 to 4096Largest answer.
Anthropic:TimeoutSeconds305 to 120Time allowed for one call.
Anthropic:EnabledtrueA master switch. With no key the feature is inactive whatever this says.
Suggestions:RequestsPerMinute301 to 10000Rate limit.
Suggestions:MaxDocumentCharacters12000500 to 200000How much of a document is sent.
Suggestions:VocabularySize2000 to 500How many existing tags are offered so that suggestions reuse them.
Suggestions:ImageCacheMinutes100 to 120How long a prepared image is kept for repeated suggestions.
Suggestions:MaxBulkTagFiles502 to 200Most 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

SettingDefaultMeaning
Startup:InitializetrueRun 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

SettingDefaultMeaning
APPLICATIONINSIGHTS_CONNECTION_STRINGunsetThe 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.

Bash
docker compose up -d
docker compose --profile full up -d
PowerShell
docker compose up -d
docker compose --profile full up -d

The .env file

Compose reads .env in the repository root. Two values are required: Compose stops with a message if they are missing.

VariableRequiredMeaning
MSSQL_SA_PASSWORDyesThe sa password of the SQL Server container and the API’s connection string.
SEED_ADMIN_EMAILyes (profile full)The first administrator, as Seed__AdminEmail.
ANTHROPIC_API_KEYnoThe Claude API key for suggestions.
SEED_DEMO_WORKSPACEno, default trueImport the demo workspace once. Set false to skip.
GIT_REMOTE_HOSTnoGitHub to mirror lakes. Empty keeps them local.
GIT_REMOTE_OWNERnoThe owner of the mirror repositories.
GITHUB_APP_IDnoThe GitHub App id.
GITHUB_INSTALLATION_IDno, default 0The installation id.
GITHUB_PRIVATE_KEY_FILEnoThe host path of the app’s .pem. It is mounted read-only into the container.
GITHUB_PRIVATE_KEY_PATHnoThe path inside the container, for example /run/secrets/github-app.pem.

The services

ServiceAddressRole
sqllocalhost,1433SQL Server with full-text search (a custom image, because the stock one has none). Developer edition.
azuritehttp://localhost:10000/devstoreaccount1Blob Storage emulator.
mailpitinbox http://localhost:8025, SMTP 1025Catches mail. Magic links land here.
markitdownhttp://localhost:8100The converter.
cms-apihttp://localhost:5080The API (profile full). OpenAPI at /openapi/v1.json.
cms-webhttp://localhost:5173The 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:Provider to Resend and keep Email:Resend:ApiKey in a secret store.
  • Point Blob Storage at a real storage account: Storage:ServiceUri with the managed identity (the identity needs Storage Blob Data Contributor on the account, and no key is stored anywhere), or Storage:ConnectionString. Set Storage:PublicBaseUrl to 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:EditorBaseUrl and Passkeys:ServerDomain to the real address of the CMS. The API serves the editor, so Cors:Origins is not needed.
  • Set App:TrustForwardedHeaders to true behind a proxy you control.
  • Set ASPNETCORE_ENVIRONMENT to Production. The development file then does not apply. The production image sets it.
  • Set DataProtection:BlobUri and DataProtection:KeyIdentifier, so the keys survive a rebuild.
  • Set Remotes:RequireMirror to true and 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:Path folder. 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.

What’s next