A reproducible recipe for publishing a subset of an Obsidian vault as a free static site, from a private GitHub repository, in about thirty minutes. Tested on wiki/ content under macOS with Node 22 and pnpm.
Why this combination:
- Quartz renders Obsidian flavored markdown natively — wikilinks, embeds, callouts, frontmatter, tags.
- Cloudflare Pages deploys from private repos for free; GitHub Pages does not.
- Vendored Quartz in a subfolder keeps the vault as the single source of truth. No submodules, no content sync, no parallel store.
wiki/content (or any subdirectory you point Quartz at) publishes to<project>.pages.dev.- Branch pushes get preview URLs.
mainpushes go to production. - Operational files (
log.md,hot.md, anything undermeta/) stay private viaignorePatterns.
For confident readers. Detailed steps and rationale follow below.
# 1. Vendor Quartz (pick latest stable v4 tag from the releases page)
TAG=v4.5.2
git clone --depth 1 --branch "$TAG" https://github.com/jackyzha0/quartz.git site
rm -rf site/.git site/.github site/content site/docs
# 2. Install
cd site && rm package-lock.json && pnpm install && cd ..
# 3. Configure: edit site/quartz.config.ts — pageTitle, baseUrl, ignorePatterns, defaultDateType.
# If your frontmatter uses `updated:`, also patch site/quartz/plugins/transformers/lastmod.ts.
# 4. Local build
cd site && pnpm exec quartz build --serve -d ../wiki
# 5. Push, then in the Cloudflare dashboard: Workers & Pages → Connect to Git.
# Build cmd: cd site && pnpm install --frozen-lockfile && npx quartz build -d ../wiki
# Output dir: site/public. Env: NODE_VERSION=22.- Node ≥ 22 and pnpm ≥ 8 locally (
corepack enable && corepack prepare pnpm@latest --activate). - A Cloudflare account.
- A GitHub repo containing your vault. It may stay private.
- The content you want to publish lives in one directory, e.g.
wiki/.
your-repo/
├── wiki/ # content to publish
├── site/ # NEW — Quartz lives here
└── .gitignore
Quartz is not on npm. The supported install is git clone, so vendor a snapshot.
Pick the latest stable v4.x.y tag from the Quartz releases page, then:
TAG=v4.5.2 # replace with the tag you picked
git clone --depth 1 --branch "$TAG" https://github.com/jackyzha0/quartz.git site
rm -rf site/.git site/.github site/content site/docsYou delete content/ and docs/ because Quartz can read content from outside its tree via --directory. Removing .github/ prevents Quartz's CI from interfering with yours.
Supply-chain note. Tags on GitHub are mutable; anyone with push access to Quartz can move a tag to a different commit. For higher assurance, replace --branch "$TAG" with a clone-then-checkout against a specific commit SHA you read from the releases page, and record the SHA in your commit message.
Pick npm or pnpm. Cloudflare Pages auto-detects either by lockfile. This guide uses pnpm; if you prefer npm, run npm install instead and skip the lockfile swap.
cd site
rm package-lock.json # only if you're switching to pnpm
pnpm install
cd ..Peer-dependency warnings are normal; only hard errors matter. Commit exactly one lockfile (the pnpm one, or the npm one — never both) so Cloudflare picks the right package manager.
# Quartz build artifacts
site/node_modules/
site/public/
**/.quartz-cache/
Commit the vendored framework code under site/quartz/. Do not commit node_modules/ or public/.
Edit the four fields below. Leave everything else default for v1.
configuration: {
pageTitle: "Your Site Title",
baseUrl: process.env.QUARTZ_BASE_URL ?? "your-project.pages.dev",
ignorePatterns: [
"private",
"templates",
".obsidian",
"log.md", // operational log
"hot.md", // LLM hot cache
"meta/**", // lint reports, conventions
],
defaultDateType: "modified",
}Notes:
baseUrltakes no scheme and no trailing slash. Quartz adds them.QUARTZ_BASE_URLlets local builds uselocalhost:8080without editing the file.ignorePatternsglobs are relative to--directory, not the repo root.defaultDateType: "modified"prefers frontmatter dates over git mtime, which the auto-commit hook would otherwise pollute.quartz.config.tsis committed code. When you enable analytics, comments, or any plugin that takes a key, put the value in a Cloudflare Pages environment variable and read it viaprocess.env. Do not commit secrets to this file.
Quartz's CreatedModifiedDate transformer reads frontmatter.modified but not frontmatter.updated. If your frontmatter uses updated: — a common Templater and Obsidian Web Clipper default — Quartz falls back to git mtime, and pages show today's date instead of the curated one.
Three options, in increasing order of long-term hygiene:
Option A — one-line patch to vendored code (fastest, technical debt). Edit site/quartz/plugins/transformers/lastmod.ts:
// before:
modified ||= file.data.frontmatter.modified as MaybeDate
// after:
modified ||= (file.data.frontmatter.modified as MaybeDate)
?? (file.data.frontmatter.updated as MaybeDate)Note the patch in your commit message so you reapply it on Quartz upgrades.
Option B — rename in the vault. Mass-rewrite updated: to modified: across your frontmatter. One-time cost; no ongoing maintenance. Breaks any other tooling that reads updated:.
Option C — upstream the fix. Open a PR against jackyzha0/quartz. Same effort as Option A and benefits every Quartz user. Worth doing if you plan to maintain this vault for any length of time.
Add two convenience scripts to site/package.json so local builds use a local baseUrl:
"scripts": {
"build:local": "QUARTZ_BASE_URL=localhost:8080 npx quartz build -d ../wiki",
"serve:local": "QUARTZ_BASE_URL=localhost:8080 npx quartz build --serve -d ../wiki"
}-d ../wiki points Quartz at content outside its own tree. No symlinks, no copies. Then:
cd site && pnpm serve:local
# open http://localhost:8080Walk five pages by hand. Click wikilinks. Try search. Confirm callouts render and tag chips work. Fix any frontmatter parse error the build reports — these fail the build. Broken wikilinks only warn.
If your vault defines callouts beyond Obsidian's built-in set (e.g. [!gap], [!key-insight]), port the styles into Quartz. Custom styles live under site/quartz/styles/, not site/styles/. Append a new SCSS file there and @use it from custom.scss:
// site/quartz/styles/callouts.scss
.callout[data-callout="gap"] {
--color: 220, 220, 170;
--border: rgb(var(--color));
--bg: rgba(var(--color), 0.1);
}Then append to site/quartz/styles/custom.scss:
@use "./callouts.scss";Quartz callouts use --color, --border, and --bg for colors, plus --callout-icon-{type} URLs for icons. The example above is the minimal color-only port. To ship a matching icon, set --callout-icon to an inline SVG data: URI alongside the colors — see Quartz's built-in callout rules for the pattern.
Three syntaxes do not survive Quartz:
# Dataview blocks render as raw code fences
grep -rln '```dataview' wiki/
# Excalidraw embeds need a plugin Quartz doesn't have
grep -rEn '\.excalidraw|excalidraw-' wiki/
# Embeds reaching outside the published directory render broken
grep -rEn '!\[\[\.\./|_attachments/' wiki/For each finding, either move the asset under your content directory, replace with a static rendering, or accept and document.
Push your branch first:
git checkout -b feat/quartz-publishing
git add site/ .gitignore
git commit -m "feat: add Quartz publishing"
git push -u origin feat/quartz-publishingThen in the Cloudflare dashboard. UI menu names shift over time — if the path below has moved, follow the current Cloudflare Pages docs.
- Workers & Pages → Pages → Connect to Git. Authorize the Cloudflare GitHub App. It requires read access to clone the repo and write access to post commit statuses for preview deploys. Grant the app access to the single repo you're deploying, not the whole organization.
- Project name: your subdomain (
your-projectbecomesyour-project.pages.dev). - Production branch:
main. - Build command:
cd site && pnpm install --frozen-lockfile && npx quartz build -d ../wiki - Build output directory:
site/public. - Root directory: leave blank.
- Environment variables:
NODE_VERSION=22.
Cloudflare auto-detects pnpm from site/pnpm-lock.yaml. First build runs against the branch you pushed; preview URL is <branch>.<project>.pages.dev. When you merge to main, production goes live at <project>.pages.dev.
Confirm the same five pages you walked locally. The most common breakage is a baseUrl mismatch: locally you used localhost:8080, but the deploy expects <project>.pages.dev. The env-var pattern from step 4 prevents this.
These cost time during the original setup. Avoid them by following the order above.
| Symptom | Cause | Fix |
|---|---|---|
pnpm install fails with workspace errors |
A package-lock.json is still present |
Delete it before pnpm install |
| Build runs but the deployed site lacks half your pages | ignorePatterns globs were written relative to the repo root |
Globs are relative to -d <dir> — use meta/** not wiki/meta/** |
| Custom callouts render but stay uncolored | SCSS placed under site/styles/ instead of site/quartz/styles/ |
Move to site/quartz/styles/, @use from custom.scss |
| Local build works, deploy ships broken absolute URLs | baseUrl hard-coded to localhost or production |
Use process.env.QUARTZ_BASE_URL ?? "your-project.pages.dev" |
| Modified dates show today's date on every page | Quartz used git mtime (polluted by auto-commit hooks), and if your frontmatter uses updated: Quartz ignores it |
Set defaultDateType: "modified", populate frontmatter dates, and if you use updated: apply one of the fixes in Step 4b |
| Cloudflare build picks the wrong package manager | Both package-lock.json and pnpm-lock.yaml present |
Commit only the pnpm lockfile |
| Build fails on a single bad page | Quartz fails the build on frontmatter parse errors | Fix the YAML; broken wikilinks only warn, they do not fail |
| Images appear broken on the deploy | Embeds point to paths outside the published directory | Move assets under your content directory, or add a copy step |
Vendoring trades automatic updates for reproducibility. To upgrade:
cd site
NEW_TAG=v4.x.y # from the Quartz releases page
git fetch --depth 1 https://github.com/jackyzha0/quartz.git "refs/tags/$NEW_TAG"
git checkout FETCH_HEAD -- .
# Review the diff, especially quartz.config.ts and quartz.layout.ts.
# Reapply your edits if upstream touched the same fields.
pnpm install
pnpm build:localCommit the upgrade as a single change. Read the Quartz release notes for breaking changes.
Out of scope (deferred): custom domain, analytics (Plausible plugs in), OG cards, RSS, Giscus comments, CI lint step.
License: Quartz is MIT — the vendored copy under site/quartz/ inherits it; keep site/LICENSE.txt. Your content keeps whatever license you publish it under.