Building a Public Notes Site with Quartz 5 — Phase 2: Privacy, Proof, and a Clean Commit

Phase 2 of my Quartz notes site: I reviewed every setting, turned on opt-in publishing, proved private notes stay private with a test note and grep, found my email already exposed in old GitHub commits, and pushed a clean first commit — mistakes included.

This is Part 2 of a 3-part series on building my public notes site. Start with Part 1: Local Build if you haven’t read it.

What Phase 2 delivered

Phase 2 turned a blank Quartz install into a private-by-default notes site, proved the privacy works, and put the first clean commit on GitHub. In Phase 1 I built the site locally; this phase was about trust: making sure nothing reaches the public internet unless I mark it.

By the end of the session:

  1. The config was reviewed line by line. Six settings changed, including two privacy problems I hadn’t expected.
  2. Opt-in publishing is on. A note appears on the site only if it carries publish: true.
  3. The filter was proven, not assumed. A deliberately unmarked test note was checked four different ways.
  4. My commit identity was audited. I discovered my email address was already public in older GitHub commits, and switched to a private address.
  5. The first real commit was pushed, with its email verified before it left my machine.
  6. My GitHub login token was located and checked: encrypted storage, standard permissions.

This post doubles as my reference. Every command is listed at the end with what it does.

A note on what’s left out: terminal prompts are trimmed to $, and I’ve omitted usernames, home paths, private email addresses and the other services on my machine. A public lab journal is reconnaissance material too.

Step 1: Reviewing quartz.config.yaml

Reading the whole config before changing anything turned up two privacy problems beyond the switch I came to flip. Quartz 5 keeps every setting in one YAML file, quartz.config.yaml, created by the setup wizard in Phase 1.

$ cd ~/projects/notes
$ cat quartz.config.yaml

The six changes

#SettingBeforeAfterWhy
ApageTitleQuartz 5NY2Honolulu NotesMy site’s name, shown top left and in browser tabs
Banalyticsprovider: plausiblenullA third-party tracking script on every page, with no account behind it
Cexplicit-publish pluginenabled: falseenabled: trueThe core control: only notes marked publish: true get published
Dencrypted-pages pluginenabled: trueenabled: falseA false sense of security in a public repo (see below)
Eobsidian-plugin-excalidrawenabled: trueenabled: falseIt failed to load in Phase 1, and I don’t publish drawings
FFooter linksQuartz’s GitHub and DiscordMy notes repo and my blogVisitors expect links to me, not to the framework’s chat server

The trap: “encrypted pages” in a public repo

This plugin lets you put password: something in a note’s frontmatter, and the website shows that page locked. The catch: my source files live in a public GitHub repo. Anyone can open the .md file there and read both the content and the password in plain text. The lock exists only on the website.

A control that looks like protection but isn’t is worse than no control, because it invites you to trust it. My rule instead: if a note is private, it never enters this repo at all.

Analytics set to null

With no Plausible account, the setting collected nothing useful, but it would still have loaded an outside script on every page. The repo history showed a recent Quartz commit titled “allow null for analytics field,” which confirmed analytics: null is the supported way to switch it off.

What I deliberately left alone

SettingValueWhy it stays
ignorePatternsprivate, templates, .obsidianAny folder with those names is skipped: a third safety layer
remove-draft pluginenabledNotes marked draft: true are hidden
cname pluginenabledGenerates the custom-domain file needed in Phase 3
comments plugindisabledNo third-party comment system
fontOrigingoogleFontsVisitors’ browsers fetch fonts from Google. Common and acceptable; worth knowing which outside services a site contacts
Encrypted-pages options: blockkeptIgnored while the plugin is off; no need to delete

A warning I chose to ignore

Zed flagged position: footer on the footer plugin: “Value is not accepted. Valid values: left, right, beforeBody, afterBody, body.” I didn’t touch that line, and the same warning existed before my edits. The Phase 1 build rendered the footer correctly with this exact value, and a Quartz commit from three months earlier was titled “make header/footer configurable.” Quartz accepts footer; the editor’s schema file is out of date.

Changing it to afterBody would have silenced the warning and moved my footer under the article. When the tool that checks code disagrees with the tool that runs it, trust the one that runs it, then confirm with a build.

