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

111 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```bash
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.