Files
hoelee-blog/docs/ops-runbook.md
T
hoelee 874c434576
Deploy / build (push) Successful in 3m59s
fix(i18n,404,nginx): broken-windows batch B2 from the external review
All items measured before/after with curl, on the origin and through Cloudflare:

- /posts/zh/ answered 403 (directory with no index) -> nginx 301 to the new archive
- /zh/posts/ 404'd -> new src/pages/zh/posts/index.astro (build 163 -> 165 pages)
- ZH nav pointed "关于" at /zh/ and "文章" at /zh/ -> /zh/about/ and /zh/posts/
- og:locale was "en" on every Chinese page -> zh_CN, plus og:locale:alternate on
  both languages (EN pages now advertise zh_CN as well)
- hreflang "zh" -> "zh-Hans"; <html lang> on ZH pages -> zh-Hans
- no 404 page existed -> src/pages/404.astro, served by nginx with a 404 status so
  crawlers skip it, offering search + archive + the 3 newest posts
- JSON-LD Person gained worksFor (Hoelee Enterprise); image still omitted until the
  author photo exists rather than pointing at a placeholder

nginx: config moved from the stock image file to a host bind mount
(/mnt/user/docker/nginx-hoelee-blog/conf.d/default.conf, Portainer stack 247 on
endpoint 3), validated with nginx -t in a throwaway container before the redeploy.
It also sets /_astro/* to immutable 1-year (hashed names) and HTML to
must-revalidate; unhashed /og/*.png and /banners/*.png keep the default TTL on
purpose. Note: the public HSTS and 16-day asset TTL come from Cloudflare zone
settings, not from this nginx.

Docs: ops-runbook.md (the nginx layer + why each rule), project-state.md (B2 entry
and the two open Cloudflare items my token cannot write).
2026-10-16 20:50:26 +08:00

5.8 KiB
Raw Blame History

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_runner on unRaid (verified working: Gitea 1.27.3 + runner 3.3.2, PAT-secret pipeline).

Write & publish a post

  1. Create src/content/posts/<slug>.md with frontmatter (see content-guide.md §6).
  2. Push to main.
  3. 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 from banner-gen / og-gen remain the og:image source of truth — see design-guide.md §3 for the format policy and measured sizes.
  4. Verify live: curl -I https://blog.hoelee.com/<slug> → expect 200.

Build pitfalls (from prior Astro work — check these first)

  • No JSX helper components in frontmatter — inline JSX directly in .map() calls, or the build fails with Expected ">" but found "class".
  • Import depth — src/pages/*.astro use ../layouts/…; subdirs use ../../. Wrong level = Could not resolve.
  • Footer must render AFTER <main> — a shared header+footer component renders footer above content otherwise.
  • npm install scripts blocked can leave esbuild's binary missing → check npm warn install-scripts at 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, correct content-type.
  • curl https://blog.hoelee.com/sitemap.xml → lists all published posts.
  • curl https://blog.hoelee.com/rss.xml → non-empty.
  • DNS: blog.hoelee.com resolves 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 Purge perm, lives in the cloudflare-pages-deploy skill) → POST /zones/8c8b2359766ac853602b91dd851c630e/purge_cache with {"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.