Skip to content
mdlake

Install the CMS

Run the mdlake CMS on your own machine with Docker Compose, understand the services and the main settings (storage, passkeys, email, AI, prices), and sign in for the first time. For developers and admins.

On this page

In this page you start the CMS on your computer, sign in as the first admin and look at the demo workspace. You also get a map of the settings you change when you move from your laptop to a server. The CMS is an ASP.NET Core API plus a React editor, backed by SQL Server, blob storage, an email provider and a document converter.

Before you start

You need:

  • Docker with Docker Compose (Docker Desktop on Windows).
  • Git.
  • For the “run the API and editor from source” route only: the .NET 10 SDK and Node 22.

Everything runs locally and costs nothing. The only paid part is the Claude API, and only if you switch on AI suggestions.

The services

docker-compose.yml in the repository root defines these services. The first four are the dependencies. The last two are the CMS itself and only start with --profile full.

ServiceWhat it isLocal address
sqlSQL Server 2022 Developer with Full-Text Search added. The stock image does not include full-text search, which the CMS search needs, so the image is built from docker/sql.localhost,1433 (user sa)
azuriteThe Azure Storage emulator. It holds the private and public asset containers.http://localhost:10000/devstoreaccount1
mailpitCatches all outgoing email so that sign-in links and invitations land in a web inbox.Inbox http://localhost:8025, SMTP port 1025
markitdownMicrosoft MarkItDown, wrapped as a small web service. It turns PDF, Word, PowerPoint and Excel files into Markdown. Only the API calls it.http://localhost:8100
cms-apiThe CMS API (profile full).http://localhost:5080
cms-webThe editor (profile full). It runs the Vite development server.http://localhost:5173

The lake repositories are kept in the data/git folder of your checkout, one bare repository per workspace (data/git/<short name>.git). The folder is mounted into the cms-api container, so you can clone a lake straight from disk.

Option A: everything in containers

This is the quickest route and the one to use if you only want to look around or build a site.

  1. Clone the repository and open a PowerShell window in its root folder.

    PowerShell
    git clone https://github.com/Floydan/mdlake.git
    cd mdlake
  2. Create your settings file from the example.

    PowerShell
    Copy-Item .env.example .env

    In Bash: cp .env.example .env. The new file .env is ignored by Git. Never commit it.

  3. Open .env and set at least two values.

    VariableMeaning
    MSSQL_SA_PASSWORDThe password of the SQL Server sa user. It must have at least 8 characters with upper case, lower case, a digit and a symbol, or SQL Server refuses to start.
    SEED_ADMIN_EMAILThe email address of the first admin. This is who you sign in as.
  4. Start everything.

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

    The first run builds the SQL, API, editor and converter images, so it takes a few minutes. SQL Server needs about 20 seconds to become healthy before the API starts.

  5. Open the editor at http://localhost:5173. You should see the Sign in page. Continue with First sign-in.

To stop everything, run docker compose --profile full down. Your data stays in the Docker volumes sql-data, azurite-data and cms-keys, and in data/git. Add -v to also delete the volumes and start from nothing.

Note

The SQL image is built for linux/amd64. On Apple Silicon it runs under emulation and is slower, but it works.

Option B: dependencies in containers, API and editor from source

Use this route when you work on the CMS itself.

  1. Do steps 1 to 3 from option A.

  2. Start only the dependencies.

    PowerShell
    docker compose up -d
  3. Give the API its connection string and the admin address as user secrets. Replace <MSSQL_SA_PASSWORD> with the password you chose.

    PowerShell
    cd cms
    dotnet user-secrets set "ConnectionStrings:Cms" "Server=localhost,1433;Database=KnowledgeCms;User Id=sa;Password=<MSSQL_SA_PASSWORD>;TrustServerCertificate=True" --project src/Mdlake.Cms.Api
    dotnet user-secrets set "Seed:AdminEmail" "you@example.com" --project src/Mdlake.Cms.Api
  4. Start the API. It listens on http://localhost:5080.

    PowerShell
    dotnet run --project src/Mdlake.Cms.Api
  5. In a second window, start the editor.

    PowerShell
    cd cms/web
    npm install
    npm run dev

    The editor opens on http://localhost:5173 and talks to the API at the address in VITE_API_BASE_URL (default http://localhost:5080). cms/web/.env.example shows the variable.

