The premise

The old site was an Astro and Sanity build with a game-world portfolio — a hero character walking across a map. Handsome, and completely wrong for what was actually needed: a place to put 186 notes about ESP32 temperature sensors, a Grundig camera system, HOMM3 internals, and language study, without opening a CMS dashboard ever again.

The requirement was one sentence long: write in Obsidian, have it appear on the internet, do nothing else.

That sentence turned out to contain about a dozen hidden problems.


Act I — Choosing the machine

Quartz v5 won because it reads an Obsidian vault natively. No export step, no content transformation, no second copy of the truth.

The first decision that mattered was structural, and it wasn’t obvious at the time.

1. Where does the vault live?

The instinct is to keep the vault separate and point Quartz at it. But Quartz builds from content/ inside its own repo, and Obsidian Git can only commit downward from the vault root — its “custom base path” setting handles a repo nested inside a vault, never the reverse.

Resolution: open the Quartz repo root as the Obsidian vault, with notes in content/. One .git, one commit, everything versioned together — config, stylesheets and notes in the same snapshot. This is also what the canonical Quartz tutorials do; it looked like a hack and wasn’t.

Cost: .obsidian/ moves up to the repo root, and every vault-absolute path in the notes gains a content/ prefix. Attachment folder settings and Dataview FROM clauses need rechecking.

2. Obsidian shows folders but not code files

After the move, .ts, .scss and .yaml files vanished from the file explorer.

Not a bug. Obsidian only renders markdown, canvas, images and a few media types. The files are on disk and — crucially — Obsidian Git commits them regardless of what Obsidian displays. It runs git add against the working tree, not against Obsidian’s view of it.

Resolution: edit code in VS Code, notes in Obsidian, same folder underneath. For explorer clutter, a CSS snippet at .obsidian/snippets/hide-quartz.css hides the scaffolding by data-path. The “Excluded files” setting alone doesn’t do it — that only affects search and suggestions.


Act II — The publishing loop

3. “Can’t Quartz just push on every change?”

It can’t. Quartz is a build tool that runs and exits; GitHub Actions only wakes on a push it receives. Neither can notice a file changing on a laptop.

Surveying what Quartz users actually do:

ApproachTriggerMobile
npx quartz sync by handyou type itno
Obsidian Git, timedintervalyes
Quartz Syncer pluginbuttonyes
cron / systemd timerintervalno

Every automated option is something on a timer running git push. That’s the entire design space. The popular video tutorial doesn’t automate at all — its publish step is literally “run npx quartz sync.”

Resolution: Obsidian Git, the only option covering Ubuntu, Windows and Android.

4. Commit often, push rarely

The interval isn’t one setting, it’s two — and conflating them costs money.

Commits are free and local. Pushes trigger a site rebuild, which consumes hosting quota.

Settings landed on:

SettingValueWhy
Vault backup interval10 minnever lose more than 10 minutes
Auto push interval30 minone rebuild per half hour, not per paragraph
Pull on startuponprevents divergence across three devices
Push on startuponcatches whatever was open at shutdown

Obsidian Git skips the commit entirely when nothing changed, so an idle vault costs nothing.

5. Why pull at all?

Push is not the counterpart of pull. Push publishes; pull prevents conflicts. Writing from three devices without pulling means editing a stale copy and resolving a merge conflict by hand — in Obsidian, on a phone.

6. Git vs. Remotely Save + Dropbox

Both were running, watching the same folder, on overlapping timers.

Git merges line by line, so two devices editing different paragraphs of one note combine cleanly. Dropbox operates on whole files and produces note (conflicted copy).md. But when two devices edit the same lines, git gives you conflict markers to resolve manually — better at avoiding conflicts, equally unpleasant at resolving them.

Resolution: git alone, with Android treated as capture-only. Conflicts need two devices touching one file; if the phone only creates new notes in an inbox folder, the failure mode disappears by construction.

7. GitHub authentication

remote: Invalid username or token. Password authentication is not supported.
fatal: could not read Username: No such device or address

Two errors stacked. GitHub dropped password auth for git in 2021; and the https:// remote tried to prompt for credentials via ssh-askpass, which wasn’t installed.

