The lake in CI, and caches
How a build on a CI runner gets the lake, what the read-only lake token is and how to create it, which folders to cache and why, and what to do when the fetch or the build fails. For developers.
On this page
On your machine the build reads the lake from a clone that the kit manages for you. In CI there is no such clone, so the pipeline has to fetch the lake first, with a token that can read the lake repository and nothing else, and it should keep the slow parts between runs. The pipelines from npx mdlake hosting init already do all of this. This page explains what they do, so you can set up the token, change a pipeline you adopted, or fix a failure.
For the concepts behind it, see How sites work.
How the lake reaches the build
A site has a file lake.lock, committed in the repository:
{
"lake": {
"workspace": "acme",
"remote": "https://github.com/acme-org/lake-acme.git",
"commit": "9b1c0e2d7f4a6c3b8e5d1a0f2c4b6d8e9a7c5b3d",
"lockedAt": "2026-10-20T09:14:00Z"
},
"mode": "snapshot"
}npm run check starts with mdlake prepare, and so does npm run build (through its prebuild script). prepare does this:
- If
LAKE_PATHis not set andlake.lockhas aremote, it makes the folder.mdlake/lakea git repository whoseoriginis that remote. - If the folder is already at the locked
commit, it does nothing and does not touch the network. - Otherwise it fetches exactly that commit (
git fetch --depth 1 origin <commit>) and checks it out. The fetch is shallow, so only that commit’s files are downloaded, not the history.
The commit must be the full 40-character hash in lower-case hexadecimal. A short hash cannot be fetched, so lake.lock with one is invalid.
In snapshot mode (the default) the build then verifies that the lake is at the locked commit, and fails if it is not. In live mode the check is skipped. See How sites work.
Two rules follow for CI:
- Do not set
LAKE_PATHin CI. It overrides the managed clone, and the build would read whatever folder it names. Leave it unset so the commit inlake.lockis what you build. - The remote in
lake.lockmust not contain credentials.preparerefuses a remote such ashttps://user:token@github.com/.... Use the plain URL and pass the token asMDLAKE_LAKE_TOKEN.
The lake token
The lake repository is private (lake-<slug> under the owner you set in the CMS; see Connect GitHub). The runner needs read access. Give it a token that can read this one repository and nothing more.
Requirements on the runner
gitinstalled. GitHub-hosted runners and Microsoft-hosted agents have it. A self-hosted agent might not. The error then reads “git was not found”.- Network access to the lake’s host (
github.com). - The variable
MDLAKE_LAKE_TOKEN, set as a secret.
Create a fine-grained GitHub token
- In GitHub, open Settings, Developer settings, Personal access tokens, Fine-grained tokens, and choose Generate new token.
- Set the Resource owner to the owner of the lake repository (the user or organization).
- Under Repository access choose Only select repositories and select the
lake-<slug>repository. - Under Permissions, Repository permissions, set Contents to Read-only. Leave everything else at no access.
- Set an expiry. Put the date in your calendar: when the token expires, every build fails until you replace it.
- Generate the token and store it as
MDLAKE_LAKE_TOKEN: as a repository secret in GitHub Actions or a secret variable in Azure DevOps. See GitHub Actions and Azure DevOps.
A token that belongs to a person stops working when that person leaves the organization. A GitHub App installation token avoids this. The CMS’s own GitHub App writes to the lake and is not meant for this. Create a second app with Contents: read-only permission, install it on the lake repository, and generate an installation token in the pipeline before the check step. The kit sends MDLAKE_LAKE_TOKEN as HTTP Basic authentication with the user name x-access-token, which is also the form GitHub expects for an installation token.
Warning
Never put the token in lake.lock, in .env in the repository, or in a workflow file. Anyone who can read the repository could read the lake.
How the token is used
The kit hands the token to the one git fetch process that talks to the remote, as an HTTP header for the remote’s host only. It does not appear in the command line, in .git/config or in the log. Error messages that quote the remote or git’s output have the token removed.
What to cache
A cold build downloads the lake and every asset, and converts every image. A warm build skips all three. Three folders are worth keeping:
| Folder | Holds | Why it helps |
|---|---|---|
.mdlake/lake | The managed lake clone at the locked commit | Avoids the fetch |
node_modules/.astro/assets | Images already converted by Astro’s image pipeline (WebP, AVIF, several widths) | Image conversion is the slowest part of a cold build |
node_modules/.cache/mdlake-assets | Assets downloaded for the build | Avoids downloading every file again |
All three are keyed on lake.lock. The key changes with every new lake commit, so a refresh starts with an empty cache, and the next builds of that commit are warm. A stale cache would only cost time: image entries are keyed on the source image and download entries on the asset id and the sidecar’s content, so a cache can never serve the wrong bytes.
The generated steps look like this in GitHub Actions:
- name: Cache the lake clone and the image caches
uses: actions/cache@v4
with:
path: |
.mdlake/lake
node_modules/.astro/assets
node_modules/.cache/mdlake-assets
key: mdlake-${{ runner.os }}-${{ hashFiles('lake.lock') }}And in Azure DevOps, three Cache@2 tasks with keys of the form mdlake0 | "$(Agent.OS)" | lake.lock. Both come after npm ci, because npm ci deletes and recreates node_modules.
Keep the cache step after npm ci and before npm run check if you rewrite the pipeline.
Cache limits
- GitHub removes a cache that has not been used for seven days. Each repository also has a total size limit (10 GB by default), and GitHub removes the oldest caches when it is exceeded. These are GitHub’s limits, not mdlake’s; they can change, so see GitHub’s documentation for the current values.
- Azure DevOps caches are scoped to the pipeline and the branch, with a fallback to the default branch.
- A pull request in GitHub cannot write a cache that the
mainbranch reads. The generated workflow does not run on pull requests, so this does not matter for it.
The asset host must be reachable
Pages never link to the asset host. The build copies the files they use into dist/files and the pages link to /files/<id>/<name>.<ext> on your own domain. To do that it downloads each asset from the url in its sidecar (the public address the CMS wrote, in Blob Storage), or from PUBLIC_ASSET_BASE_URL when you set it.
So the runner must be able to reach that address. If the storage account sits behind a firewall or a private network, add the runner’s address to the allow list, or put a CDN in front of the storage and set PUBLIC_ASSET_BASE_URL to it (as a pipeline variable). An asset that cannot be downloaded, or an asset:// link whose sidecar is missing, fails the build with the asset id and the pages that use it.
Other variables in the build
| Variable | Use in CI |
|---|---|
MDLAKE_LAKE_TOKEN | Required unless the lake repository is public. |
SITE_URL | The public address. Without it canonical links and the sitemap use https://www.example.com. |
PUBLIC_TURNSTILE_SITE_KEY | The widget’s public key, only for forms with Turnstile. |
PUBLIC_ASSET_BASE_URL | Optional. Where assets are fetched from instead of the sidecar url. |
ASSET_OPTIMIZE=false | Skip image conversion for faster builds, for example in a preview pipeline. Not for production. |
SITE_INDEXABLE=false | Adds noindex to every page and disallows everything in robots.txt. Use it for staging. |
LAKE_LOCK_CANDIDATE=<commit> | Build and check against another lake commit. Used by the refresh flow to preview new content. Do not set it in the production pipeline. |
LAKE_LOCK_IGNORE=1 | Skip the lock check. Emergencies only: the build prints a warning, “Do not deploy this build”. |
See Site configuration for all site variables.
Check a pipeline locally
You can reproduce what CI does from a clean state:
rm -rf .mdlake/lake node_modules/.astro node_modules/.cache/mdlake-assets
MDLAKE_LAKE_TOKEN=<token> npm run checkRemove-Item -Recurse -Force .mdlake/lake, node_modules/.astro, node_modules/.cache/mdlake-assets -ErrorAction SilentlyContinue
$env:MDLAKE_LAKE_TOKEN = "<token>"
npm run checkYou see the same messages as the pipeline: “Fetching lake commit … from …” and, at the end, “lake … fetched …”. Unset LAKE_PATH in your shell and .env first.
Problems
| Message or symptom | Cause and fix |
|---|---|
Could not read the lake at ... git was not allowed in | The token is missing, expired, or has no access to the repository. Check the secret, the expiry and Repository access. Locally, git ls-remote <remote> shows whether your account can read it. |
git was not found | The runner has no git. Install it in the pipeline or use a hosted runner. |
The commit locked in lake.lock is not in <remote> | The CMS has not mirrored that commit yet, or the history was rewritten. Check Lake repository in the CMS (see Connect GitHub), or lock a commit the remote has with the refresh flow. |
Expected the full 40-character git commit hash | lake.lock holds an abbreviated hash. Run the refresh-content skill, or write-lake-lock.mjs <site> --commit <hash> from the plugin, which writes the full hash. |
lake.lock must not contain credentials in its remote | Remove the credentials from the URL. Use MDLAKE_LAKE_TOKEN. |
The lake at ... is at ..., but lake.lock locks ... | The lake folder is not at the locked commit. In CI this means LAKE_PATH is set or a cache from another commit was restored over a newer folder. Unset LAKE_PATH, and make sure the cache key contains lake.lock. |
The lake folder .mdlake/lake does not exist | prepare did not run, or lake.lock has no remote. Run npx mdlake prepare and check lake.lock. |
| The build fails on one asset | The runner cannot download it. Check the asset’s url, the storage firewall and PUBLIC_ASSET_BASE_URL. |
| Every run is as slow as the first | The cache is not restored. Check that the cache step comes after npm ci and that its key contains lake.lock. Look for a “Cache hit” line in the log. |
| The pipeline passes but the site shows old content | The site shows the locked commit. Publishing in the CMS does not change it. Refresh and commit lake.lock. See Refresh content. |
What’s next
- GitHub Actions and Azure DevOps for the pipelines.
- Refresh content to move
lake.lockto newer content. - Troubleshooting.