When the API starts in the Development environment it does four things for you: it applies the database migrations, creates the private-assets and public-assets containers, creates the admin user, and imports the demo workspace.

The demo workspace

With SEED_DEMO_WORKSPACE=true (the default in docker-compose.yml), the API imports a ready-made workspace on startup: mdlake, short name mdlake-demo. It is a fictional product and company site about mdlake itself, with documents, images and a Brand folder. It gives a developer realistic content to build a site against.

  • The admin from SEED_ADMIN_EMAIL becomes its author and a member.
  • The documents are published through the normal publish queue. A few seconds after startup they are committed to data/git/mdlake-demo.git.
  • If a workspace with that short name already exists, the import is skipped, so restarts are harmless. To import again, delete the workspace or reset the database, then restart.
  • If the import fails, the error is logged and the API still starts.
  • Set SEED_DEMO_WORKSPACE=false in .env to skip it.

First sign-in

Accounts are passwordless, so there is no password to type.

  1. Open http://localhost:5173. The Sign in page appears.
  2. Enter the address from SEED_ADMIN_EMAIL in the Email field and choose Email me a link. The page changes to Check your inbox.
  3. Open Mailpit at http://localhost:8025, open the newest message and follow the link. The link works once and expires after 15 minutes.
  4. The page Signing you in appears, and then Add a passkey?. Choose Add passkey to register the fingerprint, face or PIN of your device, or Skip for now.
  5. You land in the editor. If you are an admin, the header shows Workspace settings and New workspace, and the account menu shows Users and AI usage.

Next time you can choose Sign in with passkey instead of waiting for an email. Passkeys are managed under Passkeys in the account menu, where you can Add a passkey, Rename and Remove. If you lose a device, an email link always works.

The first admin is created only when no admin exists yet. Changing Seed:AdminEmail later does not add another admin. Invite further admins from the Users page instead (Users and invites).

Passkeys on localhost

Passkeys are tied to a domain name. For local work:

  • Open the editor as http://localhost:5173, not http://127.0.0.1:5173. The passkey domain is localhost, and WebAuthn treats localhost as secure without HTTPS. On any other host name, passkeys need HTTPS.
  • The editor (port 5173) calls the API (port 5080), which is another origin. The API checks the origin of every passkey request against an allow-list made of App:EditorBaseUrl, Cors:Origins and the API’s own origin. If you open the editor from another address, add it to Cors:Origins. There is no “allow any origin”.
  • Password managers such as 1Password create and use passkeys through the normal browser interface and work the same way.
  • The short-lived cookie that carries a passkey ceremony is protected by the Data Protection keys (see below). If you rebuild the API container without a persistent key folder, a ceremony that is in progress fails. Try again.

Configuration

The API reads its settings from appsettings.json, then appsettings.Development.json, then user secrets (Development only), then environment variables, then Azure Key Vault when KeyVault__Uri is set, in that order. Later sources win.

A setting named Storage:PublicBaseUrl is written Storage__PublicBaseUrl as an environment variable (two underscores for each colon). A list entry is numbered: Cors__Origins__0.

Options are validated when the API starts. A missing required value stops the API with a message that names the setting. The API never starts half-configured.

Required everywhere

SettingEnvironment variableMeaning
ConnectionStrings:CmsConnectionStrings__CmsSQL Server connection string. The database needs Full-Text Search (Azure SQL has it).
Storage:ConnectionStringStorage__ConnectionStringBlob storage by connection string. UseDevelopmentStorage=true for Azurite. In Azure use Storage__ServiceUri instead. Set exactly one of the two.
Storage:ServiceUriStorage__ServiceUriBlob storage with the managed identity: the account’s Blob endpoint, such as https://mdlakeprodst.blob.core.windows.net. The identity needs the role Storage Blob Data Contributor on the account. Storage__ManagedIdentityClientId picks a user-assigned identity.
Storage:PublicBaseUrlStorage__PublicBaseUrlThe base address of the public container, such as http://localhost:10000/devstoreaccount1/public-assets or a CDN or custom domain in Azure. A published file’s address is this plus the asset ID.
App:EditorBaseUrlApp__EditorBaseUrlThe public address of the editor. Sign-in and invitation links in emails point here.
Passkeys:ServerDomainPasskeys__ServerDomainThe WebAuthn relying party ID: the registrable domain that the editor and the API share, such as example.com. localhost in development. There is no default.
Email:FromEmail__FromThe sender address of emails.
Converter:BaseUrlConverter__BaseUrlThe address of the MarkItDown service.