Resolution:  Personal Access Token or SSH keys. Token was generated in GitHub / Settings / Developer settings / Personal access tokens / classic. Then pasting these lines into git to do not have manually write credentials each time pushing or pulling something.

git config --global credential.helper store 
git push

Act III — Where to host it

This took three attempts and one wrong turn.

8. GitHub Pages needs a paid plan for private repos

The vault lives in the repo, so the repo is private. GitHub Pages serves private repos only on Pro and above — roughly $4/month.

9. Netlify vs. Cloudflare: the unit matters

The familiar choice was Netlify (existing account). But the two platforms count differently, and for this workload the difference is decisive:

  • Netlify: 300 build minutes/month. A ~2 min Quartz build = ~150 builds ≈ 5/day
  • Cloudflare: 500 builds/month, duration irrelevant ≈ 16/day

Quartz builds get slower as a vault grows, so Netlify’s effective ceiling drops over time while Cloudflare’s doesn’t. Exactly backwards for a garden that keeps accumulating.

Resolution: Cloudflare Pages.

10. The Workers detour

Cloudflare’s dashboard now nudges everyone toward Workers. Following “Create a Worker” led to a screen asking for a deploy command — npx wrangler deploy — which expects a Worker script, not a folder of static HTML.

Two failures followed:

Authentication error [code: 10000]

Token had Super Admin on the account but lacked the explicit Cloudflare Pages permission. Account role and token scope are separate in Cloudflare’s model.

✘ [ERROR] The Pages project "xzcxfe" does not exist.

Workers Builds doesn’t provision a Pages project as a side effect. xzcxfe was the random slug Cloudflare assigned the Worker.

Resolution: abandon that flow entirely. The sidebar is now labelled Compute (Workers), and Pages is a separate option on the Create screen — not a separate section. Compute (Workers) → Create → Pages → Connect to Git.

11. The settings that finally worked

FieldValue
Production branchv5
Framework presetNone
Build commandgit fetch --unshallow && npx quartz plugin install --from-config && npx quartz build
Build output directorypublic
NODE_VERSION env var22

Three non-obvious pieces:

  • git fetch --unshallow — Pages clones shallow; Quartz reads git history for each file’s last-modified date. Without this, every page shows the same date.
  • --from-config — installs plugins listed in quartz.config.yaml. Skipping it reproduces the Cannot find module '@quartz-themes/default' error on the build machine.
  • NODE_VERSION=22 — Pages’ default is older than Quartz v5 wants.

Act IV — The disappearing vault

The worst bug of the rebuild, because the error message pointed at the wrong thing.

12. Found 1 input files from 'content'

186 markdown files on disk. Quartz found one.

The build log said Filtered out 0 files, which looked like exoneration for ignorePatterns — but that line refers to the Filters stage (RemoveDrafts et al.), which runs after parsing. ignorePatterns applies during discovery, so excluded files are never counted as found in the first place. The log line that appears to rule out the culprit is actually consistent with it.

Suspicion fell on the pattern list. It was wrong.

Actual cause: the vault sat at content/Obsidian_Vault/Public/... nested. Unquoted spaces break glob matching, so patterns intended to exclude one folder matched far more of the path than intended.

Resolution: flatten. Vault contents directly under content/, Everything appeared immediately.

13. Excluding loose notes from the content root

The want: type a quick note without choosing a folder, don’t publish it.

yaml

ignorePatterns:
  - "!(index).md"

Act V — Making it look like something

14. The CSS that did nothing

A hand-written custom.scss was applied. Exactly one rule took effect — the timestamp box. Everything else was inert.

Three distinct failure modes were in play, and they need different fixes:

BucketSymptomFix
1. Losing the cascadeselector matches, but theme CSS overrides itraise specificity
2. Selector matches nothingv4 class name, renamed in v5find the real class name
3. Works, but imperceptible3.5% opacity grain; Chromium-only scroll animationsee it before debugging it

