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:
- The config was reviewed line by line. Six settings changed, including two privacy problems I hadn’t expected.
- Opt-in publishing is on. A note appears on the site only if it carries
publish: true. - The filter was proven, not assumed. A deliberately unmarked test note was checked four different ways.
- My commit identity was audited. I discovered my email address was already public in older GitHub commits, and switched to a private address.
- The first real commit was pushed, with its email verified before it left my machine.
- 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
| # | Setting | Before | After | Why |
|---|---|---|---|---|
| A | pageTitle | Quartz 5 | NY2Honolulu Notes | My site’s name, shown top left and in browser tabs |
| B | analytics | provider: plausible | null | A third-party tracking script on every page, with no account behind it |
| C | explicit-publish plugin | enabled: false | enabled: true | The core control: only notes marked publish: true get published |
| D | encrypted-pages plugin | enabled: true | enabled: false | A false sense of security in a public repo (see below) |
| E | obsidian-plugin-excalidraw | enabled: true | enabled: false | It failed to load in Phase 1, and I don’t publish drawings |
| F | Footer links | Quartz’s GitHub and Discord | My notes repo and my blog | Visitors 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
| Setting | Value | Why it stays |
|---|---|---|
ignorePatterns | private, templates, .obsidian | Any folder with those names is skipped: a third safety layer |
remove-draft plugin | enabled | Notes marked draft: true are hidden |
cname plugin | enabled | Generates the custom-domain file needed in Phase 3 |
comments plugin | disabled | No third-party comment system |
fontOrigin | googleFonts | Visitors’ browsers fetch fonts from Google. Common and acceptable; worth knowing which outside services a site contacts |
Encrypted-pages options: block | kept | Ignored 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
| Habit | Why |
|---|---|
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 bar | It dropped 4 → 1 as each fix landed; the last 1 was the known schema warning |
| Watch the dot on the file tab | A dot means unsaved changes; Ctrl + S clears it |
Ctrl + Z | Undoes 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
| Flag | Meaning |
|---|---|
-n | Show line numbers |
-A1 | Show one line after each match, so the enabled: line appears |
-E | Extended 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.
| Field | Effect |
|---|---|
title: | The page heading and the browser-tab title |
publish: true | The 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
| Line | Phase 1 | Phase 2 | Meaning |
|---|---|---|---|
| Input files found | 1 | 2 | Both notes were seen |
| Files filtered out | 0 | 1 | The test note was blocked |
| Files emitted | 48 | 46 | Excalidraw no longer generates its files |
| Excalidraw warning | present | gone | Edit E worked |
| Theme install | ran | skipped | Installed 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
| Check | Method | Result |
|---|---|---|
| Build report | Filtered out 1 files | Pass |
| Homepage | Title, content, footer | Pass |
| Explorer and graph | No test note, one dot | Pass |
| Direct URL | /test-private → 404 | Pass |
| Built site | grep public/ → nothing | Pass |
| Positive control | grep content/ → found | Pass |
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"
$
| Part | Meaning |
|---|---|
curl -sL | Download a page’s raw HTML; -s silent, -L follow redirects |
| | Pass that HTML to the next command |
grep -io | Search ignoring case (-i), printing only the match (-o) |
| The pattern | Any 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:
- The address was already public, since August, in a public repo.
-1000is 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.- 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):
- 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.
- 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. - 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
| File | What happened | Why |
|---|---|---|
content/.gitkeep | Deleted | Git can’t store an empty folder, so templates add a placeholder. A real note replaced it |
package-lock.json | Modified | Phase 1’s npm audit fix plus the auto-installed theme |
package.json | Modified | The theme package Quartz added mid-build |
content/index.md | New | My homepage |
quartz.config.yaml | New | My 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.
| Scope | Allows | Needed? |
|---|---|---|
repo | Full access to all my repos, including private ones | Yes, for pushing; broad |
workflow | Editing GitHub Actions files | Yes, for Phase 3’s deploy workflow |
gist | Creating and editing gists | Rarely |
read:org | Reading organization membership | Harmless, 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.
- 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.
- 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.
- Prove a filter blocks, don’t assume it. A test note designed to fail, checked four ways.
- Use a positive control. Silence from grep or curl means nothing until you’ve shown the same command finds what exists.
- Verify from disk, not from the screen. The editor shows intent; the build reads the file.
- When tools disagree, trust the one that runs the code. Then confirm with a build.
- Metadata leaks. Emails and time zones ride along in every commit, whether you type them or not.
- Read the fine print on safety nets. GitHub’s push block checks only the latest commit.
- 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:
reporesembles 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 pushis the same habit as reviewing a CloudFormation change set.
Black Hat Python:
- Web recon.
curl | grepwith 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
v5branch (notmain) - Point a DNS CNAME record for
notes.ny2honolulu.comat GitHub Pages - Turn on HTTPS for the custom domain
- Protect the
v5branch 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.
| Command | What it does |
|---|---|
cd ~/projects/notes | Move into the project folder |
cat quartz.config.yaml | Print the whole config file |
zed quartz.config.yaml | Open the config in the Zed editor |
grep -n -A1 -E "a|b" file | Search a file for several words, with line numbers and one line after each match |
zed content/index.md | Open the homepage note |
echo "text" > content/test-private.md | Create a test note containing that text |
ls content/ | List the notes folder |
npx quartz build --serve --port 8081 | Build 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.md | Delete the test note |
git status | List changed, staged and untracked files |
git diff package.json | Show line-by-line changes to one file |
git config user.email | Show 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 push | Upload local commits to GitHub |
git config --global credential.helper | Show the global credential helper (misses site-specific ones) |
git config --show-origin --get-regexp credential | Show every credential setting and the file it comes from |
gh auth status | Show GitHub CLI login, token storage and scopes |
Zed shortcuts used
| Shortcut | Action |
|---|---|
Ctrl + S | Save |
Ctrl + F | Search in the current file |
Ctrl + Shift + K | Delete the current line |
Ctrl + A | Select all |
Ctrl + Z | Undo |
Browser checks used
| URL | Purpose |
|---|---|
http://localhost:8081 | Preview the homepage |
http://localhost:8081/test-private | Confirm a blocked note returns 404 |
https://github.com/settings/emails | Email privacy and push-block settings |
https://github.com/<user>/<repo>/commit/<id>.patch | Read a commit’s raw author line |
Next in the series: Part 3: Deploy, Domain, and Proof on the Live Site.