Storage

SettingDefaultMeaning
Storage:PrivateContainerprivate-assetsDrafts and unpublished uploads. Never public.
Storage:PublicContainerpublic-assetsPublished files. Created with public read access for individual files.
Assets:MaxUploadBytes25 MiBLargest accepted upload.
Assets:MaxImageDimension4096Longest side of a stored image. Larger images are scaled down.
Assets:ThumbnailSize480Longest side of the thumbnail.
Assets:MaxPixels100,000,000Images with more pixels are refused before decoding.
Assets:ImageQuality90JPEG and WebP quality of re-encoded images.

The lake repositories

SettingDefaultMeaning
ContentRepository:Pathdata/gitThe folder that holds one bare Git repository per workspace. Keep it on a persistent volume.
ContentRepository:BranchmainThe branch that holds published content.
Git:Remote:*, GitHub:*noneThe GitHub mirror. See Connect GitHub.
Remotes:RequireMirrorfalseWhen true, a workspace cannot publish until its lake is connected to the mirror. Needs Git:Remote:Host. Set it in production.

The API image contains the git program, which the CMS uses to write the lake.

Email

SettingDefaultMeaning
Email:ProviderResend in appsettings.json, Smtp in DevelopmentSmtp or Resend. Switching providers needs no code change.
Email:Smtp:Host, Email:Smtp:Portlocalhost, 1025The SMTP server. Locally this is Mailpit. In the container it is mailpit on port 1025.
Email:Resend:ApiKeyemptyThe Resend API key. Keep it a secret.

AI suggestions

AI suggestions are off until you give the CMS a Claude API key.

SettingDefaultMeaning
ANTHROPIC_API_KEY or Anthropic:ApiKeyemptyThe key. Empty means AI is off. In .env, set ANTHROPIC_API_KEY.
Anthropic:Modelclaude-haiku-5-5The model id. Any model id works.
Anthropic:EnabledtrueSet to false to switch AI off while a key is set.
Anthropic:MaxTokens600Cap on generated tokens per call.
Anthropic:TimeoutSeconds30Time one call may take.
Suggestions:RequestsPerMinute30Requests per person per minute.
Suggestions:MaxDocumentCharacters12000Characters of document text sent to the model. Longer text keeps its start and end.
Suggestions:VocabularySize200How many of the workspace’s most used tags are offered to the model.
Suggestions:ImageCacheMinutes10How long an image suggestion is reused. 0 switches the cache off.
Suggestions:MaxBulkTagFiles50Files in one bulk Suggest tags run (2 to 200).

Once a key is set and the API restarts, each workspace gets a switch in Workspace settings. See Create a workspace and AI suggestions.

Prices for the usage report

The CMS never stores what an AI call cost. It stores token counts and works out the cost when you look, using the prices in the AiPricing section of appsettings.json. Prices are in US dollars per million tokens.

SettingMeaning
AiPricing:Models:<model id>:InputPerMTokPrice of input tokens.
AiPricing:Models:<model id>:OutputPerMTokPrice of output tokens.
AiPricing:Models:<model id>:CacheReadPerMTokPrice of cache reads.
AiPricing:Models:<model id>:CacheWritePerMTokPrice of cache writes (5 minute cache).
AiPricing:Models:<model id>:CacheWrite1hPerMTokPrice of cache writes (1 hour cache).
AiPricing:Source, AiPricing:AsOfWhere the prices come from and the date they were checked. Shown in the usage report.

A model id with a date or -latest on the end, such as claude-opus-5-5-20261101, uses the price of the longest configured id it starts with. A model with no price counts tokens only and shows “Price unknown”. Because the cost is worked out at read time, changing a price changes past months too.

People, trash, publishing and more

These settings all have sensible defaults. Change them only when you have a reason.