The decisive test — body { background: #ff0000 !important; } as the first line. Screen goes red = file loads, cascade problem. Screen unchanged = file never reaches the browser. That single result halves the problem, and the two halves have nothing in common.

Result: red. So, cascade.

Root cause, found in the generated HTML: the class list contained markdown-preview-view, popover-hint, nav-folder-title — Obsidian’s class names, not Quartz’s. That’s the whole mechanism of @quartz-themes: it relabels Quartz markup with Obsidian classes so unmodified Obsidian themes apply.

Which means theme rules look like .markdown-preview-view h2 — specificity 0,1,1. Custom rules looked like .page article h2 — also 0,1,1. An exact tie, broken by source order, and theme CSS loads later.

Every rule lost by exactly one position.

Resolution: wrap everything in .page.page.page. Matches identical elements, scores 0,3,x, clears the theme cleanly — no !important anywhere.

scss

@use "./base.scss";
 
.page.page.page {
  article h2 { font-size: 1.3rem; }
}

Sidebar rules go in a parallel .sidebar.sidebar.sidebar block, since the sidebar is a sibling of .page, not a child. Viewport-fixed effects (grain overlay, scroll progress) stay outside both — scoping them to .page would clip them to the article column.

15. Theme plugin: enable or disable?

Both, depending on intent — and this flipped mid-project:

  • Hand-written palette? Set quartz-themes to enabled: false, or it injects its own variables after yours and silently overwrites them.
  • Adopting a theme? Leave it enabled. That’s the entire point.

Landed on origami.gruvbox via @quartz-themes, with a slim custom.scss layer on top for structure the theme doesn’t provide.

Related: quartz.config.yaml had a typography block (Fraunces) fighting the theme’s own fonts — visible as fonts loading twice in the HTML, with Quartz’s defaults arriving last. Delete the typography block and let the theme own it.

16. Trying a different theme without destroying anything

bash

git commit -m "working state: origami.gruvbox"
git checkout -b theme/cyberglow

Experiment freely; git checkout main && git checkout -- . restores everything. Preview first at quartz-themes.github.io/<theme-name> and skip the branch entirely if it’s wrong.

Anything in custom.scss using var(--secondary), var(--lightgray) etc. follows whatever palette is active. Hardcoded hex values (--tannin: #b98a4b) don’t.


Carried over from earlier sessions

Two problems predating this rebuild. Both still open — recorded here so they aren’t rediscovered from scratch.

Waypoint

Used for folder-based table-of-contents generation. Two failure patterns:

  1. Subfolder links missing the parent path prefix — generated links don’t resolve.
  2. Self-referential links using vault-absolute paths prefixed with Public/, which escape Quartz’s content root entirely.

Partial progress: Waypoint’s Custom Filename setting accepts index, which resolves part of the Waypoint/Quartz naming conflict.

Also established: Obsidian comment syntax doesn’t hide Waypoint blocks in Quartz. Only the marker lines get stripped, leaving the bare link list visible. Hiding them properly needs either a custom transformer plugin (order < 30, running before OFM) or a pre-build perl one-liner in CI.

Note: the flattening fix from §12 changed every path in the vault. Waypoint output needs regenerating and re-checking before either symptom can be diagnosed further — the old broken paths are no longer the current broken paths.

Local graph view

A slug mismatch in quartz-community/graph renders folder index pages as isolated nodes in the local graph. Pages are keyed as studies/khan-academy/index but served at /studies/khan-academy/, so edges don’t connect.

Global graph is unaffected. Upstream bug, not a config error.

An earlier session found empty links arrays in contentIndex.json, suggesting the CrawlLinks transformer may be absent from the plugin pipeline. Unverified. If confirmed, this would explain both the graph behaviour and the Waypoint link resolution — worth checking before treating them as separate problems.


Still outstanding

  •  Rotate the exposed credentials. Two published files carried a camera password and a service identifier.
  •  Explorer disappearing on click — isolation test (comment out the .explorer block, rebuild) never run
  •  Mobile footer overlapping Recent Notes
  •  Delete the typography block from quartz.config.yaml
  •  Regenerate Waypoint output against the flattened paths
  •  Verify CrawlLinks is in the pipeline
  •  Run npx quartz upgrade once, on a branch, to find out what breaks before six months of notes depend on it
  • Custom footer “*Built with Quartz v5 · Written in Obsidian · Synced via Git · Published on Cloudflare Pages”