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
- Before you start
- The services
- Option A: everything in containers
- Option B: dependencies in containers, API and editor from source
- The demo workspace
- First sign-in
- Passkeys on localhost
- Configuration
- Required everywhere
- Storage
- The lake repositories
- AI suggestions
- Prices for the usage report
- People, trash, publishing and more
- Data Protection keys
- Running it on a server
- If something goes wrong
- What’s next
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.
| Service | What it is | Local address |
|---|---|---|
sql | SQL 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) |
azurite | The Azure Storage emulator. It holds the private and public asset containers. | http://localhost:10000/devstoreaccount1 |
mailpit | Catches all outgoing email so that sign-in links and invitations land in a web inbox. | Inbox http://localhost:8025, SMTP port 1025 |
markitdown | Microsoft 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-api | The CMS API (profile full). | http://localhost:5080 |
cms-web | The 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.
-
Clone the repository and open a PowerShell window in its root folder.
git clone https://github.com/Floydan/mdlake.git cd mdlake -
Create your settings file from the example.
Copy-Item .env.example .envIn Bash:
cp .env.example .env. The new file.envis ignored by Git. Never commit it. -
Open
.envand set at least two values.Variable Meaning MSSQL_SA_PASSWORDThe password of the SQL Server sauser. 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. -
Start everything.
docker compose --profile full up -d --buildThe 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.
-
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.
-
Do steps 1 to 3 from option A.
-
Start only the dependencies.
docker compose up -d -
Give the API its connection string and the admin address as user secrets. Replace
<MSSQL_SA_PASSWORD>with the password you chose.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 -
Start the API. It listens on
http://localhost:5080.dotnet run --project src/Mdlake.Cms.Api -
In a second window, start the editor.
cd cms/web npm install npm run devThe editor opens on
http://localhost:5173and talks to the API at the address inVITE_API_BASE_URL(defaulthttp://localhost:5080).cms/web/.env.exampleshows 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_EMAILbecomes 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=falsein.envto skip it.
First sign-in
Accounts are passwordless, so there is no password to type.
- Open
http://localhost:5173. The Sign in page appears. - Enter the address from
SEED_ADMIN_EMAILin the Email field and choose Email me a link. The page changes to Check your inbox. - Open Mailpit at
http://localhost:8025, open the newest message and follow the link. The link works once and expires after 15 minutes. - 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.
- 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, nothttp://127.0.0.1:5173. The passkey domain islocalhost, and WebAuthn treatslocalhostas 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:Originsand the API’s own origin. If you open the editor from another address, add it toCors: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
| Setting | Environment variable | Meaning |
|---|---|---|
ConnectionStrings:Cms | ConnectionStrings__Cms | SQL Server connection string. The database needs Full-Text Search (Azure SQL has it). |
Storage:ConnectionString | Storage__ConnectionString | Blob storage by connection string. UseDevelopmentStorage=true for Azurite. In Azure use Storage__ServiceUri instead. Set exactly one of the two. |
Storage:ServiceUri | Storage__ServiceUri | Blob 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:PublicBaseUrl | Storage__PublicBaseUrl | The 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:EditorBaseUrl | App__EditorBaseUrl | The public address of the editor. Sign-in and invitation links in emails point here. |
Passkeys:ServerDomain | Passkeys__ServerDomain | The 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:From | Email__From | The sender address of emails. |
Converter:BaseUrl | Converter__BaseUrl | The address of the MarkItDown service. |
Storage
| Setting | Default | Meaning |
|---|---|---|
Storage:PrivateContainer | private-assets | Drafts and unpublished uploads. Never public. |
Storage:PublicContainer | public-assets | Published files. Created with public read access for individual files. |
Assets:MaxUploadBytes | 25 MiB | Largest accepted upload. |
Assets:MaxImageDimension | 4096 | Longest side of a stored image. Larger images are scaled down. |
Assets:ThumbnailSize | 480 | Longest side of the thumbnail. |
Assets:MaxPixels | 100,000,000 | Images with more pixels are refused before decoding. |
Assets:ImageQuality | 90 | JPEG and WebP quality of re-encoded images. |
The lake repositories
| Setting | Default | Meaning |
|---|---|---|
ContentRepository:Path | data/git | The folder that holds one bare Git repository per workspace. Keep it on a persistent volume. |
ContentRepository:Branch | main | The branch that holds published content. |
Git:Remote:*, GitHub:* | none | The GitHub mirror. See Connect GitHub. |
Remotes:RequireMirror | false | When 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.
| Setting | Default | Meaning |
|---|---|---|
Email:Provider | Resend in appsettings.json, Smtp in Development | Smtp or Resend. Switching providers needs no code change. |
Email:Smtp:Host, Email:Smtp:Port | localhost, 1025 | The SMTP server. Locally this is Mailpit. In the container it is mailpit on port 1025. |
Email:Resend:ApiKey | empty | The Resend API key. Keep it a secret. |
AI suggestions
AI suggestions are off until you give the CMS a Claude API key.
| Setting | Default | Meaning |
|---|---|---|
ANTHROPIC_API_KEY or Anthropic:ApiKey | empty | The key. Empty means AI is off. In .env, set ANTHROPIC_API_KEY. |
Anthropic:Model | claude-haiku-5-5 | The model id. Any model id works. |
Anthropic:Enabled | true | Set to false to switch AI off while a key is set. |
Anthropic:MaxTokens | 600 | Cap on generated tokens per call. |
Anthropic:TimeoutSeconds | 30 | Time one call may take. |
Suggestions:RequestsPerMinute | 30 | Requests per person per minute. |
Suggestions:MaxDocumentCharacters | 12000 | Characters of document text sent to the model. Longer text keeps its start and end. |
Suggestions:VocabularySize | 200 | How many of the workspace’s most used tags are offered to the model. |
Suggestions:ImageCacheMinutes | 10 | How long an image suggestion is reused. 0 switches the cache off. |
Suggestions:MaxBulkTagFiles | 50 | Files 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.
| Setting | Meaning |
|---|---|
AiPricing:Models:<model id>:InputPerMTok | Price of input tokens. |
AiPricing:Models:<model id>:OutputPerMTok | Price of output tokens. |
AiPricing:Models:<model id>:CacheReadPerMTok | Price of cache reads. |
AiPricing:Models:<model id>:CacheWritePerMTok | Price of cache writes (5 minute cache). |
AiPricing:Models:<model id>:CacheWrite1hPerMTok | Price of cache writes (1 hour cache). |
AiPricing:Source, AiPricing:AsOf | Where 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.
| Setting | Default | Meaning |
|---|---|---|
Invites:ExpiryDays | 7 | How long an invitation link is valid (1 to 90). |
Invites:ResendCooldownSeconds | 60 | Minimum time between two sends of the same invitation. |
Invites:MaxEmailsPerRequest | 20 | Most addresses in one invitation. |
RateLimiting:MagicLink:PerEmailPermits | 3 | Sign-in links per email address per window. |
RateLimiting:MagicLink:PerIpPermits | 10 | Sign-in links per client address per window. |
RateLimiting:MagicLink:WindowMinutes | 15 | The window for the two limits above. |
Documents:LockSeconds | 120 | How long an edit lock lasts without a heartbeat. |
Trash:RetentionDays | 30 | Days a deleted item stays in the trash. |
Trash:PurgeIntervalMinutes | 60 | How often expired items are purged. |
Publishing:CommitterName, Publishing:CommitterEmail | mdlake CMS, cms@mdlake.local | The committer of every publish commit. The editor is the author. |
Webhooks:Urls, Webhooks:WorkspaceUrls:<short name> | none | Addresses that receive a small JSON event after each publish or unpublish. |
Tokens:DefaultExpiryDays | 90 | Default lifetime of a workspace token. 0 means no expiry. |
Tokens:RequestsPerMinute | 300 | Requests per token per minute. |
Tokens:BuilderMarketplace | https://github.com/Floydan/mdlake | What the setup guide in Workspace settings shows for /plugin marketplace add. |
MarkdownImport:* | see Importing content | Size and safety limits of the Markdown folder import. |
Cors:Origins | none | Allowed editor origins. |
App:TrustForwardedHeaders | false | Set to true behind a proxy, so rate limits see the client address. |
DataProtection:KeysPath | none | A folder that keeps the session and passkey encryption keys. See below for the Azure alternative. |
DataProtection:BlobUri, DataProtection:KeyIdentifier | none | The key ring in Blob Storage, protected by a Key Vault key. See below. |
KeyVault:Uri | none | Read secrets from this Key Vault at startup. See Running it on a server. |
Seed:AdminEmail | none | The first admin. |
Seed:DemoWorkspace | false | Import 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.ymlmounts thecms-keysvolume at/data/keysfor this.DataProtection:BlobUri: the blob that holds the key ring, such ashttps://<account>.blob.core.windows.net/dataprotection/keys.xml. Create the container first. Also setDataProtection:KeyIdentifier, the https address of the Key Vault key that protects the key ring. SetDataProtection:ManagedIdentityClientIdto 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 fromcms/Dockerfile.migrate. To apply them yourself,cms/scripts/build-migrations.shbuilds 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(andKeyVault__ManagedIdentityClientIdfor 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--ApiKeyisEmail: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__ApiKeyandEmail__From. Optionally setEmail__ReplyToso replies reach a real inbox: it is the Reply-To on every mail the CMS sends (magic links, invites), for exampleEmail__Fromnoreply@your-domainandEmail__ReplyTosupport@your-domain. - Addresses. The API serves the built editor itself, so the editor,
/apiand/mcpshare one address. SetApp__EditorBaseUrlto that public HTTPS address (sign-in links point to it) andPasskeys__ServerDomainto its domain. You do not needCors__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 aSecurecookie outside Development, so the CMS must be served over HTTPS. Behind a proxy such as the Container Apps ingress, setApp__TrustForwardedHeaders=true. - The editor.
cms/Dockerfilebuilds the editor (npm run buildincms/web) into the API image, and the API serves it. The built editor calls/apion its own origin, so leaveVITE_API_BASE_URLunset when you build it. Thecms-webcontainer indocker-compose.ymlruns the Vite development server and is for local development only. - Storage. Use a storage account. Set
Storage__ServiceUriandStorage__ManagedIdentityClientId(the managed identity signs in; the Azure setup stores no storage key in Key Vault) or, without a managed identity,Storage__ConnectionString, andStorage__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__BlobUriandDataProtection__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__Pathon a persistent volume. The production image uses/data/git, which the Azure setup mounts from an Azure Files share. In production, also setRemotes__RequireMirror=truetogether 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 whenRemotes__RequireMirroristrueandGit__Remote__Hostis not set. See Connect GitHub. - Health.
GET /health/liveanswers when the process is up.GET /health/readyanswers 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_STRINGto send requests, logs and the mirror metricmdlake.mirror.lag_secondsto Application Insights. Without it nothing is exported.
The full list of settings is in CMS configuration.
If something goes wrong
| What you see | Likely 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 setting | A required setting is missing. Add it, for example App__EditorBaseUrl or Passkeys__ServerDomain. |
| No email arrives | Open 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 refused | The 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.1 | Open the editor as localhost. |
| Everybody is signed out after a rebuild | The 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 missing | SEED_DEMO_WORKSPACE is false, or the import failed. Look at docker compose logs cms-api. |
| The demo lake is empty | The first publish has not finished. Wait a few seconds and check docker compose logs cms-api. |
What’s next
- Add colleagues: Users and invites.
- Create your first real workspace: Create a workspace.
- Build a site from the demo workspace: Install the builder.