SettingDefaultMeaning
Invites:ExpiryDays7How long an invitation link is valid (1 to 90).
Invites:ResendCooldownSeconds60Minimum time between two sends of the same invitation.
Invites:MaxEmailsPerRequest20Most addresses in one invitation.
RateLimiting:MagicLink:PerEmailPermits3Sign-in links per email address per window.
RateLimiting:MagicLink:PerIpPermits10Sign-in links per client address per window.
RateLimiting:MagicLink:WindowMinutes15The window for the two limits above.
Documents:LockSeconds120How long an edit lock lasts without a heartbeat.
Trash:RetentionDays30Days a deleted item stays in the trash.
Trash:PurgeIntervalMinutes60How often expired items are purged.
Publishing:CommitterName, Publishing:CommitterEmailmdlake CMS, cms@mdlake.localThe committer of every publish commit. The editor is the author.
Webhooks:Urls, Webhooks:WorkspaceUrls:<short name>noneAddresses that receive a small JSON event after each publish or unpublish.
Tokens:DefaultExpiryDays90Default lifetime of a workspace token. 0 means no expiry.
Tokens:RequestsPerMinute300Requests per token per minute.
Tokens:BuilderMarketplacehttps://github.com/Floydan/mdlakeWhat the setup guide in Workspace settings shows for /plugin marketplace add.
MarkdownImport:*see Importing contentSize and safety limits of the Markdown folder import.
Cors:OriginsnoneAllowed editor origins.
App:TrustForwardedHeadersfalseSet to true behind a proxy, so rate limits see the client address.
DataProtection:KeysPathnoneA folder that keeps the session and passkey encryption keys. See below for the Azure alternative.
DataProtection:BlobUri, DataProtection:KeyIdentifiernoneThe key ring in Blob Storage, protected by a Key Vault key. See below.
KeyVault:UrinoneRead secrets from this Key Vault at startup. See Running it on a server.
Seed:AdminEmailnoneThe first admin.
Seed:DemoWorkspacefalseImport the demo workspace at startup.

Data Protection keys

The session cookie, the passkey ceremony state and some tokens are encrypted with ASP.NET Core Data Protection keys. If the keys are lost, everyone is signed out. Set exactly one of these:

  • DataProtection:KeysPath: a persistent folder. docker-compose.yml mounts the cms-keys volume at /data/keys for this.
  • DataProtection:BlobUri: the blob that holds the key ring, such as https://<account>.blob.core.windows.net/dataprotection/keys.xml. Create the container first. Also set DataProtection:KeyIdentifier, the https address of the Key Vault key that protects the key ring. Set DataProtection:ManagedIdentityClientId to use a user-assigned managed identity. That identity needs the role Storage Blob Data Contributor on the blob and Key Vault Crypto User on the key.

Outside Development the API does not start when neither is set, or when both are set. In Azure use the blob: every instance and every revision shares the keys, and they are never stored in the clear.

Warning

A key folder is for local development, or for a volume that every instance mounts. A folder inside a container that is rebuilt loses the keys.

Running it on a server

