Part 3: The Build System
Part 3 of the digital garden series.
Why a Makefile
A Makefile is documentation that also happens to run. The Makefile in this project is the canonical description of how content flows from authoring through processing to deployment. It opens with a state flow diagram:
┌─────────────────────────────────────────────────────────────────┐
│ AUTHORING │
│ │
│ ../vault/inbox/ ../vault/drafts/ │
│ ├── note.md ├── my-post.md │
│ └── url.txt ├── DSC_12345.HEIC │
│ └── IMG_6789.MOV │
└─────────────────────────────┬───────────────────────────────────┘
│
│ /capture or manual processing
▼
┌─────────────────────────────────────────────────────────────────┐
│ PROCESSING (local) │
│ │
│ 1. Editorial review │
│ 2. Route to content section │
│ 3. For each asset: │
│ │
│ make archive SRC=.../DSC_12345.HEIC OUT=name \ │
│ DEST=content/links │
│ → content/links/name.heic (EXIF intact, Git LFS) │
│ │
│ make webp SRC=content/links/name.heic OUT=name │
│ → media/name.webp (stripped, 3840px, q82) │
│ │
│ make archive SRC=.../IMG_6789.MOV OUT=name \ │
│ DEST=content/links │
│ → content/links/name.mov (original, Git LFS) │
│ │
│ make mp4 SRC=content/links/name.mov OUT=name │
│ → media/name.mp4 (H.264, AAC, stripped, faststart) │
│ │
│ 4. Markdown:  or <video src="/media/..."> │
│ 5. Delete source from ../vault/ │
│ 6. make validate │
└─────────────────────────────┬───────────────────────────────────┘
│
│ git commit + push
▼
┌─────────────────────────────────────────────────────────────────┐
│ CI PIPELINE │
│ │
│ GIT_LFS_SKIP_SMUDGE=1 │
│ │
│ make build make deploy-site make deploy-media │
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ quartz build │ │ wrangler │ │ lfs pull │ │
│ │ -d content/ │ pub/ │ deploy │ │ --include= │ │
│ │ -o public/ │─────▶│ worker/ │ │ "media/**" │ │
│ │ │ │ │ │ │ │
│ │ ignorePatterns │ │ HTML,CSS,JS, │ │ r2 put each │ │
│ │ skips all │ │ favicon from │ │ media/*.webp │ │
│ │ media exts │ │ static assets│ │ media/*.mp4 │ │
│ │ │ └──────────────┘ │ media/*.m4a │ │
│ │ LFS pointers │ └──────────────┘ │
│ │ in content/ │ │
│ │ are tiny, never│ originals in content/ are │
│ │ resolved │ LFS pointers, never fetched │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ CLOUDFLARE │
│ │
│ Worker (secondbrain) │
│ ├── Static Assets ◄── Quartz output │
│ │ (HTML, CSS, JS, favicon, fonts) │
│ │ │
│ └── R2 Bucket ◄── media/*.{webp,mp4,m4a} │
│ secondbrain.dunn.dev/media/* │
└─────────────────────────────────────────────────────────────────┘
This lives in the Makefile comments. When the pipeline changes, the diagram and the targets change in the same commit.
Original preservation
A photo carries EXIF metadata (GPS, camera model, exposure, timestamps) that’s irreplaceable once stripped. The media pipeline preserves originals at full fidelity and produces stripped, resized copies for the web.
The archive target copies the original to a content directory with a normalized filename:
make archive SRC=path/to/DSC_12345.HEIC OUT=18-07-hoover-dam DEST=content/links
# → content/links/18-07-hoover-dam.heic (original, EXIF intact)
The original lives next to the markdown that references it, tracked by Git LFS so it doesn’t bloat the repository but stays visible in Obsidian alongside the content.
The webp target then produces a web-optimized copy:
make webp SRC=content/links/18-07-hoover-dam.heic OUT=18-07-hoover-dam
# → media/18-07-hoover-dam.webp (stripped, 3840px max, quality 82)
ImageMagick strips metadata, auto-orients, resizes to 3840px max, and encodes as WebP at quality 82. Typically 90%+ smaller than the original.
Video and audio follow the same pattern. make mp4 produces H.264/AAC with metadata stripped and faststart enabled for progressive playback. make m4a produces AAC at 128kbps. Originals are archived, web copies go to media/, Git LFS tracks both.
make mp4 SRC=content/links/clip.mov OUT=18-07-clip
# → media/18-07-clip.mp4 (H.264, AAC, stripped, faststart)
make m4a SRC=path/to/recording.wav OUT=2024-talk
# → media/2024-talk.m4a (AAC 128k, stripped, faststart)
Two copies of every media asset: one archival (full fidelity, EXIF intact, Git LFS) and one web (stripped, optimized, deployed to R2).
CI on GitLab
The GitLab CI pipeline:
image: node:22
stages:
- build
- deploy
variables:
GIT_LFS_SKIP_SMUDGE: "1"
GIT_LFS_SKIP_SMUDGE: "1" keeps CI from pulling every LFS object before doing anything. The build job never needs the actual media because Quartz’s ignorePatterns skips all media file extensions.
Build
build:
stage: build
script:
- make build
Clones Quartz v4, copies in two config files, builds static HTML from content/ into public/. The Quartz config’s ignorePatterns skips .obsidian/ and all media file extensions. The vault lives outside the repo as a sibling directory (../vault/), so Quartz never sees it.
Deploy site
deploy-site:
stage: deploy
needs:
- build
script:
- npm install -g wrangler
- make deploy-site
Deploys the public/ artifact via Wrangler as Workers Static Assets, along with the Worker code in worker/.
Deploy media
deploy-media:
stage: deploy
rules:
- if: '$CI_COMMIT_REF_NAME == "main"'
changes:
- media/**
needs: []
script:
- apt-get update -qq && apt-get install -y -qq git-lfs
- git lfs install --force
- npm install -g wrangler
- make deploy-media
Only runs when files in media/ change. Pulls just the media via Git LFS and uploads each to R2. Because of needs: [], it runs in parallel with deploy-site.
deploy-site waits on build because it ships what build produced. deploy-media reads nothing from the build, so needs: [] lets a media-only push deploy alongside it instead of queueing behind it.
cd $(MEDIA_DIR) && \
find . -maxdepth 1 -type f \( -name '*.webp' -o -name '*.mp4' -o -name '*.m4a' \) | while read file; do
key="${file#./}"
mime=$(file --mime-type -b "${file}")
echo "uploading ${key} (${mime})"
npx wrangler r2 object put "secondbrain/${key}" --file="${file}" --content-type="${mime}" --remote
done
Local vs CI
The same Makefile serves both environments. make help shows everything available:
developer targets:
check verify local dependencies
deps install missing dependencies via brew
archive copy + rename original next to markdown
webp strip + resize + convert to webp
mp4 strip + transcode to H.264/AAC
m4a strip + transcode to AAC
validate pre-publish checks
ci targets:
build quartz build (content/ -> public/)
deploy-site wrangler deploy worker
deploy-media selective LFS pull + R2 upload
The Makefile is visible in both Obsidian and git.
Next: Part 4: AI harness