Step 2: Editing YAML (and three mistakes)

Three small editing mistakes each produced a confusing error, and each taught a YAML rule worth remembering. I edited the file in Zed:

$ zed quartz.config.yaml

Mistake 1: no space after the colon

I typed analytics:null. Zed showed red squiggles and the message “Implicit keys need to be on a single line.”

The rule: in YAML, a colon means “key: value” only when a space follows it. Without the space, YAML reads analytics:null as one long word, not a setting.

Mistake 2: leaving the old child line behind

The original setting spanned two lines:

  analytics:
    provider: plausible

I changed the first line but left provider: plausible underneath. Combined with the missing space, the error count rose to 4 and the message became “Property analytics:null locale is not allowed”: YAML had glued the broken key onto the next line, locale. Deleting the leftover line (Ctrl + Shift + K in Zed) and adding the space fixed both. The correct result:

  enablePopovers: true
  analytics: null
  locale: en-US

Mistake 3: selecting the line break

To replace the Discord footer link, I highlighted from the start of the line to the end, but the selection also caught the line break. My typed text joined the line above, producing one broken line:

        GitHub: https://github.com/ny2honolulu/notesBlog: https://ny2honolulu.com

The error read “Nested mappings are not allowed in compact mappings.” The fix: click between notes and Blog and press Enter, then confirm Blog lines up exactly under GitHub. The final footer:

      links:
        GitHub: https://github.com/ny2honolulu/notes
        Blog: https://ny2honolulu.com

Editor habits that helped

HabitWhy
Change only the word, not the whole line (double-click false, type true)Keeps indentation intact; YAML uses spaces only, never tabs
Watch the error count in the status barIt dropped 4 → 1 as each fix landed; the last 1 was the known schema warning
Watch the dot on the file tabA dot means unsaved changes; Ctrl + S clears it
Ctrl + ZUndoes step by step when an edit goes wrong

Verifying from disk, not from the screen

An editor shows what’s on screen; the build reads what’s on disk. So I checked the saved file from the terminal:

$ grep -n -A1 -E "pageTitle|analytics|explicit-publish|encrypted-pages|excalidraw|links:" quartz.config.yaml
3:  pageTitle: NY2Honolulu Notes
4:  pageTitleSuffix: ""
--
7:  analytics: null
8-  locale: en-US
--
65:      wikilinks: true
66-      callouts: true
--
119:  - source: "@quartz-community/explicit-publish"
120-    enabled: true
--
125:  - source: "@quartz-community/encrypted-pages"
126-    enabled: false
--
231:      links:
232-        GitHub: https://github.com/ny2honolulu/notes
--
268:  - source: "@quartz-community/obsidian-plugin-excalidraw"
269-    enabled: false
FlagMeaning
-nShow line numbers
-A1Show one line after each match, so the enabled: line appears
-EExtended patterns, so | means “or”

Two extra matches appeared, and both were expected: pageTitleSuffix contains “pageTitle,” and wikilinks: contains “links:”. grep matches text, not meaning. I also learned git diff couldn’t verify this file: it was new and never committed, so Git had no earlier version to compare against.

Step 3: The homepage and its frontmatter

Turning on ExplicitPublish would have hidden my own homepage, because the starter content/index.md had no publish: true. The original file:

---
title: Welcome to Quartz
---

This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.

I replaced it with my own homepage:

---
title: NY2Honolulu Notes
publish: true
---

