Found a real bug while verifying the nav search: /pagefind/pagefind.js is an ES
module that defines no window.Pagefind, but Search.astro loaded it with a classic
<script src> and read window.Pagefind — so every query answered "Search is
unavailable." (confirmed live before the fix). Now a dynamic import; the specifier
must be a variable or Rollup resolves it at build time and the build fails.
Measured before/after in a real browser:
- archive /posts/ + /zh/posts/: 18,220px -> 3,788px (71 rows at 44px, grouped by
year) with a client-side filter (title/tags/description text, category chips,
14 tag chips, zero requests). Tested: "nginx" -> 6 rows, chip devops -> 28.
- tag pages for tags with >= 3 posts. Measured 235 distinct tags (an earlier brief
claimed 74), 156 used exactly once — pages only where a topic exists: 40 tags ->
80 pages, build 165 -> 245 pages. Pills link only when a page exists.
- homepage + ZH homepage: hero (288px) with the real scale claim, three CTAs, a
fixed Featured 4 (scale -> automation -> observability -> a delivered result),
Latest 10 -> 6. ZH hero had linked "关于我" to the English /about/.
- long code blocks collapse above 420px with an Expand button + gradient, copy
button still copies the whole block (verified on the 1010px block).
- search is visible ("⌕ Search /" pill, "/" plus the existing Ctrl/Cmd+K) and the
zero-result screen offers topic chips instead of a dead end.
Docs: project-state B3 entry, ops-runbook Pagefind gotcha.
6.4 KiB
Ops & Deploy Runbook — blog.hoelee.com
How this blog is built, deployed, and kept alive. Read when debugging CI/CD, deployment, or hosting.
Pipeline
Markdown (src/content/posts) → Astro build (dist/) → Gitea Actions → act_runner on unRaid
→ nginx container → Cloudflare tunnel → Cloudflare CDN → blog.hoelee.com
- Repo:
git.hoelee.com/hoelee/hoelee-blog(push here first, then GitHub — standing convention). - Runner:
act_runneron unRaid (verified working: Gitea 1.27.3 + runner 3.3.2, PAT-secret pipeline).
Write & publish a post
- Create
src/content/posts/<slug>.mdwith frontmatter (seecontent-guide.md§6). - Push to
main. - CI builds
dist/and deploys to the nginx container automatically.- Before pushing a post that adds art, generate its in-page derivatives:
uv run --with pillow python scripts/optimize-images.py <slug>(banner.webp+/og/thumb/<name>-{400,800}.webp). The PNGs frombanner-gen/og-genremain theog:imagesource of truth — seedesign-guide.md§3 for the format policy and measured sizes.
- Before pushing a post that adds art, generate its in-page derivatives:
- Verify live:
curl -I https://blog.hoelee.com/<slug>→ expect 200.
Build pitfalls (from prior Astro work — check these first)
-
Pagefind is an ES module and exposes no window global.
dist/pagefind/pagefind.jsends withexport { search, debouncedSearch, … }. Load it with a dynamicimport(), never a classic<script src>+window.Pagefind— that combination silently made every search answer "Search is unavailable." for as long as the overlay existed. The specifier must be a variable (const url = '/pagefind/pagefind.js'; await import(/* @vite-ignore */ url)) because Rollup resolves a literal dynamic-import path at build time and fails on a file that does not exist until after the search index is generated. -
No JSX helper components in frontmatter — inline JSX directly in
.map()calls, or the build fails withExpected ">" but found "class". -
Import depth —
src/pages/*.astrouse../layouts/…; subdirs use../../. Wrong level =Could not resolve. -
Footer must render AFTER
<main>— a shared header+footer component renders footer above content otherwise. -
npm installscripts blocked can leave esbuild's binary missing → checknpm warn install-scriptsat install time.
Verify before shipping a layout change
Headless-Chrome screenshot + vision check on both 390px and 1280px:
CHROME="/c/Program Files/Google/Chrome/Application/chrome.exe"
"$CHROME" --headless --disable-gpu --window-size=1280,2400 \
--screenshot="$(cygpath -w $LOCALAPPDATA/Temp/blog.png)" \
--virtual-time-budget=4000 "http://localhost:4321/"
Overflow diagnosis (real numbers, not screenshot guessing): check scrollWidth > innerWidth via CDP Runtime.evaluate.
nginx layer (unRaid container, Portainer stack 247)
The container nginx-hoelee-blog (nginx:1.29-alpine, host port 8081) had a stock
default.conf until 2026-10-16, when the config became a bind mount:
/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf→/etc/nginx/conf.d/default.conf:ro- Edits take effect after
docker exec nginx-hoelee-blog nginx -t && docker exec nginx-hoelee-blog nginx -s reload(no restart needed). Validate a candidate file first in a throwaway container:docker run --rm -v /mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro nginx:1.29-alpine nginx -t - Stack: Portainer DSM instance (endpoint 3 = unRaid), stack id 247. Read/update via
GET|PUT /api/stacks/247?endpointId=3; PUT recreates the service (a few seconds of downtime).
What the conf adds, and why:
| Rule | Why |
|---|---|
location = /posts/zh/ { return 301 /zh/posts/; } |
Chinese posts live at /posts/zh/<slug>/, so the bare directory had no index and answered 403. The Chinese archive is /zh/posts/. |
error_page 404 /404.html; + internal location |
Astro builds dist/404.html. Without it, a broken link showed nginx's default page. Served with a 404 status so crawlers do not index it. |
location /_astro/ → max-age=31536000, immutable |
Astro hashes these filenames, so they can never go stale. /og/*.png and /banners/*.png are not hashed and are regenerated in place, so they deliberately keep Cloudflare's default TTL. |
HTML → max-age=0, must-revalidate |
A deploy must be visible immediately; edge caching is controlled separately by the Cloudflare Cache Rule + the deploy purge step. |
Header note: the public Strict-Transport-Security and the default 16-day asset
Cache-Control come from Cloudflare zone settings, not from this nginx (the origin
sends no such headers). See docs/project-state.md §1 B2 for the open CF items.
Hosting notes
- Static output = trivial to serve from any nginx/OpenLiteSpeed dir. Cloudflare in front gives CDN + HTTPS + DDoS.
- Cloudflare Pages git integration supports GitHub/GitLab only — not Gitea. Current setup (Gitea → self-hosted runner → nginx) sidesteps this entirely and is the reason the pipeline is the way it is.
- Full self-hosting also avoids Cloudflare Pages' 25MB/file, 20k-files limits — a decision already baked into this architecture.
Health checks
curl -I https://blog.hoelee.com/→ 200, correctcontent-type.curl https://blog.hoelee.com/sitemap.xml→ lists all published posts.curl https://blog.hoelee.com/rss.xml→ non-empty.- DNS:
blog.hoelee.comresolves through Cloudflare (proxy enabled).
Pitfall: premature poll caches a 16-day 404
The origin serves Cache-Control: max-age=1382400 (16 days), and Cloudflare caches
negative (404) responses too. If you curl a freshly-deployed URL before the CI deploy
finishes, Cloudflare caches that 404 for 16 days — the page itself returns 200 but its
og/banner PNGs 404 with cf-cache-status: HIT.
- Detect: the bare URL 404s but
?v=<timestamp>(cache-buster) returns 200 → origin is fine, edge cache is stale. - Fix: purge the Cloudflare zone cache. Zone-level token (
Zone.Cache Purgeperm, lives in the cloudflare-pages-deploy skill) →POST /zones/8c8b2359766ac853602b91dd851c630e/purge_cachewith{"purge_everything":true}. See that skill for the exact curl. - Prevent: after
git push,sleep~60–90s before the first bare-URL check, or validate with?cb=$(date +%s)first so you never register a 404 into edge cache.