The supported production setup is Azure Container Apps. It is defined as Bicep in infra/cms/: one Container App that runs the API with the converter as a sidecar, Azure SQL, a storage account (blobs and an Azure Files share for the lakes), Key Vault, a container registry, monitoring, backup and alerts. Pipelines for GitHub Actions (.github/workflows/cms-*.yml) and Azure DevOps (cms/pipelines/*.yml) build the images and deploy them. The step-by-step setup, the deploy, rollback and alert descriptions and the restore runbooks are in infra/cms/README.md. Start there.

These are the differences from your laptop:

  • Migrations. The API applies database migrations at startup only when the environment is Development. In any other environment a separate step applies them before a new revision takes traffic. In the Azure setup that step is a Container Apps Job built from cms/Dockerfile.migrate. To apply them yourself, cms/scripts/build-migrations.sh builds a migration bundle (efbundle) and an idempotent SQL script (migrate.sql). A normal deploy is rolling: the previous revision keeps serving against the new schema, so a release only adds to the schema. When a release contains a migration marked [BreakingMigration], the pipeline does a maintenance deploy instead: users get a plain 503 for a few minutes while the old revisions are stopped and the database is migrated.
  • Secrets. Set KeyVault__Uri (and KeyVault__ManagedIdentityClientId for a user-assigned identity) and the API reads secrets from that Key Vault when it starts. A secret name uses -- where a setting uses a colon: Email--Resend--ApiKey is Email:Resend:ApiKey. Key Vault wins over environment variables. A changed secret needs a new revision or a restart.
  • Email. Use Email__Provider=Resend, Email__Resend__ApiKey and Email__From. Optionally set Email__ReplyTo so replies reach a real inbox: it is the Reply-To on every mail the CMS sends (magic links, invites), for example Email__From noreply@your-domain and Email__ReplyTo support@your-domain.
  • Addresses. The API serves the built editor itself, so the editor, /api and /mcp share one address. Set App__EditorBaseUrl to that public HTTPS address (sign-in links point to it) and Passkeys__ServerDomain to its domain. You do not need Cors__Origins__0, because the browser calls the API on its own origin. Set it only if you serve the editor from another address. Sessions use a Secure cookie outside Development, so the CMS must be served over HTTPS. Behind a proxy such as the Container Apps ingress, set App__TrustForwardedHeaders=true.
  • The editor. cms/Dockerfile builds the editor (npm run build in cms/web) into the API image, and the API serves it. The built editor calls /api on its own origin, so leave VITE_API_BASE_URL unset when you build it. The cms-web container in docker-compose.yml runs the Vite development server and is for local development only.
  • Storage. Use a storage account. Set Storage__ServiceUri and Storage__ManagedIdentityClientId (the managed identity signs in; the Azure setup stores no storage key in Key Vault) or, without a managed identity, Storage__ConnectionString, and Storage__PublicBaseUrl. The public container must allow anonymous read of individual files, because sites and browsers fetch published files from it.
  • Data Protection keys. Outside Development the API refuses to start without a place for them. In Azure set DataProtection__BlobUri and DataProtection__KeyIdentifier. See Data Protection keys.
  • Converter. Run the MarkItDown service next to the API as a sidecar and set Converter__BaseUrl=http://localhost:8000.
  • Lakes and the mirror. Keep ContentRepository__Path on a persistent volume. The production image uses /data/git, which the Azure setup mounts from an Azure Files share. In production, also set Remotes__RequireMirror=true together with the GitHub settings. A workspace then cannot publish until its Lake repository is connected, so every lake always has a second copy on GitHub. The API does not start when Remotes__RequireMirror is true and Git__Remote__Host is not set. See Connect GitHub.
  • Health. GET /health/live answers when the process is up. GET /health/ready answers 200 when the database has every migration of this build and Blob Storage, the lake folder, the converter and the Data Protection key ring all work, and 503 otherwise. Use them for the liveness and readiness probes. Signed-in admins also see a description of each check.
  • Monitoring. Set APPLICATIONINSIGHTS_CONNECTION_STRING to send requests, logs and the mirror metric mdlake.mirror.lag_seconds to Application Insights. Without it nothing is exported.

The full list of settings is in CMS configuration.

If something goes wrong

What you seeLikely cause and fix
docker compose stops with “set MSSQL_SA_PASSWORD in .env” or “set SEED_ADMIN_EMAIL in .env”.env is missing or a value is empty. Create it from .env.example.
The SQL container restarts or is “unhealthy”The sa password is probably too weak. Use at least 8 characters with upper case, lower case, a digit and a symbol, then run docker compose logs sql to confirm.
The API exits at startup and names a settingA required setting is missing. Add it, for example App__EditorBaseUrl or Passkeys__ServerDomain.
No email arrivesOpen Mailpit at http://localhost:8025. The CMS does not send real email locally.
“This sign-in link has expired or was already used”Links work once and last 15 minutes. Choose Request a new link.
Too many sign-in emails are refusedThe limits are 3 per address and 10 per client address in 15 minutes. Wait, or raise RateLimiting:MagicLink:*.
Passkey sign-in fails on 127.0.0.1Open the editor as localhost.
Everybody is signed out after a rebuildThe Data Protection keys were lost. Set DataProtection:KeysPath to a persistent folder. On a server, keep the keys in Blob Storage with DataProtection:BlobUri.
The demo workspace is missingSEED_DEMO_WORKSPACE is false, or the import failed. Look at docker compose logs cms-api.
The demo lake is emptyThe first publish has not finished. Wait a few seconds and check docker compose logs cms-api.

What’s next