Welcome to my public study notes — the companion to [ny2honolulu.com](https://ny2honolulu.com).

I'm a self-directed learner building cybersecurity skills from Honolulu. These notes follow the "Obsidian for Learning" method from No Boilerplate: small, linked notes written in my own words.

## What's here

- **Black Hat Python** — notes on networking, sniffing, and offensive Python
- **AWS DVA-C02** — notes for the AWS Certified Developer exam
- **Lab work** — lessons from my home cyber range

New notes appear as I study. Use the search box or the graph to explore.

What frontmatter is

The block between the two --- lines is frontmatter: settings for this one note, not text visitors read.

FieldEffect
title:The page heading and the browser-tab title
publish: trueThe permission slip. Without it, ExplicitPublish blocks the page

The rule that matters: the opening --- must be the very first line of the file, with nothing above it, not even a blank line. Otherwise Quartz treats the block as ordinary text, finds no publish: true, and silently blocks the page.

To replace the file in Zed: zed content/index.md, then Ctrl + A, Delete, paste, Ctrl + S.

Step 4: Proving the filter works

A security control isn’t finished when it’s switched on; it’s finished when you’ve watched it block what it should. I created a note designed to fail and checked it four ways.

The test note

$ echo "SECRET TEST: this note has no publish flag and must NOT appear on the site." > content/test-private.md
$ ls content/
index.md  test-private.md

echo prints text; > redirects it into a new file. The note has no frontmatter, so no publish: true. Silence after the first command meant success: most Linux commands only print when something goes wrong.

Check 1: the build report

$ npx quartz build --serve --port 8081
Found 2 input files from `content` in 24ms
Warning: content/index.md isn't yet tracked by git, dates will be inaccurate
Warning: content/test-private.md isn't yet tracked by git, dates will be inaccurate
Parsed 2 Markdown files in 182ms
Filtered out 1 files in 65μs
Emitted 46 files to `public` in 1s
Started a Quartz server listening at http://localhost:8081
LinePhase 1Phase 2Meaning
Input files found12Both notes were seen
Files filtered out01The test note was blocked
Files emitted4846Excalidraw no longer generates its files
Excalidraw warningpresentgoneEdit E worked
Theme installranskippedInstalled once, in Phase 1

Port 8081, not the default 8080, because another local service already uses 8080.

Check 2: the homepage

At localhost:8081 the title read NY2Honolulu Notes, my welcome text appeared, and the footer showed GitHub · Blog. Two quieter signs: the Explorer listed nothing, and the Graph View showed a single dot. A published test note would have added a second entry and a second dot.

Check 3: the direct URL

localhost:8081/test-private returned:

404
Either this page is private or doesn't exist.

An attacker might guess page names, so hiding a page from menus isn’t enough; it must not exist. Note the wording: Quartz deliberately doesn’t say which. That’s anti-enumeration, the same reason GitHub answers “Repository not found” for private repos instead of “access denied.”

Check 4: grep the built site, with a positive control

A blocked note can still leak through a search index, RSS feed or sitemap. So I searched every file in the finished website:

$ grep -r "SECRET TEST" public/
$

No output. But silence has two possible causes: the text is absent, or the search is broken (a typo, a wrong folder). So I ran the same search where the text definitely exists:

$ grep -r "SECRET TEST" content/
content/test-private.md:SECRET TEST: this note has no publish flag and must NOT appear on the site.

This is a positive control: proving a test can detect the thing before trusting its “nothing found.” Same command, same words: found in the source, absent from the website. That turns “I didn’t find a problem” into “I proved there isn’t one.”

Results

CheckMethodResult
Build reportFiltered out 1 filesPass
HomepageTitle, content, footerPass
Explorer and graphNo test note, one dotPass
Direct URL/test-private → 404Pass
Built sitegrep public/ → nothingPass
Positive controlgrep content/ → foundPass

Cleanup

Blocked from the website is not blocked from GitHub. Had I committed now, the test note would have gone into my public repo. So it went:

$ rm content/test-private.md

That’s the reason for the first privacy layer: private notes never enter this folder at all.

Step 5: Auditing my commit identity

Before the first commit, I checked which email Git would stamp on it, and found that address was already public in older GitHub commits. Every commit permanently records the author’s name and email, and in a public repo anyone can read them.

$ git config user.email
<my domain email address>

In this section I’ve replaced my real addresses with placeholders. Publishing them here would undo the work it describes.

Where could the address already be public?

I checked three places, in the order a scraper would find them.

1. Search engines. An exact-match web search for the address in quotes returned nothing. Useful, but not proof.

2. My blog’s code. Bots read HTML, not the rendered page. The “Get in Touch” button pointed to my contact page, not a mailto: link (hovering a link shows its destination in the browser’s bottom-left corner). Then I scanned the raw HTML of the homepage and contact page:

$ curl -sL https://ny2honolulu.com | grep -io "[a-z0-9._%+-]*@yourdomain\.com"
$ curl -sL https://ny2honolulu.com/contact/ | grep -io "[a-z0-9._%+-]*@yourdomain\.com"
$
PartMeaning
curl -sLDownload a page’s raw HTML; -s silent, -L follow redirects
|Pass that HTML to the next command
grep -ioSearch ignoring case (-i), printing only the match (-o)
The patternAny email address ending in the domain

No output. Then the positive control again: silence only counts if the page actually loaded.

$ curl -sL -o /dev/null -w "%{http_code}\n" https://ny2honolulu.com/contact/
200

-o /dev/null discards the page; -w "%{http_code}" prints just the HTTP status. 200 means it loaded, so the empty result is real. A 404 would mean a wrong URL; a 403 that a security plugin blocked curl. My WPForms contact form keeps the address hidden.

3. My existing GitHub commits. This is where it was. One of my older public repos showed my commits with a gray placeholder avatar, and I wasn’t listed under its Contributors, even though I wrote them. GitHub links commits to an account by matching the commit email to a verified address on that account. A gray avatar meant the email wasn’t one GitHub knew was mine.

To read a commit’s raw author line, add .patch to any commit URL:

https://github.com/<user>/<repo>/commit/<short-id>.patch
From: Dennis Gonzalez <my domain email address>
Date: Mon, 24 Aug 2026 07:11:11 -1000
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Three findings in four lines:

  1. The address was already public, since August, in a public repo.
  2. -1000 is my time zone, Hawaii. Every commit records it. I’m openly in Honolulu, so nothing new leaked, but it’s a clean example of metadata leakage: information the tool recorded that I never typed.
  3. Anthropic uses a noreply address for the Claude co-author line, for exactly this reason.

Old commits can’t be cleaned without rewriting history, which isn’t worth it here. The goal became: stop adding new copies.

The fix, in GitHub settings

At Settings → Emails (github.com/settings/emails):

  1. Added and verified my domain address. This linked my old commits to my account (photo and contributor credit) and gave the account a backup email, clearing a “single verified email” warning. Adding it there doesn’t make it public; that page is private to me.
  2. Turned on “Keep my email addresses private.” GitHub removed my email from my public profile and showed my private address, in the form <ID>+<username>@users.noreply.github.com.
  3. Turned on “Block command line pushes that expose my email.” GitHub now refuses a push carrying a private address, with an error like GH007: Your push would publish a private email address.

Two fine-print details mattered:

  • “Previously authored commits associated with a public email will remain public.” The setting protects the future only.
  • The block checks only the most recent commit in a push. Older commits in the same push could slip through. The block is a backup layer, not the main defense.

One gotcha: GitHub’s verification link landed me on the Dashboard, and my Emails tab still said “Unverified” until I refreshed. Browser tabs don’t update themselves.

The fix, on my computer

The real defense is making Git stamp the noreply address in the first place:

$ git config --global user.email "<ID>+<username>@users.noreply.github.com"
$ git config user.email
<ID>+<username>@users.noreply.github.com

--global writes the setting to ~/.gitconfig, so it applies to every repo on this machine. That matters: with the push block on, every repo still using the old address would have its pushes rejected. Running the check from inside notes also confirmed the repo had no local override. My laptop has its own ~/.gitconfig and needs the same command before I push from it.

Step 6: Review, commit, push

The first real commit went out with exactly five reviewed files and a verified private email.

Review what changed

$ git status
On branch v5
Changes not staged for commit:
        deleted:    content/.gitkeep
        modified:   package-lock.json
        modified:   package.json
Untracked files:
        content/index.md
        quartz.config.yaml
FileWhat happenedWhy
content/.gitkeepDeletedGit can’t store an empty folder, so templates add a placeholder. A real note replaced it
package-lock.jsonModifiedPhase 1’s npm audit fix plus the auto-installed theme
package.jsonModifiedThe theme package Quartz added mid-build
content/index.mdNewMy homepage
quartz.config.yamlNewMy settings

Just as important was what didn’t appear: test-private.md (deleted), and public/ and node_modules/, which the template’s .gitignore excludes because they’re rebuilt from scratch.

Quartz had changed package.json on its own, so I read that change before publishing it:

$ git diff package.json
-    "@quartz-themes/core": "^2.0.0",
...
+    "@quartz-themes/core": "^2.0.0",
+    "@quartz-themes/default": "^1.0.1",

@quartz-themes/core appears as removed and re-added with the identical version: npm re-sorted the list alphabetically. The only real addition is @quartz-themes/default, the theme from Phase 1’s build. The ^ allows minor updates but not a jump to version 2. No unfamiliar packages: this is how to review a dependency change before trusting it.

Stage only what was reviewed

$ git add content quartz.config.yaml package.json package-lock.json
$ git status
Changes to be committed:
        deleted:    content/.gitkeep
        new file:   content/index.md
        modified:   package-lock.json
        modified:   package.json
        new file:   quartz.config.yaml

Naming files instead of git add . is opt-in for Git: in a public repo, only what I reviewed gets in. No “Untracked” or “not staged” sections remained.

Commit, then check the stamp

$ git commit -m "Phase 2: homepage, privacy config, dependency security fixes"
[v5 6f0bb84] Phase 2: homepage, privacy config, dependency security fixes
 5 files changed, 445 insertions(+), 122 deletions(-)
 delete mode 100644 content/.gitkeep
 create mode 100644 content/index.md
 create mode 100644 quartz.config.yaml

$ git log -1 --format="%an <%ae>"
Dennis Gonzalez <ID+username@users.noreply.github.com>

100644 means a normal file, readable by all and writable by the owner, like chmod 644. The git log format prints author name (%an) and author email (%ae) of the latest commit. This is the last checkpoint: a commit is easy to fix while it’s local, and its email is permanent once pushed.

Push

$ git push
Writing objects: 100% (7/7), 5.68 KiB | 2.84 MiB/s, done.
To https://github.com/ny2honolulu/notes.git
   a50fb44..6f0bb84  v5 -> v5

Only 5.7 KB went up: Git sends changes, not whole files. No GH007 error, so the push block accepted the email. On GitHub the repo showed 2 Commits, my photo next to the commit, me listed under Contributors, and a green check: the template’s automated checks ran on the push and passed.

How did it log in without asking?

The push needed no password, so something had a saved GitHub token. Where it’s saved matters: a plain-text token is a classic theft target.

$ git config --global credential.helper
$

Empty, because the helper was set under a website-specific name. This finds every variant and the file each lives in:

$ git config --show-origin --get-regexp credential
file:~/.gitconfig    credential.https://github.com.helper
file:~/.gitconfig    credential.https://github.com.helper !/usr/bin/gh auth git-credential
file:~/.gitconfig    credential.https://gist.github.com.helper
file:~/.gitconfig    credential.https://gist.github.com.helper !/usr/bin/gh auth git-credential

The GitHub CLI (gh) handles my logins. The empty lines are deliberate: gh clears any other helper so only it answers for GitHub. Then, where does gh keep the token?

$ gh auth status
github.com
  ✓ Logged in to github.com account ny2honolulu (keyring)
  - Active account: true
  - Git operations protocol: https
  - Token: gho_************************************
  - Token scopes: 'gist', 'read:org', 'repo', 'workflow'

(keyring) means encrypted system storage, not a plain-text file. gho_ marks a GitHub OAuth token.

ScopeAllowsNeeded?
repoFull access to all my repos, including private onesYes, for pushing; broad
workflowEditing GitHub Actions filesYes, for Phase 3’s deploy workflow
gistCreating and editing gistsRarely
read:orgReading organization membershipHarmless, read-only

These are gh‘s standard defaults. The dangerous scopes, delete_repo and admin:org, are absent. If I ever suspect a compromise: GitHub → Settings → Applications → Authorized OAuth Apps → GitHub CLI → Revoke. A tighter option exists, fine-grained personal access tokens scoped to single repos, but that’s more than I need now.

Lessons learned and what’s next

The theme of Phase 2: never trust a control you haven’t tested, and never trust a test you haven’t proven can fail.

  1. Read the whole config, not just the line you came for. I came to flip one switch and found two other privacy issues: a fake lock and a tracking script.
  2. A control that looks like protection is worse than none. Encrypted pages in a public repo would have invited me to trust a lock anyone can open.
  3. Prove a filter blocks, don’t assume it. A test note designed to fail, checked four ways.
  4. Use a positive control. Silence from grep or curl means nothing until you’ve shown the same command finds what exists.
  5. Verify from disk, not from the screen. The editor shows intent; the build reads the file.
  6. When tools disagree, trust the one that runs the code. Then confirm with a build.
  7. Metadata leaks. Emails and time zones ride along in every commit, whether you type them or not.
  8. Read the fine print on safety nets. GitHub’s push block checks only the latest commit.
  9. Know where your credentials live. A token found, its storage confirmed, its permissions read.

Connections to what I’m studying

AWS DVA-C02:

  • Least privilege. Token scopes work like IAM policies: repo resembles a broad managed policy, and fine-grained tokens resemble a scoped custom policy.
  • Secrets storage. Keyring storage versus a plain-text file mirrors Secrets Manager versus hard-coded credentials.
  • Defense in depth. Three privacy layers (keep private notes out of the folder, ignorePatterns, ExplicitPublish) mirror layered IAM, security groups and bucket policies.
  • Verify before deploy. Checking the commit email before git push is the same habit as reviewing a CloudFormation change set.

Black Hat Python:

  • Web recon. curl | grep with an email pattern is a miniature version of the book’s scrapers: fetch, then extract with a pattern.
  • Enumeration. Quartz’s “private or doesn’t exist” 404 and GitHub’s “not found” for private repos are defenses against the guessing the book’s tools automate.
  • Credential theft. A plain-text token is exactly what the book’s trojans are built to grab, which is why its storage location mattered.

Phase 3 checklist: deploy

  • Choose how GitHub Pages deploys, with the workflow triggered by the v5 branch (not main)
  • Point a DNS CNAME record for notes.ny2honolulu.com at GitHub Pages
  • Turn on HTTPS for the custom domain
  • Protect the v5 branch from force-pushes and deletion
  • Review the three Dependabot pull requests one at a time
  • Replace the repo’s Quartz README with my own
  • Set the noreply email on my laptop’s Git config

Command reference

Every command from Phase 2, in the order used, run from the project folder unless noted.

CommandWhat it does
cd ~/projects/notesMove into the project folder
cat quartz.config.yamlPrint the whole config file
zed quartz.config.yamlOpen the config in the Zed editor
grep -n -A1 -E "a|b" fileSearch a file for several words, with line numbers and one line after each match
zed content/index.mdOpen the homepage note
echo "text" > content/test-private.mdCreate a test note containing that text
ls content/List the notes folder
npx quartz build --serve --port 8081Build the site and preview it at localhost:8081 (stop with Ctrl + C)
grep -r "SECRET TEST" public/Search every file of the built website for the test text
grep -r "SECRET TEST" content/Positive control: the same search where the text exists
rm content/test-private.mdDelete the test note
git statusList changed, staged and untracked files
git diff package.jsonShow line-by-line changes to one file
git config user.emailShow the email the next commit will carry
curl -sL <url> | grep -io "<pattern>"Download a page’s raw HTML and extract matching text
curl -sL -o /dev/null -w "%{http_code}\n" <url>Print only a page’s HTTP status code
git config --global user.email "<noreply>"Set the commit email for every repo on this machine
git add <files>Stage only the named files
git commit -m "<message>"Save staged files as a local snapshot
git log -1 --format="%an <%ae>"Show the latest commit’s author name and email
git pushUpload local commits to GitHub
git config --global credential.helperShow the global credential helper (misses site-specific ones)
git config --show-origin --get-regexp credentialShow every credential setting and the file it comes from
gh auth statusShow GitHub CLI login, token storage and scopes

Zed shortcuts used

ShortcutAction
Ctrl + SSave
Ctrl + FSearch in the current file
Ctrl + Shift + KDelete the current line
Ctrl + ASelect all
Ctrl + ZUndo

Browser checks used

URLPurpose
http://localhost:8081Preview the homepage
http://localhost:8081/test-privateConfirm a blocked note returns 404
https://github.com/settings/emailsEmail privacy and push-block settings
https://github.com/<user>/<repo>/commit/<id>.patchRead a commit’s raw author line

Next in the series: Part 3: Deploy, Domain, and Proof on the Live Site.