71 Commits
Author SHA1 Message Date
hoelee 378aff24d0 docs: check off Workbench/MariaDB gotcha post (B2)
Deploy / build (push) Successful in 16s
2026-09-20 04:20:45 +08:00
hoelee a9a91a25d4 Draft bank: 2 CodeIgniter posts (EN+ZH), held unpublished
Deploy / build (push) Successful in 18s
Two finished posts written from the numerology-report migration work, kept as
draft: true so they build no pages and appear in no listing until published.
Content bank for weeks when there is nothing fresh to write.

- migrating-codeigniter-iis-to-openlitespeed (engineering)
  IIS -> OpenLiteSpeed/CyberPanel. The two fatals that only appeared once the
  authentik SSO gate was gone, the docroot public/ separation, and the
  loopback self-call that becomes a real outbound HTTPS request on LiteSpeed.
- upgrading-codeigniter-46-to-47 (notes)
  The two fatal config properties NOT in the official upgrade guide
  (permittedURIChars, jsonEncodeDepth), why Composer never merges app/Config,
  and the property-diff script that finds the whole class of problem at once.

Both fill the starved engineering (1 post) and notes categories. project-state.md
records them as Step B2b with the publish checklist.
2026-09-20 04:19:37 +08:00
hoelee a1488da2d8 Add post: When Your Database Client Lies to You (Workbench 26 / MariaDB)
Deploy / build (push) Successful in 22s
EN + ZH devops gotcha post on debugging MySQL Workbench 26.7.0's failure
to connect to MariaDB. Three patches to Oracle's bundled code, all the
same root cause: `major >= 8` is not a valid MySQL-vs-MariaDB test.

Also adds per-post OG + banner (TERMINALS/BANNERS entries).
2026-09-20 04:18:11 +08:00
hoelee 060b0f1733 docs: record robots.txt encoding rule and sitemap derivation in seo-reference
Deploy / build (push) Successful in 19s
The crawler/sitemap mechanics now live in the repo doc, not just in the
commit log: robots.txt is a build-time endpoint that must stay pure ASCII
(no charset on a text/plain response means non-ASCII renders as mojibake),
the policy is allow-all with enforcement deliberately left to Cloudflare,
why writing a real robots.txt demotes Cloudflare's placeholder from
replacement to prepend, and that lastmod/hreflang are derived so they must
never be hand-authored.
2026-09-19 22:14:40 +08:00
hoelee e18c322538 SEO: sitemap lastmod + hreflang alternates; robots.txt allows all crawlers
Deploy / build (push) Successful in 24s
robots.txt carried a UTF-8 em dash in its header comment, which renders as
mojibake ("鈥�") in clients that read text/plain as a legacy codepage. The
file is now pure ASCII (verified with `LC_ALL=C grep '[^ -~]'`), and the
encoding trap is documented in the endpoint so it does not come back.

Policy change to allow-all: the previous version blocked CCBot, Bytespider,
Amazonbot and Applebot-Extended. Training crawlers are now welcome too - the
blog benefits from being read, and robots.txt is advisory anyway (Cloudflare
documents it as unenforced; AI Crawl Control is the enforcement layer).
Drops the Content-Signal directives along with the blocklist.

Sitemap gains both fields Google actually uses:
- lastmod on all 58 post URLs, from updatedDate ?? pubDate, read straight
  from the markdown frontmatter at config-eval time (the sitemap runs in
  astro:build:done, after the content collection is gone).
- xhtml:link hreflang alternates on all 77 URLs, pairing EN/ZH twins.
  /posts/ is special-cased: it has no /zh/posts/ twin, the Chinese post
  listing IS the /zh/ homepage, so the pair is declared rather than derived.
  Verified programmatically that every emitted alternate resolves to a page
  that is actually built and present in the sitemap.
2026-09-19 22:03:37 +08:00
hoelee 7e0802f913 SEO: real robots.txt + hreflang pairs + BlogPosting schema
Deploy / build (push) Successful in 18s
robots.txt was absent at the origin, so Cloudflare served its Free-plan
Content Signals Policy placeholder — a file with no User-agent, no Sitemap
and no directives. Adds src/pages/robots.txt.ts (origin 200 → CF merges
instead of substituting) pointing at sitemap-index.xml, disallowing
/pagefind/, and expressing search=yes / ai-input=yes / ai-train=no in line
with the two-lane SEO+GEO strategy in docs/seo-reference.md. Citation
crawlers (Google-Extended, OAI-SearchBot, PerplexityBot, ClaudeBot) stay
allowed; only training-only harvesters blocked.

hreflang was never emitted despite full EN/ZH i18n: both language versions
sat in the sitemap with no cross-reference. BaseLayout gains altLocaleUrl
and emits en/zh/x-default per page; every post pair, category pair, and
landing page now cross-links.

Also adds BlogPosting JSON-LD (@id-linked to the site-wide Person node)
so Google has a rich-result-eligible node per article, and creates the
missing /zh/about/ page — the EN about page pointed its language switch at
a 404, and /about/ was the only unpaired page after hreflang landed.
2026-09-19 21:41:22 +08:00
hoelee 86f3a977b9 Fix gpg command mangled by Cloudflare email obfuscation
Deploy / build (push) Successful in 19s
Cloudflare's Email Address Obfuscation rewrites literal email
addresses into [email protected] with a data-cfemail payload, which
broke the gpg --locate-keys line inside the code block - readers
copying the shell command got garbage. Replaces it with a
comment pointing at the .asc link and the published fingerprint.
2026-09-19 21:16:17 +08:00
hoelee a0c22f22ca Add beginner Tor Browser walkthrough to onion service post
Deploy / build (push) Successful in 24s
Readers arriving at the onion addresses had no instructions for
opening a .onion. Adds a step-by-step Tor Browser install (download,
GPG signature verification, connect, bridge fallback) plus how to
open a v3 address: 56-char base32 rules, no typo correction, v2
retirement, and the 10-60 minute descriptor propagation window.

Mirrored to the Chinese version in the same commit.
2026-09-19 21:14:17 +08:00
hoelee ab16b8f935 docs: record RDPGuard/IPBan gotcha post in B2 backlog
Deploy / build (push) Successful in 19s
2026-09-19 19:53:27 +08:00
hoelee daca86dc43 Add post: Replacing RDPGuard With IPBan (EN + ZH, og+banner)
Deploy / build (push) Successful in 22s
Case study of migrating from paid RDPGuard 7.8.7 to open-source IPBan
4.1.0, covering the three undocumented traps: the uninstaller that would
have silently unbanned 12 active attackers, the non-existent
--install-service flag in v4.1.0, and ExpireTime vs BanTime.

Also fixes an og-gen defect: the tag chip hardcoded KIND='DevOps', so
every non-devops post carried a wrong label (e.g. "case-studies · DevOps"
on the STT card). KIND now derives from the post category, and all 29
existing OG images are regenerated with correct badges.
2026-09-19 19:50:27 +08:00
hoelee 8cebe20981 Merge GitHub main: keep both OG/banner entries (TRIM, SATA cable, STT)
Deploy / build (push) Successful in 17s
2026-09-19 07:56:53 +08:00
hoelee 9fa366a0ac Add case study: self-hosted speech-to-text API (EN + ZH)
Deploy / build (push) Successful in 25s
New flagship case study covering a GPU-backed whisper.cpp transcription API
reachable from Windows, iPhone, iPad and Android behind an authenticated
gateway — framed as a service offering with the office-productivity case
(roughly 5x typing throughput, unlimited, audio never leaves the premises).

Content:
- EN + ZH posts (same slug -> auto language switch)
- "Why it matters" opener, hire CTA with clickable WhatsApp + mailto
- Four documented traps: incomplete CUDA component selection, loopback bind
  mistaken for a firewall problem, n8n Code nodes discarding binary + the
  data0 key name, and nginx default.conf hijacking port 80
- Honest scoping of the auth model (access control, not hardened public API)

Assets:
- Custom OG image + 16:9 banner (generator entries appended, not patched
  inside the template-literal maps)

Also marks Mem0 (B1) done and adds B1b to project-state.md.
2026-09-19 07:55:29 +08:00
hoelee 044ded45b3 Add TRIM corruption sequel post + publish SATA cable post (en/zh, og+banner)
Deploy / build (push) Successful in 19s
- New post: 'The Corruption Came Back on Different Drives — the Cause Was
  TRIM, Not the SSDs' — same 0x8941f998 zeros fingerprint on a second drive
  stack (IronWolf 110 SATA pair), root cause queued TRIM (FreeBSD gag 264139),
  fix diskAutotrim=off + nodiscard, scrub evidence, enterprise SATA buying
  guidance. en + zh, custom OG + banner.
- Publish formerly-draft SATA cable post (draft:false) with OG + banner.
- Correct drive identity in both: ZA960NM10001 is Seagate IronWolf 110, not
  'Samsung PM9A3' (PM9A3 is NVMe; FPDMA errors are SATA-only).
- Old RAID post (en+zh): cross-link to sequel, fix 'a Samsung' -> IronWolf 110.
2026-09-18 05:38:13 +08:00
hoelee fb66e17b50 posts: n8n v1→v2 migration, self-healing entitlements, TTS sidecars
Deploy / build (push) Successful in 19s
Three new posts (EN + ZH twins, og + banner each):

- n8n-v1-to-v2-upgrade-gotchas (devops): the seven deprecations that
  surfaced upgrading 1.123.x → 2.40.1, decoded from the boot log —
  telemetry schema rejection, N8N_WEBHOOK_URL rename, internal runner
  deprecation, task timeout 300s→60s, two compression limits, v3
  storage rename, plus the DB override that silently disabled the
  AI sandbox.
- self-healing-digital-goods-entitlements (case-studies): the W1–W5
  NocoDB → n8n → AList entitlement lifecycle. Build-time code sharing
  for n8n Code nodes, MAX-expiry semantics, dry-run safety, daily
  drift repair, CORS-not-HMAC reasoning, and the public→internal
  NocoDB cascading-failure fix (504 → retry storm → 503).
- running-tts-as-a-service-with-token-sidecars (ai): a year-long TTS
  service built on two cron containers that refresh Azure/Google
  tokens into a shared file, with the speed/voice mapping layer.

banner-gen: center terminal body vertically so line counts shorter
than the fixed 690px panel don't leave a dead void at the bottom.
Verified via DOM measurement (gapAbove 104 / gapBelow 106).
2026-09-18 01:51:46 +08:00
hoelee be37348a8c post: add missing Chinese translation of hello-world (restores 22/22 EN-ZH parity)
Deploy / build (push) Successful in 17s
2026-09-18 01:34:57 +08:00
hoelee 937a114e30 post: shipping an AI photo editor as a WordPress plugin (case study, EN+ZH, og+banner)
Deploy / build (push) Successful in 20s
2026-09-18 01:31:46 +08:00
hoelee 4e2e603449 post: shipping an AI photo editor as a WordPress plugin (case study, EN+ZH, og+banner) 2026-09-18 01:30:04 +08:00
hoelee cdc3475f61 Fix og-gen: Chrome headless truncates 630px card footer; capture at 900 and crop with sharp, pin footer via absolute pos
Deploy / build (push) Successful in 15s
2026-09-16 01:21:25 +08:00
hoelee 55619aa04e Add posts: RAID silent corruption + self-hosted mem0 (en/zh, og+banner)
Deploy / build (push) Successful in 20s
2026-09-16 01:04:41 +08:00
hoelee 0dee1ea173 post: unraid array-stop swapfile hang root cause + User Scripts fix (en+zh, OG+banner)
Deploy / build (push) Successful in 1m1s
2026-09-15 18:44:09 +08:00
hoelee 53f1ec5de7 project-state: mark C3 done, C4 partially done (zh categories live, zh posts pending)
Deploy / build (push) Successful in 18s
2026-09-14 00:14:56 +08:00
hoelee 12529e0cd0 posts: reframe marketplace scraper as client work (used-electronics reseller), en+zh + OG image
Deploy / build (push) Successful in 18s
2026-09-14 00:09:45 +08:00
hoelee 4cf52e04c1 Categories page: descriptions, per-category terminal illustrations, full ZH version
Deploy / build (push) Successful in 17s
- 7 categories now listed with name, blurb, post count and a
  per-category terminal-style SVG illustration (CategoryArt)
- empty categories show '0 posts · coming soon' (non-link, no soft-404)
- new zh pages: /zh/categories/ index + /zh/categories/[category]/ detail
- PostList is locale-aware (zh-CN dates, zh category links)
- zh nav '分类' now points to /zh/categories/
- category detail pages get art + description header
- language switch wired both ways (EN <-> ZH)
2026-09-13 23:50:08 +08:00
hoelee 730a936137 Add scraping bot-walled marketplace warm-browser-session deep-dive (en + zh + OG)
Deploy / build (push) Successful in 17s
2026-09-13 23:33:26 +08:00
hoelee f0819eb3a3 post: passbolt hang three failure modes (devops gotcha, EN+ZH, og+banner)
Deploy / build (push) Successful in 16s
2026-09-13 17:20:42 +08:00
hoelee 249b32b5d9 Add OG image + banner for Chinese LLM APIs post (EN + ZH)
Deploy / build (push) Successful in 16s
2026-09-11 19:57:33 +08:00
hoelee da60e08e20 Add post: Using Chinese LLM APIs from Malaysia (EN + ZH)
Deploy / build (push) Successful in 14s
2026-09-11 19:01:49 +08:00
hoelee f41a6bdb1e post: add LTX-2.3 ComfyUI workflow breakdown + exact prompt to AI video guide (en + zh)
Deploy / build (push) Successful in 16s
2026-09-11 18:47:16 +08:00
hoelee 63029ddefd Add guide: Best AI video generators 2026 free vs paid + LTX-2.3 test (en + zh)
Deploy / build (push) Successful in 14s
2026-09-11 18:27:38 +08:00
hoelee 8e595d0f93 post: how I made my own songs with Suno AI (case study with embedded players + LRC sync, EN+ZH)
Deploy / build (push) Successful in 15s
2026-09-11 16:36:01 +08:00
hoelee 30a1963bea Add case study: AI furniture compositing with FLUX.1 Kontext (en + zh)
Deploy / build (push) Successful in 15s
2026-09-11 12:19:16 +08:00
hoelee 2645f778b2 Add case study: syncing a self-improving AI agent across machines (en + zh)
Deploy / build (push) Successful in 19s
2026-09-11 12:12:55 +08:00
hoelee 89a46451f4 post: add Tor/onion primer at top + service CTA with contact links (en + zh)
Deploy / build (push) Successful in 1m27s
2026-09-09 09:56:29 +08:00
hoelee 010306f01f assets: regenerate og+banner via official og-gen/banner-gen with per-post terminal entries
Deploy / build (push) Successful in 58s
2026-09-09 09:47:41 +08:00
hoelee 0da3ea5768 post: darknet-curiosity intro with live portfolio/gitea onion mirrors, og+banner images (en + zh)
Deploy / build (push) Successful in 2m35s
2026-09-09 09:41:38 +08:00
hoelee 383e46cd0c post: single obfs4 mention (reading-online only)
Deploy / build (push) Successful in 55s
2026-09-09 09:20:01 +08:00
hoelee 3503bb22b8 post: reframe as onion-only hosting exploration, add service-offering + vanity mining section (en + zh)
Deploy / build (push) Successful in 1m2s
2026-09-09 09:17:45 +08:00
hoelee b3f45bfc0d post: Hardening a Tor Onion Service: What Actually Matters (en + zh)
Deploy / build (push) Successful in 1m26s
2026-09-09 09:07:04 +08:00
hoelee 6aa9105460 Add CyberPanel v2 API reverse-engineering post (EN + ZH)
Deploy / build (push) Successful in 44s
2026-09-09 07:53:07 +08:00
hoelee 618b01e4bd feat: show 4 related posts; title-case 'Related Posts'
Deploy / build (push) Successful in 37s
2026-09-09 07:37:53 +08:00
hoelee 98b50af6be docs: document 16-day 404 cache pitfall in ops runbook
Deploy / build (push) Successful in 41s
2026-09-09 07:35:48 +08:00
hoelee 3d5e75aa70 Add case study: vetted 20 VPS providers with parallel subagents (EN + ZH + OG + banner)
Deploy / build (push) Successful in 1m5s
2026-09-09 07:28:03 +08:00
hoelee f378b4f58d Add OG image + banner for hosting-provider verification post
Deploy / build (push) Successful in 33s
2026-09-09 07:23:32 +08:00
hoelee ffb2dbe15a Add post: How to Verify a Hosting Provider Before You Buy (EN + ZH)
Deploy / build (push) Successful in 49s
2026-09-09 07:19:49 +08:00
hoelee 62b681bad1 Add two posts: headless-browser scraping limits + NocoDB backfill gotchas
Deploy / build (push) Successful in 1m12s
- why-your-headless-browser-cant-scrape-everything (case study, en+zh)
- the-nocodb-attachment-that-wouldnt-update (note, en+zh)
- og + banner images for both, generator entries added
- gitignore .og/ scratch dir (also removes previously-committed scratch files)
2026-09-09 06:56:20 +08:00
hoelee ca88623486 Upgrade Telegram post CTA to one-tap wa.me/mailto links with named service (en+zh)
Deploy / build (push) Successful in 46s
2026-09-09 06:39:45 +08:00
hoelee 7ecf1b878a Move banner below title/meta; related posts: og-image cover cards, 2-col desktop / 1-col mobile; doc: one-tap CTA rule
Deploy / build (push) Successful in 36s
2026-09-09 06:38:59 +08:00
hoelee 3a00a2d517 Add per-post banners (16:9 hero images) + banner-gen script; add banner to all posts
Deploy / build (push) Successful in 37s
2026-09-09 06:33:13 +08:00
hoelee ca650f69c0 Enlarge post list thumbnails to 400px on desktop
Deploy / build (push) Successful in 28s
2026-09-09 06:21:14 +08:00
hoelee 1a61c30674 Generate OG images for all remaining posts
Deploy / build (push) Successful in 37s
2026-09-09 06:17:30 +08:00
hoelee 493dab88ba Widen container to 70rem; show post thumbnails full-width on tablet, side image only on desktop 1024px+
Deploy / build (push) Successful in 33s
2026-09-09 06:15:07 +08:00
hoelee 7f25f9ae4d Add og-gen script: reusable per-post OG image generator
Deploy / build (push) Successful in 41s
2026-09-09 06:11:13 +08:00
hoelee ef6b54b2f1 Show post OG thumbnails in post list (left image + right text on desktop)
Deploy / build (push) Successful in 34s
2026-09-09 06:08:57 +08:00
hoelee 756179d79f Use branding logo in OG image and favicons
Deploy / build (push) Successful in 34s
2026-09-09 05:59:14 +08:00
hoelee 2c86da97c7 Add per-post OG image for Telegram debugging post
Deploy / build (push) Successful in 32s
2026-09-09 05:52:01 +08:00
hoelee a9554212a1 Add post: Why Telegram Bot Notifications Die (IPv6/DNS/multipart debugging)
Deploy / build (push) Successful in 36s
2026-09-09 05:45:10 +08:00
hoelee 34fff3e4e9 Add authentik intro and business CTA to en/zh
Deploy / build (push) Successful in 42s
2026-09-09 03:11:05 +08:00
hoelee b3108b2c7c Add Chinese translation and make i18n mandatory for all posts
Deploy / build (push) Successful in 34s
2026-09-09 03:07:29 +08:00
hoelee 203400a731 Add authentik major upgrade gotchas post
Deploy / build (push) Successful in 32s
2026-09-09 03:00:52 +08:00
hoelee a031722ec1 Document automatic i18n-sync + hire-CTA flow in post-guideline
Deploy / build (push) Successful in 18s
2026-09-06 07:48:11 +08:00
hoelee e5ec418b9f Add business-benefit section + hire CTA to DigiKedai post (EN+ZH)
Deploy / build (push) Successful in 22s
2026-09-06 07:47:25 +08:00
hoelee 184395695f Add Chinese translation of DigiKedai bot post; center search icon
Deploy / build (push) Successful in 21s
2026-09-06 07:45:18 +08:00
hoelee 1314587ba1 Fix post ordering (distinct pubDates) and align nav control heights
Deploy / build (push) Successful in 15s
2026-09-06 07:35:28 +08:00
hoelee 93c716f876 Add Pagefind search, sort posts by update time, link DigiKedai bot repo
Deploy / build (push) Successful in 20s
2026-09-06 07:31:02 +08:00
hoelee dfd9612a6b Add DigiKedai Telegram AI bot case study
Deploy / build (push) Successful in 13s
2026-09-06 07:19:15 +08:00
hoelee f0fce15dee docs: update project-state, add post-guideline reference
Deploy / build (push) Successful in 11s
- Refresh project-state.md with all completed work this session
- Add docs/post-guideline.md (title case, locale structure, frontmatter, publish flow)
- Reference it in README + hoelee-blog skill
2026-09-06 07:08:05 +08:00
hoelee 1ccb54a466 style: nav labels title-case + RSS caps; post titles title-case
Deploy / build (push) Successful in 14s
2026-09-06 07:04:34 +08:00
hoelee e608d28ec3 i18n: translate nav labels on Chinese pages (文章/分类/关于)
Deploy / build (push) Successful in 14s
2026-09-06 07:02:01 +08:00
hoelee da0409f09c content: add 'What I write about' section to hello-world intro post
Deploy / build (push) Successful in 11s
2026-09-06 06:59:44 +08:00
hoelee cf31d85ece fix: correct business framing - website design & development is primary, email is secondary
Deploy / build (push) Successful in 13s
Update hello-world post, homepage, about page, and zh landing to say
website design & development (primary) + email hosting (side offering),
not 'email-hosting business'.
2026-09-06 06:56:25 +08:00
hoelee 6b56c1f4db feat: language switch links to same post in other language
Deploy / build (push) Successful in 10s
- Add translation field to schema + auto-match by zh/ prefix
- BaseLayout accepts per-page switchHref; post page resolves translation link
- Falls back to section landing (/ or /zh/) when no translation exists
2026-09-06 06:50:37 +08:00
164 changed files with 15167 additions and 104 deletions
+5
View File
@@ -16,3 +16,8 @@ Thumbs.db
# logs
*.log
npm-debug.log*
# og/banner gen scratch\.og/
# og/banner gen scratch
.og/
+2
View File
@@ -38,6 +38,7 @@ src/
lang.ts # derives en/zh from folder path
docs/
content-guide.md # what to write, what to avoid (READ before writing a post)
post-guideline.md # HOW to write it: title case, locale, frontmatter, publish flow
design-guide.md # what the design must have, what to avoid (READ before touching UI)
seo-reference.md # E-E-A-T / name identity, SEO + GEO checklist, syndication
ops-runbook.md # pipeline, publish steps, build pitfalls, health checks
@@ -49,6 +50,7 @@ docs/
| Doc | When to read |
|---|---|
| [`docs/content-guide.md`](docs/content-guide.md) | Before writing/planning any blog post — categories, post types, the "hard job → post" template, and the anti-patterns. |
| [`docs/post-guideline.md`](docs/post-guideline.md) | Before writing/editing a post — title case, locale folder structure, frontmatter, publish flow. |
| [`docs/design-guide.md`](docs/design-guide.md) | Before changing theme, layout, typography, color, or SEO markup. |
| [`docs/seo-reference.md`](docs/seo-reference.md) | Before adding posts/`<head>` markup or debugging search visibility. |
| [`docs/ops-runbook.md`](docs/ops-runbook.md) | Before debugging CI/CD, deployment, or hosting. |
+98 -1
View File
@@ -1,5 +1,60 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------------------------------------------------------------------------
// Post metadata, read straight from the markdown at config-eval time.
// The sitemap is generated in `astro:build:done`, so the config layer resolves
// BEFORE the content collection exists - reading the files directly is the
// supported way to get post dates into `serialize()`.
// ---------------------------------------------------------------------------
const POSTS_DIR = fileURLToPath(new URL('./src/content/posts', import.meta.url));
/** Pull `key: value` pairs out of a markdown frontmatter block. */
function frontmatter(file) {
const raw = readFileSync(file, 'utf-8');
const end = raw.indexOf('---', 3);
const block = end === -1 ? raw : raw.slice(0, end);
const out = {};
for (const line of block.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.+)$/);
if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
}
return out;
}
/** Normalize a frontmatter date (2026-09-04) to a sitemap lastmod (W3C). */
function isoDate(d) {
if (!d) return undefined;
const t = Date.parse(d);
return Number.isNaN(t) ? undefined : new Date(t).toISOString();
}
// slug -> { en, zh } ISO dates. EN and ZH twins share a slug, so one entry
// serves both localized URLs; lastmod = the newest of the two.
const postDates = new Map();
{
const collect = (dir, key) => {
for (const file of readdirSync(dir, { withFileTypes: true })) {
if (!file.isFile() || !file.name.endsWith('.md')) continue;
const slug = file.name.slice(0, -3);
const fm = frontmatter(join(dir, file.name));
const date = isoDate(fm.updatedDate ?? fm.pubDate);
if (!date) continue;
const entry = postDates.get(slug) ?? {};
entry[key] = date;
postDates.set(slug, entry);
}
};
try {
collect(POSTS_DIR, 'en');
collect(join(POSTS_DIR, 'zh'), 'zh');
} catch (e) {
console.warn('[sitemap] could not read post dates:', e.message);
}
}
// https://astro.build/config
export default defineConfig({
@@ -12,5 +67,47 @@ export default defineConfig({
prefixDefaultLocale: false,
},
},
integrations: [sitemap()],
integrations: [
sitemap({
// lastmod lets Google re-crawl content that actually changed instead of
// falling back to its own heuristics. hreflang alternates in the sitemap
// are treated as more reliable than the in-page <link rel="alternate">
// tags when the two disagree, so both are emitted.
serialize(item) {
const path = new URL(item.url).pathname;
const post = path.match(/^\/posts\/(?:zh\/)?([^/]+)\/$/);
if (post) {
// Post pages get lastmod; their alternates are the EN/ZH twins.
const dates = postDates.get(post[1]);
if (dates) item.lastmod = dates.en > dates.zh ? dates.en : dates.zh ?? dates.en;
item.links = [
{ lang: 'en', url: `https://blog.hoelee.com/posts/${post[1]}/` },
{ lang: 'zh', url: `https://blog.hoelee.com/posts/zh/${post[1]}/` },
];
return item;
}
// Non-post routes: pair the two landing/section pages by pathname.
// Exception: /posts/ has no /zh/posts/ twin - the Chinese post listing
// IS the /zh/ homepage (the ZH nav "Posts" points there), so that pair
// is declared by hand rather than derived.
const MANUAL = {
'/posts/': { en: '/posts/', zh: '/zh/' },
'/zh/': { en: '/posts/', zh: '/zh/' },
};
const src = /^\/zh\/(.*)$/.exec(path);
const pair = MANUAL[path]
?? (src ? { en: `/${src[1]}`, zh: path } : { en: path, zh: `/zh/${path.slice(1)}` });
item.links = [
{ lang: 'en', url: `https://blog.hoelee.com${pair.en}` },
{ lang: 'zh', url: `https://blog.hoelee.com${pair.zh}` },
];
return item;
},
}),
],
});
+1 -1
View File
@@ -103,7 +103,7 @@ draft: false
## 8. i18n policy
- **English is primary and non-negotiable** — the whole SEO strategy targets English queries.
- **Chinese (zh):** translate only the 23 best case studies. Cheap differentiation, opens zh-SG/zh-MY search.
- **Chinese (zh): translate every post.** Same filename in `posts/zh/` (auto language-switch). Opens zh-SG/zh-MY search and covers bilingual recruiters.
- **Malay: skip for v1** — no dev-audience demand (the main site already has a dangling Malay config; don't repeat it).
### File layout (enforced by code)
+15
View File
@@ -63,3 +63,18 @@ Overflow diagnosis (real numbers, not screenshot guessing): check `scrollWidth >
- `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` ~6090s before the first bare-URL check, or validate
with `?cb=$(date +%s)` first so you never register a 404 into edge cache.
+108
View File
@@ -0,0 +1,108 @@
# Post-Writing Guideline
A single reference for writing a post on blog.hoelee.com. Read this **before** creating or editing any post. It consolidates the style, locale, and frontmatter rules — the "how to write it correctly the first time" checklist.
> Broader *what-to-write* strategy (categories, post types, cadence, anti-patterns) lives in `content-guide.md`. This file is the *how* — mechanics and style.
---
## 1. File location & language (enforced by code)
| Language | Path | URL |
|---|---|---|
| English | `src/content/posts/<slug>.md` | `/posts/<slug>/` |
| Chinese | `src/content/posts/zh/<slug>.md` | `/posts/zh/<slug>/` |
- **Do NOT set a `lang:` field** in frontmatter — language is derived from the folder.
- English is primary. Chinese is selective (23 flagship case studies). No Malay.
- To make a Chinese translation of a post, give it the **same filename** in the `zh/` folder — the language switcher auto-links them. If filenames differ, add a `translation: "zh/<other-slug>"` field to link them manually.
---
## 2. Title style — Title Case
All English post titles use **Title Case** (capitalize the first letter of every significant word):
-`How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD`
-`Hello, World — About This Blog`
-`How I host this blog: ...`
Exceptions stay lowercase (articles, prepositions, conjunctions — but our house style capitalizes them after punctuation like `—` or `:` for a clean look): "About", "This", "And", "and" are all acceptable; be **consistent**.
Chinese titles need no capitalization change.
---
## 3. Frontmatter shape
```yaml
---
title: "How I Built the DigiKedai Telegram AI Bot" # Title Case
description: "A ~155-char meta description with the target keyword."
pubDate: 2026-09-06
updatedDate: 2026-09-10 # optional, when revised
category: case-studies # one of the 7 below
tags: ["telegram", "n8n", "docker", "cloudflare"]
translation: "zh/how-i-built-digikedai-bot" # optional, only if slug differs from zh counterpart
ogImage: "/og/digikedai-bot.png" # optional, 1200×630 custom image
draft: false # true = hidden from build
---
```
### Categories (the only 7 allowed)
`engineering` · `devops` · `ai` · `web3` · `tutorials` · `case-studies` · `notes`
---
## 4. The "hard job → post" template
When you finish a difficult piece of work, use this shape — it's simultaneously a tutorial, a case study, and a proof-of-expertise:
```
① The problem → phrased as the searchable question a learner would type
② What I tried & why it failed → the debugging story (no one else can copy this)
③ The fix → runnable code/config, explained
④ What I'd do differently → shows judgment
⑤ The result → one quantified outcome
```
---
## 5. Language & identity rules
- **Business framing:** website design & development is the primary business; email hosting is a **side offering** — never describe it as "an email-hosting business".
- **Name:** "Lee Teong Hoe" / "Mr Hoelee" — identical across blog, LinkedIn, GitHub, git.hoelee.com.
- **No overclaiming**, especially Web3 (learning projects, not production DeFi).
---
## 6. Code blocks
- Wrap code in fenced blocks with the language tag (```yaml, ```bash, ```ts).
- Keep code/commands in their **original language** (don't translate code or commands inside a Chinese post).
- The copy button is added automatically by the layout — no action needed.
---
## 7. Publish flow
1. Write the `.md` file in the correct folder (see §1).
2. `npm run build` locally to confirm it compiles (optional but recommended).
3. Commit + push to both remotes:
```bash
git push origin main && git push github main
```
4. Gitea Actions CI builds and deploys automatically; verify with `curl -I https://blog.hoelee.com/posts/<slug>/` → 200.
## 8. i18n sync — Chinese translation (do this automatically, every post)
Every post gets a Chinese translation, **every time**, without being asked:
1. **Translate every post.** Any new post gets a Chinese version at `src/content/posts/zh/<same-slug>.md` — **same filename**, so the language switcher auto-links the two (no `translation:` field needed).
2. **Keep EN and ZH in sync.** If you edit the English post (add a section, fix a fact, update a link), make the **same edit** to the Chinese version in the same commit. Never let the two versions drift.
3. **Translate the *frontmatter* too** — title and description go to Chinese, but `category`, `tags`, and `pubDate` stay identical to the English post (they're data, not prose).
4. **Code/commands stay in English** inside the Chinese post (see §6) — only the prose around them is translated.
5. **Every case study ends with a hire CTA** (in the post body, not frontmatter): a "Want this for your business?" section. The contact must be **one-tap, not plain text** — a clickable WhatsApp link (`https://wa.me/60127972969`) and a `mailto:` link (`mailto:me@hoelee.com?subject=...`), plus `hoelee.com` — and it should **name the concrete service offered** (e.g. "I build Telegram support bots like this one", "I set up self-hosted monitoring pipelines"), so a reader can tap straight through and ask for that specific thing. This is a business blog — every flagship post doubles as a lead magnet.
6. **Case studies open with a "why it matters" section** (the business benefit: saves money, 24/7, converts browsers, remembers customers) before the technical architecture.
**Verify after build:** the English page links to `/posts/zh/<slug>/` and the Chinese page links back to `/posts/<slug>/` (the auto language-switch).
+134 -29
View File
@@ -4,9 +4,11 @@ Living list of what's done and what's next for blog.hoelee.com. Work through the
**Status key:** ✅ done · 🔵 in progress · ⬜ not started
> **How to resume the project:** start at the top-most ⬜ item in the **Execution Plan** (§2) below and work downward. Each step is self-contained, has a "done when" criterion, and references the governing doc. Don't jump ahead — earlier steps unlock later ones.
---
## Done (foundation)
## 1. Done (foundation)
- ✅ Astro 5 static + Markdown, Gitea Actions CI/CD → nginx → Cloudflare (deployed)
- ✅ Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button
@@ -14,47 +16,150 @@ Living list of what's done and what's next for blog.hoelee.com. Work through the
- ✅ Author card + `Person`/`ProfilePage` JSON-LD (E-E-A-T), article meta, reading time, related posts
- ✅ Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap
- ✅ Category pages (`/categories/`, `/categories/[category]/`)
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder, no `lang:` frontmatter)
- ✅ Language switcher in nav (EN ↔ 中文) + `/zh/` landing page
-Dark mode default (light is opt-in via toggle)
-Case study post "how-i-host-this-blog" updated with docker-compose sample + real installation gotchas (from commit history)
-Knowledge guides in `docs/` (content, design, seo, ops) + README index + `hoelee-blog` skill
- **Make the Gitea repo public** — verified no secrets in source or history (PAT is a `${{ secrets.PAT }}` reference, not hardcoded). Recommended: yes, public — it's a portfolio artifact. Action: flip visibility in Gitea repo settings.
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder)
- ✅ Language switcher in nav — links to the **same post** in the other language (auto-matches by `zh/` prefix)
-Locale-aware nav labels (EN/ZH)
-Dark mode default (light opt-in via toggle)
-Case study "How I Host This Blog"
- ✅ Chinese translation of the case study (`posts/zh/how-i-host-this-blog.md`)
- ✅ hello-world intro post
- ✅ Case study "How I Built the DigiKedai Telegram AI Bot" + Chinese twin (`posts/zh/how-i-built-the-digikedai-telegram-bot/`)
- ✅ Business framing corrected (website design & development = primary; email hosting = secondary)
- ✅ Knowledge guides in `docs/` + README index + `hoelee-blog` skill
- ✅ Both repos public (Gitea + GitHub) with title/description/homepage/topics + `v1.0.0` release
---
## Tier 1 — Content (80% of value; do this first)
## 2. Execution Plan (work top → bottom, one step at a time)
-**Write the 2 flagship case studies** — highest ROI, these are the portfolio:
- ⬜ "How I built the DigiKedai Telegram AI bot" (DSM Docker + Cloudflare tunnel webhook + LiteLLM)
- ⬜ "Self-hosting a mem0 memory stack" (API + LiteLLM + pgvector)
-**23 gotcha posts** from real debugging history (short, Google-friendly):
- ⬜ "The Traefik forward-auth gotcha that cost me a day"
- ⬜ "Site-to-site OpenVPN behind CGNAT"
- ⬜ "Fixing the WordPress /cv 301→404 chain" (from own audit)
-**"Hard job → post" habit** — every solved problem becomes a `notes` entry the same week (template in `docs/content-guide.md` §4)
> This plan comes from a full research pass (Sept 2026) comparing blog.hoelee.com against reference developer blogs (Simon Willison, Josh Comeau, Dan Abramov/overreacted, Julia Evans) + industry surveys. Priority is fixed: **identity & content → discovery → polish.** Don't reorder unless the user says so.
### Phase A — Identity (highest ROI, ~23 hrs total)
**Step A1 — Add a real author photo (headshot).**
The #1 gap vs. every reference blog: the avatar is a letter "M" placeholder and there's no photo anywhere on the site. Every credible personal dev blog has a human face.
- [ ] User provides one headshot (square, ≥800×800 for avatar; also source for og).
- [ ] Replace `avatar` letter with the photo in: nav/brand (optional), author card (About + every post), `ProfilePage` JSON-LD `image`.
- [ ] Add the photo to `og-default.png` template so the default share card has a face.
- **Governing doc:** `design-guide.md` §2 (author box), §5 (E-E-A-T name+photo consistency).
- **Done when:** a real face renders in the author card on About + every post; `curl` shows no 404 for the asset.
**Step A2 — Align the homepage title & hero framing.**
The `<title>` says "engineering, DevOps & self-hosting" but the hero says "full-stack developer and DevOps engineer" — two slightly different framings.
- [ ] Pick one line (recommend: "full-stack developer & DevOps engineer") and use it in both `<title>`/meta description and hero paragraph.
- **Done when:** homepage title, meta description, and hero all say the same thing about who Hoelee is.
**Step A3 — Add a "Start here" / featured posts route.**
New visitors land on reverse-chronological "Latest posts" with no guidance to the best content (Julia Evans' Favorites, Josh Comeau's featured posts both solve this).
- [ ] Add a "Start here" (or "Featured") section on the homepage surfacing 23 flagship case studies.
- [ ] Optionally add a `/favorites` or `/start-here` page (defer the dedicated page until ≥6 strong posts; the homepage strip is the immediate win).
- **Done when:** homepage shows a featured/start-here strip above or beside "Latest posts".
### Phase B — Content (80% of value; the long game)
**Step B1 — Write the 2nd flagship case study: "Self-Hosting a Mem0 Memory Stack".** ✅ Done 2026-09-16
The Mem0 flagship is already the single highest-value unwritten post in the backlog.
- [x] Write `src/content/posts/self-hosting-mem0.md` (category `case-studies`).
- [x] Write Chinese twin `src/content/posts/zh/self-hosting-mem0.md` (same filename → auto language-switch).
- [x] Follow the "hard job → post" template (§4 content-guide) + open with "why it matters" + end with hire CTA (§8 post-guideline).
- **Governing doc:** `content-guide.md` §4/§8, `post-guideline.md` §8.
- **Done when:** ✅ both EN + ZH pages live, language-switch works, hire CTA present.
**Step B1b — Write the self-hosted STT case study.** ✅ Done 2026-09-19
`self-hosted-speech-to-text-api.md` (EN + ZH): whisper.cpp on GPU + n8n auth gate +
nginx gateway, with the four build traps and the "5x faster than typing" business case.
- [x] EN + ZH posts, custom OG + banner, hire CTA.
- **Done when:** ✅ both pages build, language-switch verified, images generated.
**Step B2 — Write 23 short "gotcha" posts (Google-friendly, compound over time).**
- [x] "Replacing RDPGuard With IPBan: The Traps Nobody Documents" (EN + ZH, `devops`, 2026-09-19) — the uninstaller that unbans 12 attackers, `--install-service` doesn't exist in v4.1.0, `ExpireTime` vs `BanTime`. Both images custom.
- [x] "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB" (EN + ZH, `devops`, 2026-09-19) — a client whose error handler crashed while reporting its own errors, masking every real failure; three patches to Oracle's bundled code, all stemming from `major >= 8` being an invalid MySQL-vs-MariaDB test. Both images custom.
- [ ] "The Traefik forward-auth gotcha that cost me a day"
- [ ] "Site-to-site OpenVPN behind CGNAT"
- [ ] "Fixing the WordPress /cv 301→404 chain" (from own audit)
- **Governing doc:** `content-guide.md` §3 (post type #3), `post-guideline.md`.
- **Done when:** ≥2 gotcha posts live (these are `notes`/`devops`, no Chinese translation required per §8).
-**Note:** `post-guideline.md` §8 (newer) says *every* post gets a ZH twin — the "no Chinese required" note above is stale. The RDPGuard post was published EN + ZH.
**Step B2b — Draft bank (written, held as `draft: true`, publish when content runs short).** ✅ Drafted 2026-09-20
Two finished posts (EN + ZH, each with frontmatter pointing at OG + banner paths) sitting in the repo but
**not built or listed**`draft: true` excludes them from all listings and generates no pages.
| Slug | Category | Status | Assets |
|---|---|---|---|
| `migrating-codeigniter-iis-to-openlitespeed` | `engineering` | drafted, unpublished | OG + banner PNGs **not yet generated** |
| `upgrading-codeigniter-46-to-47` | `notes` | drafted, unpublished | OG + banner PNGs **not yet generated** |
**To publish one later:**
1. Flip `draft: true``draft: false` in **both** `src/content/posts/<slug>.md` and `src/content/posts/zh/<slug>.md`.
2. Set the real `pubDate` (currently `2026-09-20`, the draft date) in both files.
3. Generate its images: `node scripts/og-gen/generate.mjs <slug>` and `node scripts/banner-gen/generate.mjs <slug>` (add a `TERMINALS[slug]` / `BANNERS[slug]` entry first for the custom panel).
4. `npm run build`, commit, `git push origin main`.
5. Verify both URLs return 200 and the language switcher links them.
- **Why these two:** `engineering` had only 1 post and `tutorials` only 1 — the blog was ~all `devops`/`case-studies`. These put PHP/CodeIgniter (the actual day-job stack) on the blog, which is what a PHP full-stack recruiter searches for.
- **Governing doc:** `content-guide.md` §3/§4, `post-guideline.md` §8.
- **Done when:** both are published live with EN+ZH, custom OG + banner, and verified 200.
**Step B3 — Adopt the "hard job → post" habit.**
Every solved problem becomes a `notes` entry the same week.
- [ ] Revisit cadence target: 2 posts/month → 1/week (`content-guide.md` §5).
- **Done when:** 3 consecutive months hit the 2-posts/month floor.
### Phase C — Discovery & structure (Tier 2)
**Step C1 — Per-post custom OG images (at least for case studies).**
Currently every post shares the generic 14KB `og-default.png` — flagship posts share the same bland card as category pages.
- [ ] Build a branded 1200×630 OG template (name + face + title).
- [ ] Generate a custom `ogImage` for each case study (frontmatter `ogImage:` field already supported).
- **Governing doc:** `design-guide.md` §2/§3, `content-guide.md` §6 (ogImage field).
- **Done when:** each case study's `og:image` is unique and 1200×630.
**Step C2 — Tag pages** (`/tags/[tag]/` archive pages for fine-grained discovery + internal linking).
- [ ] Add tag archive routes (tags currently render as labels only).
- **Done when:** clicking a tag on any post opens a working `/tags/<tag>/` page.
**Step C3 — Categories page shows all 7 categories** (not just those with posts), with "0 posts / coming soon" for empty ones — signals intended coverage. ✅ Done 2026-09-13
- [x] `/categories/` lists all 7 categories with name, description, per-category terminal-style SVG illustration (CategoryArt/Grid components) and a post count; empty ones show "0 posts · coming soon" as a non-link.
- **Done when:** ✅ all 7 render with a placeholder for empty ones + descriptions + illustrations.
**Step C4 — Dedicated `/zh/posts/` and `/zh/categories/` archive pages.** 🔵 In progress
- [x] `/zh/categories/` index + `/zh/categories/[category]/` detail pages live (zh nav "分类" points there; PostList is locale-aware with zh-CN dates).
- [ ] `/zh/posts/` archive still missing — zh nav "文章" falls back to `/zh/` landing (zh post count already 17, the archive is due).
- **Done when:** zh nav links to real `/zh/posts/` + `/zh/categories/` archives.
### Phase D — Polish / later (Tier 3)
**Step D1 — Search** (AstroPaper-style fuzzy search). Low priority until >20 posts.
**Step D2 — Google Search Console submission** — submit `sitemap-index.xml` for faster indexing.
**Step D3 — Newsletter / email capture** — only after real traffic exists (agree: do NOT add yet).
---
## Tier 2 — Structural gaps
## 3. Research Findings Snapshot (Sept 2026)
-**Tag pages** — tags currently render as labels only; add `/tags/[tag]/` archive pages for fine-grained discovery + internal linking
-**Categories page shows all 7 categories** (not just those with posts) — signal intended coverage; show "0 posts / coming soon" for empty ones
-**Search** — AstroPaper-style fuzzy search (low priority until >20 posts)
What the reference blogs do that blog.hoelee.com should mirror, ranked:
| Finding | Reference example | Status on blog.hoelee.com |
|---|---|---|
| Real name + photo + one-line identity | All four | ⚠️ name ✅, photo ❌ (letter "M") — **Step A1** |
| Focused thesis (one sentence on what it's about) | Julia Evans, Simon Willison | ⚠️ has it, but title/hero drift — **Step A2** |
| Honesty about what you *don't* know | Simon, Dan Abramov | ✅ strong (DigiKedai "bugs that ate an afternoon") |
| Specific detail: code, diagrams, numbers, bug stories | All four | ✅ strong |
| Consistent cadence (slow is fine, dead is not) | Julia (~monthly), Simon (daily) | ⚠️ only 3 posts, all Sept 46 — **Phase B** |
| "Start here" / Favorites route | Julia Evans, Josh Comeau | ❌ — **Step A3** |
| RSS + sitemap + clean SEO | All four | ✅ |
| Per-post OG images | Josh Comeau | ❌ — **Step C1** |
| Search (once >1520 posts) | Josh Comeau | ❌ deferred — **Step D1** |
---
## Tier 3 — Polish / later
-**Verify Chinese content split** — confirm `zh/` posts don't appear in EN feed (the `isEn` helper already filters; re-check when first zh post lands)
-**Google Search Console submission** — submit `sitemap-index.xml` for faster indexing
-**Newsletter / email capture** — only after real traffic exists
---
## Conventions (non-negotiable)
## 4. Conventions (non-negotiable)
- Push git.hoelee.com first, then GitHub
- English-first; Chinese selective (23 flagship case studies); no Malay
- No overclaiming, especially Web3
- Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles everywhere
- Business framing: website design & development is primary; email hosting is secondary
- English post titles use Title Case
- See `docs/post-guideline.md` for post-writing rules; `docs/content-guide.md` for strategy; `docs/design-guide.md` for UI
+8
View File
@@ -45,6 +45,14 @@ The entire blog strategy is a **name-search play**, so identity must be airtight
- [ ] Related-posts module (3 cards) on every post
- [ ] No orphaned/thin pages (avoid the hoelee.com ~400-attachment mistake)
### Crawler / sitemap mechanics (as built)
- `robots.txt` is a **build-time endpoint** (`src/pages/robots.txt.ts`), not a static file in `public/`. It interpolates `SITE.url` so the `Sitemap:` line can't drift. **Keep it pure ASCII** — the response has no charset, so a non-ASCII byte renders as mojibake (`鈥`) in viewers that fall back to a legacy codepage. Check: `LC_ALL=C grep -c '[^ -~]' dist/robots.txt` must be `0`.
- Policy is **allow-all**, including AI *training* crawlers. Rationale: `robots.txt` is advisory and unenforced (Cloudflare documents it as voluntarily honoured), so a blocklist there buys nothing; enforcement, if ever wanted, belongs in Cloudflare AI Crawl Control. `/pagefind/` is the only exclusion (build artifacts, not content).
- **Why a real robots.txt matters at all:** writing one turned Cloudflare's *Content Signals Policy* placeholder from a **replacement** into a **prepend**. On a Free-plan zone with no origin `robots.txt`, Cloudflare serves that placeholder *in place of* your file — a comment block with no `User-agent`, no directives and no `Sitemap:` line. Confirm the origin is serving yours before blaming Astro.
- Sitemap `lastmod` + `xhtml:link` hreflang alternates are **derived**, never authored — see the rule in the project skill. `sitemap-index.xml` legitimately contains exactly one entry (`sitemap-0.xml`) and looks empty in a viewer; the URLs are in `sitemap-0.xml` (77 of them at 29 posts). Don't "fix" the index.
- hreflang exists in **two places on purpose**: in-page `<link rel="alternate">` (from `BaseLayout`'s `altLocaleUrl`) and sitemap `xhtml:link`. Google treats the sitemap as more authoritative when they disagree, so both must be kept in sync — which is automatic for posts and for any page that passes `altLocaleUrl`.
---
## Syndication policy
+111 -1
View File
@@ -12,7 +12,8 @@
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^5.5.0"
"astro": "^5.5.0",
"pagefind": "^1.5.2"
}
},
"node_modules/@astrojs/compiler": {
@@ -1184,6 +1185,97 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@pagefind/darwin-arm64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz",
"integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@pagefind/darwin-x64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz",
"integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@pagefind/freebsd-x64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz",
"integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@pagefind/linux-arm64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz",
"integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@pagefind/linux-x64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz",
"integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@pagefind/windows-arm64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz",
"integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pagefind/windows-x64": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz",
"integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/pluginutils": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
@@ -4028,6 +4120,24 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT"
},
"node_modules/pagefind": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz",
"integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==",
"license": "MIT",
"bin": {
"pagefind": "lib/runner/bin.cjs"
},
"optionalDependencies": {
"@pagefind/darwin-arm64": "1.5.2",
"@pagefind/darwin-x64": "1.5.2",
"@pagefind/freebsd-x64": "1.5.2",
"@pagefind/linux-arm64": "1.5.2",
"@pagefind/linux-x64": "1.5.2",
"@pagefind/windows-arm64": "1.5.2",
"@pagefind/windows-x64": "1.5.2"
}
},
"node_modules/parse-latin": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
+3 -2
View File
@@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "astro build && pagefind --site dist",
"preview": "astro preview",
"astro": "astro"
},
@@ -14,6 +14,7 @@
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^5.5.0"
"astro": "^5.5.0",
"pagefind": "^1.5.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+43
View File
@@ -0,0 +1,43 @@
# banner-gen — per-post banner/hero image generator
Generates a branded 1600×900 (16:9) banner image for a blog post — shown at
the top of the article, above the title. Matches the site's cobalt-blue
branding and the "terminal / debugging" visual language (siblings with
`scripts/og-gen/`, which produces the 1200×630 social-share image).
## Usage
```bash
node scripts/banner-gen/generate.mjs <post-slug>
```
Reads the post's category from frontmatter, fills `template.html`, renders
with headless Chrome, and writes `public/banners/<slug>.png`.
Then add `banner: /banners/<slug>.png` to the post frontmatter (en + zh) —
`[...slug].astro` renders it above the H1 title.
## Customizing content
Each post's banner content lives in `BANNERS` in `generate.mjs`, keyed by
slug, with three parts:
- `titlebar` — the terminal window's title-bar text
- `lines` — terminal body rows: `{ t, text }` where `t`
`cmd` · `dim` · `err` (red) · `ok` (green) · `hl` (amber) · `prompt` (`$`)
- `flow` — bottom pipeline steps: `{ n: '1', label: '...', err?: true }`
No entry → a generic default banner.
## Requirements
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
(`CHROME_PATH` to override).
- Node 20+.
## Relationship to og-gen
- og-gen → `public/og/<slug>.png` (1200×630, 2:1) for social sharing.
- banner-gen → `public/banners/<slug>.png` (1600×900, 16:9) for the article
hero. The banner is a "filled" visual (terminal + flow pipeline), not a
poster — avoid large empty margins.
+685
View File
@@ -0,0 +1,685 @@
#!/usr/bin/env node
/**
* banner-gen — generate a 1600×900 (16:9) banner/hero image for a blog post.
*
* Usage:
* node scripts/banner-gen/generate.mjs <slug> [--all]
*
* Reads the post's frontmatter, fills the template, renders with headless
* Chrome, and writes public/banners/<slug>.png.
*
* Each post's content (titlebar, terminal lines, flow steps) is defined in
* BANNERS below, keyed by slug. Falls back to a generic default.
*
* The post frontmatter then needs `banner: /banners/<slug>.png` so the
* article renders it above the title ([...slug].astro does this).
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const slug = process.argv[2];
if (!slug) {
console.error('Usage: node scripts/banner-gen/generate.mjs <slug> | --all');
process.exit(1);
}
// ---------- per-post banner content ----------
// lines: [{ t: 'cmd'|'dim'|'err'|'ok'|'hl'|'prompt', text }]
// flow: [{ n: '1', label: '...', err?: true }]
const BANNERS = {
'why-telegram-bot-notifications-die': {
titlebar: 'root@dsm — carousell-monitor',
lines: [
{ t: 'dim', text: '2026-09-08 20:45:41' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'scraping "uniform" — 49 cards parsed' },
{ t: 'dim', text: '2026-09-08 20:45:42' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '2 new listings → archiving to NocoDB' },
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendPhoto failed: 400 nginx/1.30.1' },
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendMessage failed: 400 nginx/1.30.1' },
{ t: 'prompt', text: '$', },
{ t: 'cmd', text: 'getent hosts api.telegram.org' },
{ t: 'hl', text: '2001:67c:4e8:f004::9' }, { t: 'dim', text: 'api.telegram.org ← IPv6 only, no A record' },
{ t: 'prompt', text: '$' },
{ t: 'cmd', text: 'fix = extra_hosts → IPv4 · multipart join → binary-safe' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ delivered ✓' },
],
flow: [
{ n: '1', label: 'Collect listings' },
{ n: '2', label: 'sendPhoto 400', err: true },
{ n: '3', label: 'DNS → IPv6 only' },
{ n: '4', label: 'multipart fix' },
{ n: '5', label: 'delivered ✓' },
],
},
'authentik-major-upgrade-gotchas': {
titlebar: 'root@dsm — authentik upgrade',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose pull authentik-worker' },
{ t: 'dim', text: 'Pulling authentik:2026.8.1 ... done' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose up -d && docker logs -f authentik' },
{ t: 'err', text: 'authorization_flow not found · SSO broken' },
{ t: 'dim', text: 'trusted_proxy_cidrs was reset · storage mount changed' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = authorization_flow → authentication_flow' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ SSO restored ✓' },
],
flow: [
{ n: '1', label: '2025.8 → 2026.8' },
{ n: '2', label: 'SSO broken', err: true },
{ n: '3', label: 'flow renamed' },
{ n: '4', label: 'trusted proxy' },
{ n: '5', label: 'restored ✓' },
],
},
'using-chinese-llm-apis-from-malaysia': {
titlebar: 'root@dsm — llm cost watch',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'deepseek-v4-flash · off-peak' },
{ t: 'dim', text: '$0.15 / 1M in · $0.60 / 1M out — cached input ~20× less' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'tokenrhythm ¥68 → GLM-5.3 · Qwen3.8-Max · Kimi K2.7-Code' },
{ t: 'err', text: 'wall: RMB payment · mainland CN phone verification' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = Alipay top-up · email-signup platforms' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~US$10 credit ≈ a month of agent work ✓' },
],
flow: [
{ n: '1', label: 'RMB via Alipay' },
{ n: '2', label: 'CN phone wall', err: true },
{ n: '3', label: 'OpenAI-compatible key' },
{ n: '4', label: '¥68 credit' },
{ n: '5', label: 'agents run ✓' },
],
},
'how-i-host-this-blog': {
titlebar: 'root@unraid — deploy pipeline',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git push origin main' },
{ t: 'dim', text: '→ git.hoelee.com/hoelee/hoelee-blog' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'Gitea Actions → build (Astro 5)' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'npm ci && npm run build → dist/' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'deploy → unRaid runner → nginx' },
{ t: 'ok', text: '✓ served via Cloudflare (cache + SSL)' },
{ t: 'dim', text: 'git-as-CMS · zero-downtime deploy' },
],
flow: [
{ n: '1', label: 'git push' },
{ n: '2', label: 'Gitea Actions' },
{ n: '3', label: 'Astro build' },
{ n: '4', label: 'unRaid nginx' },
{ n: '5', label: 'Cloudflare' },
],
},
'how-i-built-the-digikedai-telegram-bot': {
titlebar: 'DigiKedai — AI support bot',
lines: [
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "does this course have a free trial?"' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: yes — here\'s your trial account ✓' },
{ t: 'dim', text: '(answered in < 2s, no human in the loop)' },
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "which package should I buy?"' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: recommends + provisions a free account' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = grammY · LiteLLM · Cloudflare tunnel · 24/7' },
],
flow: [
{ n: '1', label: 'User asks' },
{ n: '2', label: 'grammY webhook' },
{ n: '3', label: 'LiteLLM' },
{ n: '4', label: 'AI answers' },
{ n: '5', label: 'provisions ✓' },
],
},
'when-smart-says-healthy-but-your-raid-is-corrupting-data': {
titlebar: 'root@unraid — cache pool scrub',
lines: [
{ t: 'dim', text: 'btrfs RAID1 · 2× Samsung PM9A3 NVMe · same batch' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs scrub /mnt/cache' },
{ t: 'err', text: 'csum 0x8941f998 recurring = CRC32C(zero block)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'self-heal rewrite → does NOT stick' },
{ t: 'err', text: 'write-path corruption · SMART stays clean' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = restore VM + replace both drives' },
],
flow: [
{ n: '1', label: 'SMART clean' },
{ n: '2', label: 'csum zeros', err: true },
{ n: '3', label: 'self-heal no-stick' },
{ n: '4', label: 'restore VM' },
{ n: '5', label: 'replace both ✓' },
],
},
'self-hosting-mem0-memory-stack': {
titlebar: 'root@dsm — mem0 memory stack',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "curl -X POST :20015/memories -H X-Api-Key" },
{ t: 'dim', text: 'user_id + text → mem0 extracts & stores' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: '→ remembers across sessions ✓' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = mem0-api + LiteLLM + pgvector(pg17)' },
{ t: 'err', text: 'infer=true → LLM hop · slow writes' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'self-hosted · data stays on LAN ✓' },
],
flow: [
{ n: '1', label: 'POST /memories' },
{ n: '2', label: 'store fact' },
{ n: '3', label: 'search' },
{ n: '4', label: 'feed context' },
{ n: '5', label: 'remembers ✓' },
],
},
'hardening-a-tor-onion-service': {
titlebar: 'root@tor-host — onion service audit',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wget -qO- ipv4.icanhazip.com # from inside the app' },
{ t: 'err', text: '→ home IP returned · iptables block silently gone' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'cap_add: NET_BIND_SERVICE · keep :80' },
{ t: 'err', text: 'listen tcp :80: bind: permission denied · CapEff=0' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'networks: internal:true · port 8080 · SocksPort 0' },
{ t: 'ok', text: '→ bad address ✓ · zero egress ✓ · all containers healthy ✓' },
],
flow: [
{ n: '1', label: 'Audit egress', err: true },
{ n: '2', label: 'internal:true' },
{ n: '3', label: 'non-root :8080' },
{ n: '4', label: 'healthchecks' },
{ n: '5', label: 'verify ✓' },
],
},
'hello-world': {
titlebar: '~/hoelee-blog — first commit',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git init hoelee-blog && git add -A' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git commit -m "Hello, world"' },
{ t: 'ok', text: '[main 0000001] Hello, world ✓' },
{ t: 'dim', text: 'technical writing · self-hosting · build log' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'about → blog.hoelee.com' },
],
flow: [
{ n: '1', label: 'Why this blog' },
{ n: '2', label: 'what I build' },
{ n: '3', label: 'self-hosting' },
{ n: '4', label: 'learn in public' },
],
},
'why-your-headless-browser-cant-scrape-everything': {
titlebar: 'root@dsm — headless browser vs anti-bot',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'browserless → goofish.com search?q=iPhone15' },
{ t: 'err', text: '非法访问 · "please use a normal browser"' },
{ t: 'dim', text: 'stealth flag · patched navigator.webdriver · real UA → still blocked' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl carousell search → parse __PRELOADED_STATE__' },
{ t: 'ok', text: 'listings + price + photos ✓ (no browser, no stealth)' },
{ t: 'hl', text: 'server-rendered JSON ≠ signed async API' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the data path before choosing a tool ✓' },
],
flow: [
{ n: '1', label: 'goofish' },
{ n: '2', label: 'signed API', err: true },
{ n: '3', label: 'carousell' },
{ n: '4', label: 'server JSON', err: false },
{ n: '5', label: 'parsed ✓' },
],
},
'the-nocodb-attachment-that-wouldnt-update': {
titlebar: 'root@dsm — nocodb backfill',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'PATCH image path = fullsize_url' },
{ t: 'dim', text: '200 OK — but readback still shows the old thumbnail' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'grep image.json | jq .id' },
{ t: 'hl', text: 'id present → NocoDB resolves by id, ignores new path' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'drop id + signedPath · url.replace("_progressive_thumbnail","")' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '300 rows updated ✓' },
],
flow: [
{ n: '1', label: 'patch path' },
{ n: '2', label: 'id keeps old', err: true },
{ n: '3', label: 'strip id' },
{ n: '4', label: 'replace() suffix' },
{ n: '5', label: 'updated ✓' },
],
},
'how-to-verify-a-hosting-provider-before-you-buy': {
titlebar: '~/hosting-due-diligence',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'whois vps.tld | grep -i created' },
{ t: 'err', text: 'registration: 2026-05 — homepage says \"since 2012\"' },
{ t: 'dim', text: 'a \"since\" claim on a young domain is never verifiable' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl AUP | grep -iE \"tor|reverse proxy|tunnel\"' },
{ t: 'hl', text: 'found: \"TOR nodes\", \"anonymizing services\" → hard no' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'trustpilot trend · r/hosting · retention clause' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ verdict ✓' },
],
flow: [
{ n: '1', label: 'domain age' },
{ n: '2', label: '\"since\" claim', err: true },
{ n: '3', label: 'read AUP' },
{ n: '4', label: 'reputation' },
{ n: '5', label: 'verdict ✓' },
],
},
'how-i-vetted-20-vps-providers-with-parallel-subagents': {
titlebar: '~/vendor-due-diligence — fan-out',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'delegate → 3 subagents, 20 providers' },
{ t: 'dim', text: 'batch A: 5 · batch B: 6 · batch C: 9 (parallel)' },
{ t: 'info', text: 'each → WHOIS · AUP · privacy · pricing · reviews' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'merge → one scorecard' },
{ t: 'hl', text: 'aup flags: "TOR nodes" · domain-age mismatch · metered cap' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~20 providers audited in 3h ✓' },
],
flow: [
{ n: '1', label: 'checklist' },
{ n: '2', label: '3 subagents' },
{ n: '3', label: 'parallel fetch' },
{ n: '4', label: 'scorecard' },
{ n: '5', label: 'verdict ✓' },
],
},
'automating-cyberpanel-without-the-ui': {
titlebar: 'root@cyberpanel — reverse-engineering the v2 API',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -X POST .../api/verifyConnection' },
{ t: 'err', text: '404 — the /api/ prefix was dropped in v2' },
{ t: 'dim', text: 'docs say adminUser/adminPass · panel says "This request need session."' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → csrftoken → POST /verifyLogin (X-CSRFToken)' },
{ t: 'hl', text: 'loginStatus: 1 · session cookie set' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /websites/fetchWebsitesList' },
{ t: 'ok', text: '→ all sites + SSL expiry ✓ (no UI)' },
],
flow: [
{ n: '1', label: 'docs 404', err: true },
{ n: '2', label: 'session wall' },
{ n: '3', label: 'CSRF token' },
{ n: '4', label: 'verifyLogin' },
{ n: '5', label: 'sites ✓' },
],
},
'syncing-a-self-improving-ai-agent-across-machines': {
titlebar: '~/hermes — sync agent across machines',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git pull --ff-only origin main' },
{ t: 'err', text: 'refusing: local divergence · never --force' },
{ t: 'dim', text: 'distribution repo skills/ is a COPY, not the live dir' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'diff -rq live ~/skills repo copy' },
{ t: 'hl', text: 'Only in live/: hermes-profile-sync ← created after last push' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stage real skills only · commit · push · verify' },
{ t: 'ok', text: '→ edbf7ac on both ends ✓ · 172 skills · 49MB junk stripped' },
],
flow: [
{ n: '1', label: 'pull --ff-only' },
{ n: '2', label: 'diff 3-way', err: true },
{ n: '3', label: 'merge / ask' },
{ n: '4', label: 'push' },
{ n: '5', label: 'verify ✓' },
],
},
'ai-furniture-compositing-with-flux-kontext': {
titlebar: 'client — furniture compositing PoC',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST fal-ai/flux-pro/kontext/multi · 2 photos in' },
{ t: 'err', text: 'toDataURL: tainted canvas · may not be exported' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = same-origin paths · enhance_prompt:false · name objects' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 1 staged room out ✓ ($0.04, ~17s)' },
],
flow: [
{ n: '1', label: '2 photos in' },
{ n: '2', label: 'tainted canvas', err: true },
{ n: '3', label: 'same-origin fix' },
{ n: '4', label: 'prompt anchoring' },
{ n: '5', label: '1 room out ✓' },
],
},
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': {
titlebar: 'wp-admin — AI Remix Photo plugin',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wp plugin list · hre-ai-remix 0.0.1 → 0.0.22' },
{ t: 'err', text: '404 …/v1admin/photos — rest_url() has no trailing slash' },
{ t: 'dim', text: 'routes in is_admin() ✗ · watermark silent-fallback ✗' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "join REST with '/admin/…' · log root cause · warn admin" },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 22 releases · 58 commits · 4 were the AI ✓' },
],
flow: [
{ n: '1', label: 'PoC → plugin' },
{ n: '2', label: 'v1admin 404', err: true },
{ n: '3', label: 'silent watermark', err: true },
{ n: '4', label: 'server-side gates' },
{ n: '5', label: '0.0.22 ✓' },
],
},
'best-ai-video-generators-2026': {
titlebar: '~/blog — AI video free vs paid 2026',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'compare cloud APIs vs open weights' },
{ t: 'err', text: '"free" = 4 different deals · credits ≠ seconds' },
{ t: 'dim', text: 'Sora 2 app gone 04-26 · API sunset 09-24' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'LTX-2.3 via PinkCherry · demo clip ↓' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 11 tools · pricing checked 2026-09-11 ✓' },
],
flow: [
{ n: '1', label: 'free credits' },
{ n: '2', label: 'cloud APIs' },
{ n: '3', label: 'open weights' },
{ n: '4', label: 'LTX-2.3 test' },
{ n: '5', label: 'pick one ✓' },
],
},
'how-i-made-my-own-songs-with-suno-ai': {
titlebar: 'hoelee@studio — suno v6 · 7 songs',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'write lyrics · craft style prompt (200 chars)' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '[Verse][Chorus][Bridge] · [Whispered][Belted]' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'generate 35 takes → keep best' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'AI vocalist mispronounces 忘川 — respell test clip' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'v6: section edit · single-line swap · voices' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 songs hosted · embedded above ✓' },
],
flow: [
{ n: '1', label: 'lyrics first' },
{ n: '2', label: 'style prompt' },
{ n: '3', label: 'metatags' },
{ n: '4', label: 'iterate takes' },
{ n: '5', label: 'ship ✓' },
],
},
'passbolt-hang-three-failure-modes': {
titlebar: 'root@dsm — passbolt incident',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → 504 Gateway Timeout (every minute, worse under load)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'cron: job is still running since ... (1m elapsed)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'SMTP Setting errors: fingerprint null' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = bind-mount passbolt.php · full fingerprint · remove ssl.force' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ cron 0.4s · UI 302 · healthcheck green ✓' },
],
flow: [
{ n: '1', label: '504 hang' },
{ n: '2', label: 'fingerprint null', err: true },
{ n: '3', label: 'mount config' },
{ n: '4', label: 'redirect loop', err: true },
{ n: '5', label: 'fixed ✓' },
],
},
'unraid-stop-array-hangs-on-swapfile': {
titlebar: 'root@unraid — array stop incident',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'WebUI → Stop array · swapfile lives on /mnt/cache (btrfs RAID1)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Retry unmounting user shares… · umount: target is busy (every 5s, forever)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: '/proc/swaps lists /dev/loop0 — grep swapfile never matches' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = User Scripts: swapoff -a + losetup -j/-d at stopping_svcs · swapon at disks_mounted' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean unmount on first try · swap survives stop/start ✓' },
],
flow: [
{ n: '1', label: 'stop array' },
{ n: '2', label: 'EBUSY loop', err: true },
{ n: '3', label: 'losetup -j' },
{ n: '4', label: 'swapoff hook' },
{ n: '5', label: 'clean stop ✓' },
],
},
'the-cause-was-trim-not-the-ssds': {
titlebar: 'root@unraid — ssd pool trim watch',
lines: [
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'raw read error rate (failing now) is 19665 — sdd SMART trip' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs device stats /mnt/ssd' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = diskAutotrim="off" · remount,nodiscard · scrub' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ scrub #2: corrected 0 · counters flat · cause = queued TRIM firmware bug ✓' },
],
flow: [
{ n: '1', label: 'SMART trip' },
{ n: '2', label: 'zeros on both mirrors', err: true },
{ n: '3', label: 'queued TRIM' },
{ n: '4', label: 'autotrim off' },
{ n: '5', label: 'scrub clean ✓' },
],
},
'that-dying-ssd-was-just-a-bad-sata-cable': {
titlebar: 'root@unraid — sdd mkfs attempt',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'mkfs.btrfs -K -f /dev/sdd1' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ata7.00: WRITE FPDMA QUEUED timeout · NCQ disabled · lost async page write' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ERROR: superblock magic doesn\'t match · smartctl -H timeout' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = new SATA cable + different port · rerun the same mkfs' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean format · 8 GiB fio verify=crc32c err=0 · device stats all zero ✓' },
],
flow: [
{ n: '1', label: '"dying" SSD' },
{ n: '2', label: 'FPDMA timeouts', err: true },
{ n: '3', label: 'cable/port swap' },
{ n: '4', label: 'rerun mkfs' },
{ n: '5', label: 'clean ✓' },
],
},
};
const DEFAULT_BANNER = {
titlebar: 'root@host — shell',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'engineering · devops · self-hosting' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the full post →' },
],
flow: [
{ n: '1', label: 'start' }, { n: '2', label: 'work' }, { n: '3', label: 'ship' },
],
};
BANNERS['n8n-v1-to-v2-upgrade-gotchas'] = {
titlebar: 'root@dsm — n8n v1 → v2 migration',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker pull n8nio/n8n:2.40.1 · container up in 90s' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'v1.123.x → v2.40.1 · 17 workflows · 7 active' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Telemetry failed schema validation: executions_data_save_on_error' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Failed to start Python task runner — Python 3 missing' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Sandbox: enabled=false (DB override; env was enabled=true)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'N8N_WEBHOOK_URL · pin TASK_TIMEOUT=300 · pin compression limits' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'storage rename flagged for v3 · migrate + remount together' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 deprecations resolved · downstream workflows intact ✓' },
],
flow: [
{ n: '1', label: 'pull v2' },
{ n: '2', label: 'read boot log' },
{ n: '3', label: 'schema reject', err: true },
{ n: '4', label: 'pin defaults' },
{ n: '5', label: 'verified ✓' },
],
};
BANNERS['self-healing-digital-goods-entitlements'] = {
titlebar: 'root@dsm — entitlement lifecycle (W1W5)',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'nocodb webhook → n8n compute → alist role scopes' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'desired = union(active purchases, direct grants) · MAX expiry wins' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'shared/effective-grants.js inlined at build · 4 workflows' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'public hostname: >60s → nginx 504 → 1s retries → pool → 503' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'http://nocodb:10380 (30ms, no tunnel) → cascade gone' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W3 sweeps every 5min · W4 repairs drift 03:00 + verifies' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W5 public: CORS-locked, minimal fields, no HMAC theatre' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ repair verified · reconcile log empty when healthy ✓' },
],
flow: [
{ n: '1', label: 'grant' },
{ n: '2', label: 'expire' },
{ n: '3', label: 'drift', err: true },
{ n: '4', label: 'repair' },
{ n: '5', label: 'verify ✓' },
],
};
BANNERS['running-tts-as-a-service-with-token-sidecars'] = {
titlebar: 'root@dsm — tts service · 1 year uptime',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'reader → GET /webhook/{mtts,gtts}?pass=…&text=…&speed=…' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'azure speech F0 · google cloud cmn-CN-Wavenet-A' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'token stored in workflow → 401 after 10min (azure) / 1h (google)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: '2 cron sidecars → accesstoken.txt on shared volume' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'refresh 570s / 3500s · margin before expiry' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'map speed 550 → -20%…+150% · strip trailing newline' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '8 neural voices selected by integer index' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ secrets in zero workflows · still running ✓' },
],
flow: [
{ n: '1', label: 'webhook' },
{ n: '2', label: 'read token' },
{ n: '3', label: 'synthesize' },
{ n: '4', label: 'audio/wav' },
{ n: '5', label: '1yr ✓' },
],
};
BANNERS['self-hosted-speech-to-text-api'] = {
titlebar: 'root@gpu-pc — whisper.cpp',
lines: [
{ t: 'cmd', text: 'netstat -an | grep 20129' },
{ t: 'ok', text: 'TCP 127.0.0.1:20129 LISTENING ← only this PC' },
{ t: 'dim', text: 'iPhone · iPad · Android · work PCs ?' },
{ t: 'cmd', text: '--host 0.0.0.0 + firewall -RemoteAddress LocalSubnet' },
{ t: 'ok', text: 'TCP 0.0.0.0:20129 LISTENING ← reachable' },
{ t: 'cmd', text: 'n8n gate: Authorization header → per-device key' },
{ t: 'err', text: 'bad key → 403' },
{ t: 'hl', text: '{"text":"…"} large-v3 on RTX 3060 · 130 wpm' },
],
flow: [
{ n: '1', label: 'phone dictates' },
{ n: '2', label: 'HTTPS + key' },
{ n: '3', label: 'n8n gate' },
{ n: '4', label: 'GPU transcribe' },
{ n: '5', label: '5x typing ✓' },
],
};
BANNERS['replacing-rdpguard-with-ipban'] = {
titlebar: 'root@win11 — ipban service',
lines: [
{ t: 'cmd', text: 'sc.exe qc IPBAN' },
{ t: 'ok', text: 'START_TYPE : 2 AUTO_START ✓' },
{ t: 'dim', text: 'RdpGuard 7.8.7 — paid, closed, 3 versions behind' },
{ t: 'err', text: 'uninstall → rule rdpguard-… deleted → 12 bans LOST' },
{ t: 'cmd', text: 'ban.txt → IPBan import (12 ipv4)' },
{ t: 'ok', text: 'Updating firewall with 12 entries...' },
{ t: 'err', text: "ipban --install-service → 'Unrecognized command'" },
{ t: 'hl', text: '16 attackers blocked · zero protection gap' },
],
flow: [
{ n: '1', label: 'export bans' },
{ n: '2', label: 'sc.exe install' },
{ n: '3', label: 'whitelist LAN' },
{ n: '4', label: '16 blocked ✓' },
],
};
BANNERS['patching-workbench-26-for-mariadb'] = {
titlebar: 'root@win11 — workbench 26.7.0',
lines: [
{ t: 'cmd', text: 'workbench → mariadb 192.168.1.124:3306' },
{ t: 'err', text: 'TypeError: on_session_message() missing 1 arg' },
{ t: 'dim', text: 'the error handler crashed reporting the error' },
{ t: 'err', text: "ERROR 1193: Unknown system variable 'gtid_mode'" },
{ t: 'dim', text: 'MariaDB 10.11 → nversion 101119 · major >= 8 guard passes' },
{ t: 'cmd', text: 'patch replication.py · DbSession.py · SetupTasks.py' },
{ t: 'err', text: "ERROR 1193: 'explain_json_format_version' — again" },
{ t: 'hl', text: '4 connections · mysqlsh proved the server was fine' },
],
flow: [
{ n: '1', label: 'mysqlsh test' },
{ n: '2', label: 'fix handler' },
{ n: '3', label: '3 patches' },
{ n: '4', label: 'connected ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
if (existsSync(postPath)) {
const raw = readFileSync(postPath, 'utf8');
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const c = fm.match(/^category\s*:\s*(.+)$/m)?.[1]?.trim();
if (c) category = c.replace(/["']/g, '');
}
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// render terminal lines
function renderLines(lines) {
// group into visual lines: each entry has t + text; consecutive dim/prompt/cmd share a row
let html = '';
let i = 0;
while (i < lines.length) {
const l = lines[i];
if (l.t === 'prompt') {
// prompt may be followed by a cmd/err/ok/hl/dim on same row
const next = lines[i + 1];
const promptClass = l.text === '$' ? 'prompt' : 'prompt';
let row = `<span class="${promptClass}">${esc(l.text) || '&nbsp;'}</span>`;
if (next && next.t !== 'prompt') {
row += `<span class="${next.t}">${esc(next.text)}</span>`;
i += 1;
}
html += `<div class="line" style="margin-top:6px;">${row}</div>`;
} else {
html += `<div class="line" style="margin-top:6px;"><span class="${l.t}">${esc(l.text)}</span></div>`;
}
i += 1;
}
return html;
}
function renderFlow(flow) {
let html = '';
flow.forEach((s, idx) => {
html += `<div class="step${s.err ? ' err-step' : ''}"><span class="num">${s.n}</span>${esc(s.label)}</div>`;
if (idx < flow.length - 1) html += `<div class="arrow">→</div>`;
});
return html;
}
const cfg = BANNERS[slug] || DEFAULT_BANNER;
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
tpl = tpl
.replace('{{CATEGORY}}', esc(category))
.replace('{{SLUG}}', esc(slug))
.replace('{{LOGO_PATH}}', logoPath)
.replace('{{TITLEBAR}}', esc(cfg.titlebar))
.replace('{{TERMINAL_HTML}}', renderLines(cfg.lines))
.replace('{{FLOW_HTML}}', renderFlow(cfg.flow));
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
const htmlPath = join(ROOT, '.og', 'gen', `${slug}-banner.html`);
writeFileSync(htmlPath, tpl);
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const outPng = join(ROOT, '.og', 'gen', `${slug}-banner.png`);
try {
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1600,900 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' });
} catch (e) {
console.error('Chrome render failed:', e.message);
process.exit(1);
}
const publicDir = join(ROOT, 'public', 'banners');
mkdirSync(publicDir, { recursive: true });
const finalPng = join(publicDir, `${slug}.png`);
execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' });
console.log(`✓ Banner generated: public/banners/${slug}.png`);
console.log(` category: ${category}`);
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1600px; height: 900px; overflow: hidden; }
body {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
background: #0d1220;
position: relative;
padding: 48px 52px;
}
.glow {
position: absolute;
border-radius: 50%;
filter: blur(100px);
}
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
.topbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 26px;
}
.topbar .crumb {
display: flex; align-items: center; gap: 10px;
color: #6b7c99; font-size: 18px;
}
.topbar .crumb .pipe { color: #2a3550; }
.topbar .crumb .active { color: #e6edf3; }
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
.terminal {
position: relative;
z-index: 2;
height: 690px;
background: rgba(22, 30, 46, 0.95);
border: 1px solid #2a3550;
border-radius: 14px;
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal .titlebar {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 22px;
background: #131a2a;
border-bottom: 1px solid #2a3550;
}
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
.terminal .dot.r { background: #ff5c5c; }
.terminal .dot.y { background: #f5a524; }
.terminal .dot.g { background: #3fb950; }
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
.terminal .cmd { color: #e6edf3; }
.terminal .dim { color: #6b7c99; }
.terminal .err {
color: #ff5c5c;
background: rgba(255,92,92,0.10);
padding: 2px 10px;
border-radius: 5px;
}
.terminal .ok { color: #3fb950; }
.terminal .hl { color: #f5a524; }
.flow {
position: relative;
z-index: 2;
margin-top: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.flow .step {
flex: 1;
text-align: center;
padding: 14px 8px;
background: #161e2e;
border: 1px solid #2a3550;
border-radius: 10px;
color: #e6edf3;
font-size: 17px;
}
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
.flow .step.err-step { border-color: #ff5c5c; }
.flow .step.err-step .num { color: #ff5c5c; }
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="topbar">
<div class="crumb">
<span>blog.hoelee.com</span><span class="pipe">/</span>
<span>{{CATEGORY}}</span><span class="pipe">/</span>
<span class="active">{{SLUG}}</span>
</div>
<div class="logo"><img src="{{LOGO_PATH}}" alt=""></div>
</div>
<div class="terminal">
<div class="titlebar">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<span class="title-text">{{TITLEBAR}}</span>
</div>
<div class="body">
{{TERMINAL_HTML}}
</div>
</div>
<div class="flow">
{{FLOW_HTML}}
</div>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
# og-gen — per-post OG image generator
Generates a branded 1200×630 social-share image for a blog post, matching the
site's cobalt-blue `#295cff` branding, the `logo-square.png` mark, and the
"terminal / debugging" visual style.
## Usage
```bash
node scripts/og-gen/generate.mjs <post-slug>
```
Reads frontmatter (`title`, `category`, `tags`) from
`src/content/posts/<slug>.md`, fills `template.html`, renders with headless
Chrome, and writes `public/og/<slug>.png`.
The post's frontmatter needs `ogImage: /og/<slug>.png` so the blog serves it
(layout already renders `og:image` + width/height; PostList shows it as a
card thumbnail).
## Customizing the terminal box
Each post shows a "terminal" panel (the red error / green fix lines). Add an
entry keyed by slug in `TERMINALS` inside `generate.mjs`, using these tokens:
```html
<div class="line"><span class="prompt">$</span><span class="cmd">command</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">error line</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">fix</span><span class="fix">→ done ✓</span></div>
```
No entry → a generic default terminal.
## Requirements
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
(override with `CHROME_PATH` env var).
- Node 20+.
## Notes
- PNG (not JPG) is intentional: for flat UI/terminal art, PNG is smaller and
sharper than JPG (see commit history).
- The `logo-square.png` referenced lives at `public/logo-square.png`.
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* og-gen — generate a 1200x630 OG image for a blog post.
*
* Usage:
* node scripts/og-gen/generate.mjs <slug>
*
* Reads the post's frontmatter from src/content/posts/<slug>.md (title,
* category, tags), fills the HTML template, and renders it to
* public/og/<slug>.png via headless Chrome.
*
* Terminal content (the red "error" line + green "fix" line) is looked up in
* TERMINALS below by slug; fall back to a generic default if absent.
*
* Requirements: Google Chrome installed at the default Windows path.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const slug = process.argv[2];
if (!slug) {
console.error('Usage: node scripts/og-gen/generate.mjs <slug>');
process.exit(1);
}
// ---------- per-post terminal content (tune per post) ----------
// Each entry renders inside the terminal box. Use these tokens:
// {cmd} → a normal command line
// {err} → a red error line
// {fix} → a green fix line
const TERMINALS = {
'why-telegram-bot-notifications-die': `
<div class="line"><span class="prompt">$</span><span class="cmd">docker exec monitor python check_notify.py</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">telegram sendPhoto failed: 400 nginx/1.30.1</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">fix = IPv6 · DNS · multipart</span><span class="fix">→ delivered ✓</span></div>`,
'hello-world': `
<div class="line"><span class="prompt">$</span><span class="cmd">git init hoelee-blog · first commit</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">technical writing · self-hosting · build log</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">about → blog.hoelee.com</span></div>`,
'how-i-host-this-blog': `
<div class="line"><span class="prompt">$</span><span class="cmd">git push origin main</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">Gitea Actions → unRaid runner → nginx → Cloudflare ✓</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">git-as-CMS · zero-downtime deploy</span></div>`,
'how-i-built-the-digikedai-telegram-bot': `
<div class="line"><span class="prompt">$</span><span class="cmd">user:"does this course have a free trial?"</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">bot → yes, here's your account ✓ (24/7, no human)</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">grammY · LiteLLM · Cloudflare tunnel</span></div>`,
'authentik-major-upgrade-gotchas': `
<div class="line"><span class="prompt">$</span><span class="cmd">docker compose pull authentik-worker</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">authorization_flow not found · SSO broken</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">uptime: 2025.8 → 2026.8</span><span class="fix">fixed ✓</span></div>`,
'why-your-headless-browser-cant-scrape-everything': `
<div class="line"><span class="prompt">$</span><span class="cmd">browserless → goofish search</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">非法访问 · product list stuck "loading…"</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">carousell: server-rendered JSON</span><span class="fix">→ parsed ✓</span></div>`,
'the-nocodb-attachment-that-wouldnt-update': `
<div class="line"><span class="prompt">$</span><span class="cmd">PATCH image path → 300 rows backfill</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">path unchanged · keep the id, keep the old URL</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">strip id · replace() suffix</span><span class="fix">→ updated ✓</span></div>`,
'using-chinese-llm-apis-from-malaysia': `
<div class="line"><span class="prompt">$</span><span class="cmd">curl tokenrhythm.studio/v1/chat/completions</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">403 · mainland CN phone required</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">Alipay RMB · OpenAI-compatible key</span><span class="fix">→ ¥68 credit ✓</span></div>`,
'how-to-verify-a-hosting-provider-before-you-buy': `
<div class="line"><span class="prompt">$</span><span class="cmd">curl -s rdap.org/domain/vps.tld | jq .events</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">registration: 2026-05 · "trusted since 2012"</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">AUP grep tor · reviews · retention</span><span class="fix">→ verdict ✓</span></div>`,
'how-i-vetted-20-vps-providers-with-parallel-subagents': `
<div class="line"><span class="prompt">$</span><span class="cmd">fan-out → 3 subagents × 20 providers</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">whois · AUP · reviews · retention — in parallel</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">merge scorecard · rank · audit trail</span><span class="fix">→ verdict ✓</span></div>`,
'automating-cyberpanel-without-the-ui': `
<div class="line"><span class="prompt">$</span><span class="cmd">POST /api/verifyConnection</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">404 · API prefix dropped in v2</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">csrftoken → /verifyLogin → fetchWebsitesList</span><span class="fix">→ sites ✓</span></div>`,
'hardening-a-tor-onion-service': `
<div class="line"><span class="prompt">$</span><span class="cmd">wget -qO- ipv4.icanhazip.com</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">bind: permission denied · CapEff=0 on :80</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">internal:true · :8080 · SocksPort 0</span><span class="fix">→ zero egress ✓</span></div>`,
'syncing-a-self-improving-ai-agent-across-machines': `
<div class="line"><span class="prompt">$</span><span class="cmd">git pull --ff-only origin main</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">refusing: local divergence · never --force</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">diff -rq live skills · merge on conflict</span><span class="fix">→ synced ✓</span></div>`,
'best-ai-video-generators-2026': `
<div class="line"><span class="prompt">$</span><span class="cmd">video_gen --free --compare --2026</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">"free" = 4 deals · credits ≠ seconds · Sora 2 sunset 09-24</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">fix = LTX-2.3 test embed · pricing checked 2026-09-11</span><span class="fix">→ shipped ✓</span></div>`,
'ai-furniture-compositing-with-flux-kontext': `
<div class="line"><span class="prompt">$</span><span class="cmd">fetch fal-ai/flux-pro/kontext/multi · 2 photos in</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">toDataURL: tainted canvas · may not be exported</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">enhance_prompt:false · same-origin paths</span><span class="fix">→ 1 room out ✓</span></div>`,
'how-i-made-my-own-songs-with-suno-ai': `
<div class="line"><span class="prompt">$</span><span class="cmd">suno "indie folk · playful whistles · sprite-girl vocals"</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">lyrics + metatags → 7 songs shipped ✓</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">v6: section edits · single-line swaps · voices</span></div>`,
'passbolt-hang-three-failure-modes': `
<div class="line"><span class="prompt">$</span><span class="cmd">curl -I https://pass.hoelee.com</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">504 · ERR_TOO_MANY_REDIRECTS · fingerprint null</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">mount passbolt.php · full fingerprint</span><span class="fix">→ fixed ✓</span></div>`,
'scraping-bot-walled-marketplace-warm-browser-session': `
<div class="line"><span class="prompt">$</span><span class="cmd">CDP → shopee search "used phone" · client marketplace monitor</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">/verify captcha · empty product cards</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">warm session · 7s pacing · sweep.py</span><span class="fix">→ 20+ listings ✓</span></div>`,
'unraid-stop-array-hangs-on-swapfile': `
<div class="line"><span class="prompt">$</span><span class="cmd">unraid → stop array · swapfile on /mnt/cache</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">umount: target is busy · /proc/swaps says /dev/loop0</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">swapoff -a + losetup -j at stopping_svcs</span><span class="fix">→ clean stop ✓</span></div>`,
'when-smart-says-healthy-but-your-raid-is-corrupting-data': `
<div class="line"><span class="prompt">$</span><span class="cmd">scrub → re-read vdisk1.img</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">csum 0x8941f998 = CRC32C(zeros) · recurring</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">self-heal rewrite does NOT stick</span><span class="fix">→ replace both drives ✓</span></div>`,
'self-hosting-mem0-memory-stack': `
<div class="line"><span class="prompt">$</span><span class="cmd">curl -X POST :20015/memories · X-Api-Key</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">infer=true → LLM hop · slow write</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">infer=false · pgvector · LiteLLM gateway</span><span class="fix">→ remembers across chats ✓</span></div>`,
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': `
<div class="line"><span class="prompt">$</span><span class="cmd">wp plugin list · hre-ai-remix 0.0.1 → 0.0.22</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">404 …/wp-json/hre/v1<span class="hl">admin</span>/photos — rest_url() has no trailing slash</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">fix = '/admin/…' · 58 commits · 4 were the AI</span><span class="fix">→ shipped ✓</span></div>`,
'n8n-v1-to-v2-upgrade-gotchas': `
<div class="line"><span class="prompt">$</span><span class="cmd">pull n8nio/n8n:2.40.1 · restart · upgrade took 90s</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">telemetry schema: executions_data_save_on_error rejected</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">read the boot log · pin timeouts + limits</span><span class="fix">→ 7 fixed ✓</span></div>`,
'self-healing-digital-goods-entitlements': `
<div class="line"><span class="prompt">$</span><span class="cmd">nocodb → n8n W1W5 → alist role scopes</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">public hostname: 60s latency → nginx 504 → retry storm → 503</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">http://nocodb:10380 · 30ms · W4 repairs drift 03:00</span><span class="fix">→ self-healing ✓</span></div>`,
'running-tts-as-a-service-with-token-sidecars': `
<div class="line"><span class="prompt">$</span><span class="cmd">reading app → GET /webhook/mtts?pass=…&text=…</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">azure token expires in ~10min · google in ~1h</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">2 cron sidecars write accesstoken.txt · 570s / 3500s</span><span class="fix">→ 1 year uptime ✓</span></div>`,
'the-cause-was-trim-not-the-ssds': `
<div class="line"><span class="prompt">$</span><span class="cmd">btrfs device stats /mnt/ssd</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">diskAutotrim="off" · remount,nodiscard · scrub</span><span class="fix">→ 0 new errors ✓</span></div>`,
'that-dying-ssd-was-just-a-bad-sata-cable': `
<div class="line"><span class="prompt">$</span><span class="cmd">mkfs.btrfs -K -f /dev/sdd1</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">WRITE FPDMA QUEUED timeouts · superblock magic doesn't match</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">swap SATA cable/port · rerun mkfs</span><span class="fix">→ clean · 0 errors ✓</span></div>`,
};
TERMINALS['self-hosted-speech-to-text-api'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">whisper-server --host 0.0.0.0 --port 20129 · large-v3 · RTX 3060</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">connect ETIMEDOUT 192.168.1.123:20129 — bound to 127.0.0.1 only</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">bind 0.0.0.0 · firewall LocalSubnet · n8n key gate</span><span class="fix">→ 130 wpm ✓</span></div>`;
TERMINALS['replacing-rdpguard-with-ipban'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">ipban --install-service</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">Unrecognized command or argument '--install-service'</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">sc.exe create IPBAN type= own start= auto binPath= ...</span><span class="fix">→ AUTO_START ✓</span></div>`;
TERMINALS['patching-workbench-26-for-mariadb'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">TypeError: on_session_message() missing 1 required argument</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">select @@gtid_mode · MariaDB 10.11</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">ERROR 1193: Unknown system variable 'gtid_mode'</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">3 patches · 4 connections</span><span class="fix">→ connected ✓</span></div>`;
const DEFAULT_TERMINAL = `
<div class="line"><span class="prompt">$</span><span class="cmd">engineering · devops · self-hosting</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">read the full post →</span></div>`;
// ---------- read frontmatter from the post ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
console.error(`Post not found: ${postPath}`);
process.exit(1);
}
const raw = readFileSync(postPath, 'utf8');
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const get = (key) => {
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'm'));
if (!m) return undefined;
let v = m[1].trim();
// strip quotes
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
return v;
};
const title = get('title') || slug.replace(/-/g, ' ');
const category = get('category') || 'devops';
const tagsRaw = get('tags') || '[]';
let tags = [];
// tags is a YAML flow array: [docker, telegram, "cloudflare"]
const stripped = tagsRaw.replace(/^\[|\]$/g, '');
tags = stripped
.split(',')
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
// ---------- build template substitutions ----------
// split the long title into two balanced lines for readability
const words = title.split(' ');
const mid = Math.ceil(words.length / 2);
const line1 = words.slice(0, mid).join(' ');
const line2 = words.slice(mid).join(' ');
// escape HTML
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// title size: short titles can be larger
const titleLen = title.length;
const titleSize = titleLen > 60 ? 40 : titleLen > 40 ? 46 : 52;
const titleHtml = `${esc(line1)}<br>${esc(line2)}`;
const terminalHtml = TERMINALS[slug] || DEFAULT_TERMINAL;
const tagsHtml = tags.map((t) => `<span>#${esc(t)}</span>`).join('');
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
// the tag chip shows "<category> · <kind>"; kind is a human label for the section.
// Were KIND to be hardcoded, every non-devops post would carry a wrong label.
const KIND_BY_CATEGORY = {
engineering: 'Engineering',
devops: 'DevOps',
ai: 'AI',
web3: 'Web3',
tutorials: 'Tutorials',
'case-studies': 'Case Study',
notes: 'Notes',
};
const kind = KIND_BY_CATEGORY[category] || category;
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
tpl = tpl
.replace('{{TITLE_SIZE}}', String(titleSize))
.replace('{{LOGO_PATH}}', logoPath)
.replace('{{CATEGORY}}', esc(category === kind.toLowerCase() ? category : kind))
.replace('{{KIND}}', '')
.replace('{{TITLE_HTML}}', titleHtml)
.replace('{{TERMINAL_HTML}}', terminalHtml)
.replace('{{TAGS_HTML}}', tagsHtml);
// write temp html
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
const htmlPath = join(ROOT, '.og', 'gen', `${slug}.html`);
writeFileSync(htmlPath, tpl);
// render with headless Chrome
const chrome = process.env.CHROME_PATH ||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const outPng = join(ROOT, '.og', 'gen', `${slug}.png`);
try {
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1200,900 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' });
} catch (e) {
console.error('Chrome render failed:', e.message);
process.exit(1);
}
// move to public/og/
const publicDir = join(ROOT, 'public', 'og');
mkdirSync(publicDir, { recursive: true });
const finalPng = join(publicDir, `${slug}.png`);
sharp(outPng)
.extract({ left: 0, top: 0, width: 1200, height: 630 })
.toFile(finalPng)
.then(() => {
console.log(`✓ OG image generated: public/og/${slug}.png`);
})
.catch((e) => {
console.error('crop failed:', e.message);
process.exit(1);
});
console.log(` title: ${title}`);
console.log(` category: ${category} | tags: ${tags.join(', ')}`);
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1200px; height: 630px; overflow: hidden; }
body {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
background: #0d1220;
color: #e6edf3;
display: flex;
flex-direction: column;
position: relative;
}
.topbar {
display: flex; align-items: center; gap: 14px;
padding: 34px 48px 0;
}
.avatar {
width: 48px; height: 48px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
background: #ffffff; padding: 4px;
}
.avatar img { width: 100%; height: 100%; object-fit: contain; display: block; }
.brand {
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
font-weight: 700; font-size: 22px; color: #ffffff; letter-spacing: 0.3px;
}
.brand .domain { color: #6b7c99; font-weight: 500; font-size: 19px; }
.tag {
margin: 40px 48px 0;
display: inline-flex; align-items: center; gap: 8px;
width: fit-content; padding: 7px 16px; border-radius: 6px;
background: rgba(41, 92, 255, 0.16);
border: 1px solid rgba(41, 92, 255, 0.5);
color: #7599ff; font-size: 16px; letter-spacing: 0.8px; text-transform: uppercase;
}
.tag .dot { width: 8px; height: 8px; border-radius: 50%; background: #295cff; }
.title {
margin: 30px 48px 0;
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
font-weight: 700; font-size: {{TITLE_SIZE}}px;
line-height: 1.15; color: #ffffff; max-width: 1000px;
}
.title .accent { color: #ff5c5c; }
.terminal {
margin: 34px 48px 0;
background: #161e2e; border: 1px solid #2a3550; border-radius: 10px;
padding: 22px 28px;
font-family: "JetBrains Mono", "Fira Code", Menlo, monospace;
font-size: 21px; line-height: 1.6; max-width: 1040px;
}
.terminal .line { display: flex; gap: 12px; }
.terminal .prompt { color: #7599ff; }
.terminal .cmd { color: #e6edf3; }
.terminal .err { color: #ff5c5c; background: rgba(255, 92, 92, 0.10); padding: 1px 8px; border-radius: 4px; }
.terminal .fix { color: #3fb950; }
.footer {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 0 48px 34px;
display: flex; align-items: center; justify-content: space-between;
}
.footer .tags { color: #6b7c99; font-size: 17px; }
.footer .tags span { margin-right: 18px; color: #7599ff; }
.footer .url { color: #6b7c99; font-size: 17px; }
</style>
</head>
<body>
<div class="topbar">
<div class="avatar"><img src="{{LOGO_PATH}}" alt=""></div>
<div class="brand">Mr Hoelee <span class="domain">· blog.hoelee.com</span></div>
</div>
<div class="tag"><span class="dot"></span>{{CATEGORY}}</div>
<div class="title">{{TITLE_HTML}}</div>
<div class="terminal">
{{TERMINAL_HTML}}
</div>
<div class="footer">
<div class="tags">{{TAGS_HTML}}</div>
<div class="url">blog.hoelee.com</div>
</div>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
---
/**
* CategoryArt — terminal-style SVG illustration per category.
* Matches the banner-gen visual language (dark #0d1220 canvas, terminal
* window with traffic-light dots, JetBrains Mono, status colors) but with a
* per-category accent color, so each category is scannable at a glance.
* Inline SVG inherits the blog's self-hosted JetBrains Mono for text lines.
*/
import { categoryMeta } from '../lib/categories';
interface Props {
slug: string;
}
const { slug } = Astro.props;
const meta = categoryMeta(slug);
// line types: prompt (accent), cmd #e6edf3, ok #3fb950, hl #f5a524, dim #6b7c99
type Line = { t: 'prompt' | 'cmd' | 'ok' | 'hl' | 'dim'; c: string };
const ART: Record<string, { title: string; lines: Line[]; glyph: 'text' | 'check'; gc: string }> = {
ai: {
title: 'n8n — ai workflows',
lines: [
{ t: 'dim', c: 'telegram ⇄ llm ⇄ memory' },
{ t: 'prompt', c: '>' },
{ t: 'cmd', c: ' user: "help me book a slot"' },
{ t: 'ok', c: 'bot: done · calendar updated' },
{ t: 'hl', c: 'mem0 long-term memory · on' },
],
glyph: 'text',
gc: 'λ',
},
'case-studies': {
title: 'mr@hoelee — portfolio',
lines: [
{ t: 'dim', c: 'drwxr-xr-x hoelee projects' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' cd digikedai-bot && ship' },
{ t: 'ok', c: 'deployed · measured · written up' },
{ t: 'hl', c: '→ end-to-end build logs' },
],
glyph: 'check',
gc: '',
},
devops: {
title: 'root@homelab',
lines: [
{ t: 'dim', c: '~140 containers · 6 hosts' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' docker compose up -d' },
{ t: 'ok', c: 'traefik · pihole · n8n — Up' },
{ t: 'ok', c: 'backups 3-2-1 · verified' },
],
glyph: 'text',
gc: '$',
},
engineering: {
title: 'src — spring & wordpress',
lines: [
{ t: 'dim', c: 'Java · PHP · TypeScript' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' mvn clean package' },
{ t: 'ok', c: 'BUILD SUCCESS · 42s' },
{ t: 'hl', c: '{ "status": "200 OK" }' },
],
glyph: 'text',
gc: '</>',
},
notes: {
title: 'scratchpad — gotchas',
lines: [
{ t: 'dim', c: '3-minute reads' },
{ t: 'hl', c: 'fix · traefik forward-auth' },
{ t: 'hl', c: 'fix · cv 301 → 404 chain' },
{ t: 'dim', c: '→ lessons, fast' },
],
glyph: 'text',
gc: '!',
},
tutorials: {
title: 'learn — follow along',
lines: [
{ t: 'dim', c: 'copy · run · understand' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' npm create astro@latest' },
{ t: 'ok', c: 'step 01 · scaffolded' },
{ t: 'ok', c: 'step 02 · deployed' },
],
glyph: 'text',
gc: '>_',
},
web3: {
title: 'foundry — testnet 31337',
lines: [
{ t: 'dim', c: 'solidity · honest scope' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' forge test' },
{ t: 'ok', c: '[PASS] testMintERC721' },
{ t: 'ok', c: '[PASS] testTransferERC20' },
],
glyph: 'text',
gc: '0x',
},
};
const fallback: (typeof ART)[string] = {
title: slug,
lines: [
{ t: 'dim', c: 'mr hoelee · blog' },
{ t: 'prompt', c: '$' },
{ t: 'cmd', c: ' ls posts' },
],
glyph: 'text',
gc: '#',
};
const art = ART[slug] ?? fallback;
const accent = meta?.accent ?? '#295cff';
const LINE_COLORS: Record<Line['t'], string> = {
prompt: accent,
cmd: '#e6edf3',
ok: '#3fb950',
hl: '#f5a524',
dim: '#6b7c99',
};
---
<svg
viewBox="0 0 640 320"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label={meta ? `${meta.name.en} category` : 'Category illustration'}
focusable="false"
>
<rect width="640" height="320" fill="#0d1220" />
<circle cx="600" cy="-20" r="150" fill={accent} opacity="0.12" />
<circle cx="10" cy="330" r="130" fill="#0e94ff" opacity="0.08" />
<!-- terminal window -->
<rect x="28" y="24" width="584" height="268" rx="12" fill="#161e2e" stroke="#2a3550" stroke-width="1" />
<!-- titlebar -->
<path
d="M 28 36 a 12 12 0 0 1 12 -12 h 560 a 12 12 0 0 1 12 12 v 32 h -584 z"
fill="#131a2a"
/>
<circle cx="52" cy="46" r="6" fill="#ff5c5c" />
<circle cx="74" cy="46" r="6" fill="#f5a524" />
<circle cx="96" cy="46" r="6" fill="#3fb950" />
<text x="118" y="52" fill="#6b7c99" font-family="'JetBrains Mono', ui-monospace, monospace" font-size="15">{art.title}</text>
<!-- body lines -->
{art.lines.map((ln, i) => (
<text
x="52"
y={112 + i * 36}
fill={LINE_COLORS[ln.t]}
font-family="'JetBrains Mono', ui-monospace, monospace"
font-size="19"
>{ln.c}</text>
))}
<!-- watermark glyph -->
{art.glyph === 'text' ? (
<text
x="588"
y="262"
text-anchor="end"
fill={accent}
opacity="0.16"
font-family="'JetBrains Mono', ui-monospace, monospace"
font-weight="700"
font-size="96"
>{art.gc}</text>
) : (
<g opacity="0.22">
<circle cx="560" cy="196" r="58" fill="none" stroke={accent} stroke-width="10" />
<path d="M 536 196 l 17 17 l 32 -36" fill="none" stroke={accent} stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
</g>
)}
</svg>
+54
View File
@@ -0,0 +1,54 @@
---
import CategoryArt from './CategoryArt.astro';
import { ALL_CATEGORIES } from '../lib/categories';
interface Props {
/** Per-slug post count for the current locale. */
counts: Record<string, number>;
lang?: 'en' | 'zh';
}
const { counts, lang = 'en' } = Astro.props;
const isZh = lang === 'zh';
const label = (count: number) => {
if (isZh) return count === 0 ? '0 篇 · 敬请期待' : `${count} 篇`;
return count === 0 ? '0 posts · coming soon' : `${count} post${count === 1 ? '' : 's'}`;
};
---
<ul class="category-grid">
{ALL_CATEGORIES.map((cat) => {
const count = counts[cat.slug] ?? 0;
const href = count > 0 ? `/${isZh ? 'zh/' : ''}categories/${cat.slug}/` : null;
const cardBodyAttrs = href ? { href } : {};
const body = (
<>
<div class="art">
<CategoryArt slug={cat.slug} />
</div>
<div class="body">
<h2>
<span class="dot" style={`background:${cat.accent}`} aria-hidden="true"></span>
{cat.name[isZh ? 'zh' : 'en']}
</h2>
<p>{cat.blurb[isZh ? 'zh' : 'en']}</p>
<span class="count" class:list={{ 'is-empty': count === 0 }}>
<span class="n">{count}</span> {label(count)}
</span>
</div>
</>
);
return (
<li>
{href ? (
<a {...cardBodyAttrs} class="category-card">
{body}
</a>
) : (
<div class="category-card is-empty">{body}</div>
)}
</li>
);
})}
</ul>
+36 -11
View File
@@ -4,26 +4,51 @@ import type { CollectionEntry } from 'astro:content';
interface Props {
posts: CollectionEntry<'posts'>[];
limit?: number;
lang?: 'en' | 'zh';
}
const { posts, limit } = Astro.props;
const { posts, limit, lang = 'en' } = Astro.props;
const shown = limit ? posts.slice(0, limit) : posts;
const isZh = lang === 'zh';
const fmt = (d: Date) =>
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const updatedLabel = isZh ? '更新于' : 'Updated';
const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${cat}/`;
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.png';
---
<ul class="post-list">
{shown.map((post) => (
<li>
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
<div class="meta">
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
<span class="sep">·</span>
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
<li class="post-row">
<a class="thumb" href={`/posts/${post.slug}/`} aria-hidden="true" tabindex="-1">
<img src={og(post)} alt="" loading="lazy" width="1200" height="630" />
</a>
<div class="body">
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
<div class="meta">
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
{post.data.updatedDate && (
<>
<span class="sep">·</span>
<span class="updated" title={`${updatedLabel} ${fmt(post.data.updatedDate)}`}>
{updatedLabel} {fmt(post.data.updatedDate)}
</span>
</>
)}
<span class="sep">·</span>
<a class="tag" href={catHref(post.data.category)}>{post.data.category}</a>
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
</div>
<p>{post.data.description}</p>
</div>
<p>{post.data.description}</p>
</li>
))}
</ul>
</ul>
+201
View File
@@ -0,0 +1,201 @@
---
// Client-side search via Pagefind. The search box lives in the site header;
// this component is mounted once per page (in BaseLayout) and drives the
// overlay + results. Pagefind's index is generated post-build (see the build
// script); its UI/JS loads from /pagefind/ at runtime.
interface Props {
lang: string;
}
const { lang } = Astro.props;
const placeholder =
lang === 'zh' ? '搜索文章…' : 'Search posts…';
const noResults =
lang === 'zh' ? '没有匹配的结果。' : 'No results.';
---
<div class="search-overlay" id="search-overlay" hidden>
<div class="search-panel">
<div class="search-bar">
<input
type="search"
id="search-input"
placeholder={placeholder}
autocomplete="off"
spellcheck="false"
aria-label={placeholder}
/>
<button class="search-close" id="search-close" aria-label="Close">✕</button>
</div>
<div class="search-results" id="search-results"></div>
</div>
</div>
<style>
.search-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: color-mix(in srgb, var(--paper) 92%, transparent);
backdrop-filter: blur(4px);
padding-top: 4rem;
}
.search-panel {
max-width: var(--measure);
margin: 0 auto;
padding: 0 1.5rem;
}
.search-bar {
display: flex;
align-items: center;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.4rem 0.7rem;
}
.search-bar input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--ink);
font-family: var(--font-sans);
font-size: 1.05rem;
padding: 0.4rem 0;
}
.search-close {
background: none;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 6px;
padding: 0.2rem 0.6rem;
cursor: pointer;
font-family: var(--font-sans);
}
.search-close:hover { color: var(--brand); border-color: var(--brand); }
.search-results {
margin-top: 1rem;
max-height: 70vh;
overflow-y: auto;
}
.search-result {
display: block;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
color: var(--ink);
text-decoration: none;
}
.search-result:hover { color: var(--brand); }
.search-result h3 { margin: 0 0 0.2rem; font-size: 1.05rem; }
.search-result .excerpt { color: var(--muted); font-size: 0.88rem; margin: 0; }
.search-result mark {
background: color-mix(in srgb, var(--brand) 25%, transparent);
color: var(--ink);
border-radius: 2px;
padding: 0 0.1em;
}
</style>
<script>
interface Pagefind {
search(query: string): Promise<{ results: PagefindResult[] }>;
debouncedSearch(query: string): Promise<{ results: PagefindResult[] }>;
}
interface PagefindResult {
data(): Promise<{ url: string; meta: Record<string, string>; excerpt: string }>;
}
const overlay = document.getElementById('search-overlay') as HTMLElement;
const input = document.getElementById('search-input') as HTMLInputElement;
const results = document.getElementById('search-results') as HTMLElement;
const closeBtn = document.getElementById('search-close') as HTMLButtonElement;
let pagefind: Pagefind | undefined;
let searchButton: HTMLElement | null = null;
function open() {
overlay.hidden = false;
input.value = '';
results.innerHTML = '';
input.focus();
}
function close() {
overlay.hidden = true;
}
// The header search trigger is injected by BaseLayout's script.
const trigger = document.getElementById('search-toggle');
if (trigger) trigger.addEventListener('click', open);
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !overlay.hidden) close();
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
overlay.hidden ? open() : close();
}
});
async function loadPagefind() {
if (pagefind) return pagefind;
// Pagefind's UI/JS is copied into /pagefind/ post-build. Load it via a
// script tag (not a dynamic import) so Vite leaves the URL untouched.
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = '/pagefind/pagefind.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('pagefind load failed'));
document.head.appendChild(s);
});
const w = window as unknown as { Pagefind?: Pagefind };
pagefind = w.Pagefind;
return pagefind;
}
let timer: ReturnType<typeof setTimeout> | undefined;
input.addEventListener('input', () => {
const q = input.value.trim();
if (!q) {
results.innerHTML = '';
return;
}
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
try {
const pf = await loadPagefind();
const search = pf.debouncedSearch ? pf.debouncedSearch(q) : pf.search(q);
const res = await search;
results.innerHTML = '';
if (!res.results.length) {
const empty = document.createElement('p');
empty.className = 'excerpt';
empty.textContent = 'No results.';
results.appendChild(empty);
return;
}
for (const r of res.results.slice(0, 12)) {
const d = await r.data();
const a = document.createElement('a');
a.className = 'search-result';
a.href = d.url;
const h = document.createElement('h3');
h.textContent = d.meta.title || d.url;
const ex = document.createElement('p');
ex.className = 'excerpt';
ex.innerHTML = d.excerpt || '';
a.appendChild(h);
a.appendChild(ex);
results.appendChild(a);
}
} catch (err) {
results.innerHTML = '';
const errEl = document.createElement('p');
errEl.className = 'excerpt';
errEl.textContent = 'Search is unavailable.';
results.appendChild(errEl);
}
}, 120);
});
</script>
+4
View File
@@ -21,7 +21,11 @@ const posts = defineCollection({
// lang is NOT set in frontmatter anymore — it's derived from the folder:
// posts/hello-world.md -> en (flat)
// posts/zh/hello-world.md -> zh (subfolder)
// Explicit link to the other-language version (its full slug, e.g. "zh/how-i-host-this-blog").
// When absent, the language switch falls back to the section landing page (/ or /zh/).
translation: z.string().optional(),
ogImage: z.string().optional(),
banner: z.string().optional(),
}),
});
@@ -0,0 +1,262 @@
---
title: "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext"
description: "Compositing furniture photos into styled room scenes with fal.ai FLUX.1 Kontext — the multi-image endpoint, the tainted-canvas fix, and why prompt enhancement had to go."
pubDate: 2026-09-11
category: case-studies
tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce]
ogImage: /og/ai-furniture-compositing-with-flux-kontext.png
banner: /banners/ai-furniture-compositing-with-flux-kontext.png
---
A furniture client came to me with a problem that every small e-commerce seller
eventually hits: their catalogue photos are **isolated product shots** — a
table on white, a chair on white — but customers don't buy furniture from a
white void. They buy the *room*. Staging a real photoshoot for every product,
in every interior style, is out of the question for a one-person business.
The ask: take two product photos (a dining table, a chair) and place them
together into a believable, high-end interior scene — generated, not
photographed. This post is the story of the proof-of-concept I built to prove
that's possible, the model I chose, and the three bugs that tried to eat it.
## Why FLUX.1 Kontext — and the endpoint that matters
The obvious first instinct is a general text-to-image model: *"a dining table
and a chair in a modern living room."* That fails the moment the client says
*"no — MY table, MY chair, the exact one on my product page."* Generating a
lookalike product is worthless; the whole point is to keep the **real product
unchanged** and only change the room around it.
That's the specific problem **FLUX.1 Kontext** (via [fal.ai](https://fal.ai))
is built for: image-conditioned generation, where the reference image *anchors*
the product and the prompt describes the scene. But there's a subtlety that cost
me an afternoon: there are **two** endpoints.
| Endpoint | Reference input | Use case |
|---|---|---|
| `fal-ai/flux-pro/kontext` | `image_url` (single) | edit / re-context one image |
| `fal-ai/flux-pro/kontext/multi` | `image_urls` (array) | **combine multiple reference images** |
I needed to *fuse two separate products into one scene*, so plain `kontext`
wasn't enough — it only takes one reference image. The `multi` variant accepts
an array of reference images and is what makes "table + chair → one room"
possible. It's flagged experimental, but it's the only game in town for this.
## Calling fal.ai from a plain HTML file — no SDK, no server
For a proof-of-concept I didn't want to stand up a backend. fal.ai exposes a
plain REST queue API, so a single `index.html` with vanilla JavaScript can do
the whole job. There are three steps, not one:
```js
// 1. Submit — returns request_id + polling URLs
const submit = await fetch(
"https://queue.fal.run/fal-ai/flux-pro/kontext/multi",
{
method: "POST",
headers: {
Authorization: "Key " + FAL_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: "...",
image_urls: [tableDataUrl, chairDataUrl],
enhance_prompt: false,
}),
}
);
const { request_id, status_url, response_url } = await submit.json();
// 2. Poll status until COMPLETED (takes ~530s)
let status;
do {
await new Promise((r) => setTimeout(r, 2000));
status = (await (await fetch(status_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json()).status;
} while (status === "IN_QUEUE" || status === "IN_PROGRESS");
// 3. Fetch the result
const result = await (await fetch(response_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json();
// result.images[0].url is the generated image
```
Two things I verified early, because they make or break the "single file"
approach:
1. **CORS is open.** `queue.fal.run` returns a permissive
`access-control-allow-origin` and allows the `authorization` header, so a
browser can call it directly with no proxy. I confirmed this with a
preflight before writing any UI.
2. **Local images go in as base64 data URIs.** fal.ai accepts `data:` URIs in
`image_urls`, so I never had to upload the client's photos to a storage
bucket first — the demo reads a file, compresses it, and ships it straight
in the request body.
## The canvas compression layer
Phone photos are 510 MB. Two of those, base64-encoded, bloats the request to
unusable size and slows generation. So before submitting, the demo runs each
image through a `<canvas>` to resize and re-encode it:
```js
async function imgToDataURI(file, maxSize = 1024, quality = 0.85) {
const img = await createImageBitmap(file); // or new Image()
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const c = document.createElement("canvas");
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
return c.toDataURL("image/jpeg", quality); // ~200400 KB each
}
```
A 7 MB phone shot becomes a ~300 KB JPEG. Fast to upload, fast for the model to
process.
## Bug #1 — "Tainted canvases may not be exported"
The demo worked in my head. Then it didn't work in a browser. The moment I hit
"generate" I got:
> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.`
This is a browser security boundary, not a fal.ai problem. When you open the
page over `file://` and load a *local* image with `<img src="chair.jpg">`,
that image is an **opaque origin**. The moment it's drawn onto a canvas, the
canvas becomes *tainted*, and `toDataURL()` refuses to export it — the browser
won't let a webpage read back the pixels of a file it can't prove it's allowed
to read.
Two fixes, and I used both at different points:
1. **Inline the default images as base64 data URIs** directly in the HTML
source. Data URIs don't taint the canvas, so the demo works even when
double-clicked from disk.
2. **Serve over HTTP with same-origin relative paths** (`src="chair.jpg"`).
Once the page and images share an origin, the canvas stays clean. This is
the right answer for the real deployment — the demo now lives at a URL, not
a double-clicked file.
The general rule: **`toDataURL` fails the instant any opaque-origin image touches
the canvas.** If you're loading local files, either inline them or serve over
HTTP. There is no third way without changing browser security settings.
## Bug #2 — the API key is in the HTML
For a demo this is tolerable; for anything public it's a hole. A `Key` header in
a client-side `fetch` means the key is in the page source, readable by anyone
who presses F12. Fine for a PoC I hand to a client, unacceptable for production.
The plan for the real WordPress plugin (the next phase) is to move the key to
**server-side PHP**: the plugin's endpoint does the authenticated fal.ai call
and returns the image, while the browser only talks to the plugin's own route.
The demo proved the pipeline works; the plugin will put the secret where
secrets belong.
## Bug #3 — the output looked like "the second image"
This was the one that made me question whether `multi` even worked. I swapped in
a new table photo, generated, and the result looked almost identical to the
chair reference — as if the model had just ignored the table and copied the
chair.
Before blaming the model, I checked something falsifiable: **is `multi` even
compositing, or just echoing one input?** I compared the generated image
against both source images with a perceptual hash and mean pixel difference:
| Comparison | Mean pixel diff (0 = identical) | dHash distance |
|---|---|---|
| result vs table | 55.7 | 31 |
| result vs chair | 55.3 | 29 |
| table vs chair | 69.3 | 30 |
The result was *far* from both inputs — the endpoint genuinely composites a new
scene, not a copy. So the "looks like the chair" problem wasn't the API; it was
the **inputs and the prompt**.
Three compounding causes:
1. **The reference photos were inconsistent.** The original `chair.jpg` was a
photo of a full table-and-chair *scene*, not a clean single chair — so the
model already saw "table + chair" satisfied by one image and leaned on it.
2. **Positional prompting is unreliable.** Kontext matches images by *content*,
not array order. Saying "the first image" / "the second image" doesn't
reliably map to "the table" / "the chair". The fix is to name the objects —
*"the dining table"* and *"the chair"* — and let the model match them to the
right reference.
3. **Prompt enhancement was on.** fal.ai's `enhance_prompt` (default off, but I
had it in mind) rewrites your prompt into a richer aesthetic description —
exactly wrong when the goal is *"do not reinterpret the product."* I pinned
it to `false` so the model obeys the literal prompt instead of embellishing
it.
## The prompt that finally worked
The client's core requirement was strict: **preserve the furniture exactly** and
only invent the room. That needs a prompt that spends most of its length
*preventing* reinterpretation, not describing style:
> Create a photorealistic interior photograph using the exact dining table and
> exact chair from the reference images. Preserve both furniture pieces exactly
> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret
> either product. Keep their original geometry, proportions, construction,
> material, finish, texture, and color exactly unchanged.
>
> Place the chair naturally beside the dining table in a realistic dining
> position, slightly pulled under the table and properly aligned with it.
> Maintain realistic scale, perspective, and physical contact with the floor.
> The chair and table must look photographed together in the same real room,
> with natural contact shadows — not composited or pasted together.
>
> Preserve the true original color. Do not allow the room lighting, wall or
> floor colors, or grading to alter the furniture.
>
> Set the scene in a spacious modern living room with floor-to-ceiling windows
> and subtle warm afternoon daylight. Use restrained, neutral interior colors.
>
> Photorealistic commercial furniture photography, realistic camera
> perspective, natural proportions, high detail.
The demo ships six of these, one per scene style (modern living room, cozy
dining room, Scandinavian, wabi-sabi, and a clean studio), each differing only
in the "set the scene" paragraph.
## The result
One `index.html`, no backend, no SDK — two product photos in, a staged room
out, at **$0.04 per image** and roughly 1520 seconds of generation. The client
now has a working preview they can show *their* customers, with a scene selector
and an editable prompt, before committing to the full WordPress plugin.
The proof-of-concept answered the question it was built to answer: **yes, you
can keep the real product and only generate the room around it** — as long as
you respect the three constraints that actually matter: a clean reference image
per product, object-named (not positional) prompts, and `enhance_prompt: false`.
## What I'd do differently
- **Require clean, single-product reference images on day one.** Every failure
mode downstream — the "looks like one image" problem, the proportion drift —
traces back to inconsistent source photos. I'd ship a tiny client-facing
guide ("one product per photo, plain background, no other furniture") before
touching any model.
- **Turn `enhance_prompt` off explicitly, first.** I lost a generation to the
enhancement rewriting my careful "do not reinterpret" instructions into
exactly the opposite.
- **Validate CORS with a real preflight before building UI.** It's a two-line
`curl` and it de-risks the entire architecture decision.
---
## Want this for your product catalogue?
I build AI image pipelines, WordPress plugins, and self-hosted infrastructure
for e-commerce businesses. If you want to stage your products in styled room
scenes — or hire me for a similar AI integration — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20product%20image%20compositing)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
@@ -0,0 +1,246 @@
---
title: "Authentik 2025.8 to 2026.8: The Breakable Parts Nobody Warns You About"
description: "A year's worth of authentik major-version upgrade pain: storage mount changes, RBAC session cleanup, trusted proxies, and the authorization_flow vs authentication_flow mix-up that broke SSO."
pubDate: 2026-09-09
category: devops
tags: [authentik, sso, docker, portainer, self-hosting, oidc, upgrade]
ogImage: /og/authentik-major-upgrade-gotchas.png
banner: /banners/authentik-major-upgrade-gotchas.png
---
I run authentik as the single sign-on gate in front of my self-hosted stack —
email, dashboard, Synology apps, a remote-access outpost. For a long while it
sat on **2025.8.3**, and a year of releases piled up. This is the story of
bringing it all the way to **2026.8.1** in one sitting, and everything that
broke along the way — especially the one mistake that took Single Sign-On
completely offline and made every internal app ask for a password again.
## What is authentik, and why run it?
authentik is an open-source **identity provider (IdP)** — the thing that answers
one question over and over: *"who is this person, and are they allowed in?"*
Think of it as the front door to a whole building of apps. Instead of every
app running its own username/password screen (and its own list of bugs, its own
"forgot password" flow, its own 2FA), you make all of them ask authentik instead.
It speaks the protocols that matter for real deployments:
- **OIDC / OAuth2** — the modern single sign-on standard (what "Sign in with
Google" uses under the hood).
- **SAML** — the enterprise standard (for tools like Grafana, Jira, or any
legacy line-of-business app).
- **LDAP** — so older clients and NAS boxes that only know LDAP can join too.
- **Proxy / forward-auth** — it sits in front of an app via a reverse proxy and
gatekeeps access before a request ever reaches the app.
The practical benefits, in order of how much they actually matter:
1. **One login for everything.** Log in once, move freely between every app.
Users stop juggling a dozen passwords (and the support tickets that go with
them).
2. **One place to lock things down.** MFA, password policy, session limits, and
account recovery live in authentik once — not re-implemented per app.
3. **One place to audit.** Every login, on every app, for every user, in one
log. That's the difference between "we think nothing happened" and "we can
prove it" when a question comes up.
4. **Self-contained and self-hosted.** You own the data. No per-user SaaS fees
that scale with your headcount, no lock-in — it's AGPL, runs in Docker.
For a small business or a solo operator running twenty-something services
(email, dashboards, NAS apps, internal tools), authentik is the difference
between "every app has its own flimsy password" and "one hardened front door."
## Why upgrade at all
Version 2025.8.3 wasn't broken. But it had fallen far enough behind that a
stack of CVEs had landed in the releases after it, and I was getting ready to
do per-application branding. authentik's own policy is that you can't jump
major versions — it enforces a stepwise path. So the plan was:
```
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8
```
Six hops, one at a time, with a migration and a health-check between each.
Before touching anything, the one non-negotiable step: **back up the database**.
authentik doesn't support downgrades. If a migration half-runs, you're restoring
from dump, not rolling back an image tag.
```bash
sudo docker exec authentik-postgres pg_dump -U authentik authentik > authentik-backup.sql
```
## Pitfall 1: Portainer is the source of truth, not the compose file
My first instinct was to edit the `docker-compose.yml` on disk and `up` it.
Wrong. The stack is managed by **Portainer** (stack 143), which keeps the real
compose and the real environment variables in its own store. The `.env` on disk
was stale — its `PG_PASS` didn't match what Portainer actually ran.
The correct update path is via the Portainer API, not the filesystem:
1. Update the image tags in the compose content.
2. `docker pull` the new images *first* (so the API call doesn't time out mid-pull).
3. `docker stop` + `docker rm` the running containers (fixed `container_name`
will otherwise collide on redeploy).
4. `PUT /api/stacks/143?endpointId=2` with the new `StackFileContent` + `Env`.
I hit the colliding-container error, the pull-timeout error, and a network-attach
problem where the rebuilt `authentik-server` only joined one of its two networks
and couldn't resolve `postgres-server`. Each one is a five-minute fix once you
know what you're looking at, but together they ate the better part of the evening.
## Pitfall 2: the storage mount moved (2025.12)
Up to 2025.12, brand assets lived under `/media/` and were served at `/media/...`.
After 2025.12, the storage layout changed: files moved to a `/data/media`
structure served under a new `/files/media/public/<name>?token=...` URL with a
JWT signature. My containers were still mounting `./media:/media`, so every
logo, favicon, and background image 404'd the moment I crossed that version.
The fix is the documented migration:
```bash
mkdir -p data && mv media data/media
```
…and change the mounts to `./data:/data`. The new file backend also refuses to
work unless `/data` is an actual mount point — the earlier version of my fix
used a symlink, which the backend's `is_mount()` check rejected with
`No file management backend configured`.
## Pitfall 3: RBAC migration leaves a poisoned session table
2025.12 removed the old `authentik_core.User_groups` model in favor of the RBAC
rework. The migration ran clean, but **old sessions** still held references to
the deleted model. Result: the login page kept throwing
`LookupError: App 'authentik_core' doesn't have a 'User_groups' model`.
Not `django_session` — that was empty. The real culprit was authentik's own
`authentik_core_session` table. Clearing it (and the other session tables)
forced everyone to log in again and cleared the error:
```sql
TRUNCATE authentik_core_session;
```
One side effect worth knowing: this also invalidates every OIDC refresh token
your apps were holding. They'll bounce the user to a fresh login once, then
recover. It's a one-time annoyance, not a bug.
## Pitfall 4: trusted proxies are now opt-in (2026.8)
2026.8 tightened the default forwarded-header handling. Previously authentik
trusted all the private ranges; now it only trusts what you list explicitly.
Behind a Synology reverse proxy forwarding to `localhost`, that means:
```yaml
environment:
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: 127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128
```
Skip this and the proxy headers get rejected, which surfaces as auth failures
that look like almost anything except what they actually are.
## Pitfall 5: the one that broke SSO — authorization_flow vs authentication_flow
This was the expensive mistake, and it's the kind of thing that's easy to get
wrong if you're doing per-application branding.
A provider in authentik has **two** flow fields, and they mean different things:
```python
# "Flow used for authentication when the associated application is
# accessed by an un-authenticated user." ← the LOGIN page
authentication_flow = models.ForeignKey(...)
# "Flow used when authorizing this provider." ← the OAuth consent page
authorization_flow = models.ForeignKey(...)
```
- `authentication_flow` is the **login page**. This is where you put a
per-application flow to customise the title and background.
- `authorization_flow` is the **consent/authorize step** for a user who is
*already* logged in.
I wanted different background images per app, so I created one flow per
application and pointed the provider's `authorization_flow` at it. Instant
breakage: a user who was already authenticated still got walked through the
full identification → password → MFA flow every time they opened another app.
Single Sign-On was, effectively, gone.
The fix was a single UPDATE to put the two fields back where they belong:
```sql
UPDATE authentik_core_provider
SET authorization_flow_id = '1d85b1b1-...', -- explicit-consent flow
authentication_flow_id = 'aa##-per-app-flow'
WHERE ...;
```
And the branding itself — the per-app background and title — goes on the
**flow's own** `background` and `title` fields (writable since 2026.8), not on
the brand's domain match.
There was a second, related gotcha hiding behind this one. When I created the
18 per-app flows, they all landed with `designation=authentication`, and my
`auth.hoelee.com` brand had `flow_authentication` set to `NULL`. authentik's
fallback when a brand has no explicit auth flow is to pick the first
authentication flow **by slug, alphabetically** — which happened to be
`auth-agent`, not `default-authentication-flow`. So the root login page started
showing my agent's background image. Setting the brand's `flow_authentication`
to the real default flow fixed it.
And one more that compounds with the RBAC pitfall: creating a flow sets its
`background` but **not its stages**`stages` is read-only on the flow object.
An empty flow with no stage bindings is exactly what produced an infinite
redirect loop on the login page earlier in the migration. Stage bindings are
created separately:
```
POST /api/v3/flows/bindings/ # { target: "<flow pk>", stage: "<stage pk>", order: N }
```
## What I'd do differently
The whole ordeal came down to three preventable patterns:
1. **Never guess at a field's semantics** — I treated `authorization_flow` as
"the login flow" when the source-of-truth is the model definition, which
spells out the difference in the field's own docstring.
2. **Keep the API surface at arm's length** — API tokens kept expiring mid-run
with every server restart, so I ended up doing the critical fixes directly
against the database with `psql`. Reliable, but worth scripting *before*
the cluster is on fire, not during.
3. **One breaking change per restart** — I tried to reason about storage,
RBAC, and proxy changes all in one go. Each would have been trivial if
isolated and verified independently.
## The result
authentik now runs **2026.8.1** — current, patched, all containers healthy —
with 18 applications each showing their own background and title on the login
page, and SSO working across every subdomain. Seventeen of the applications I
use daily went from "asks me to log in again every time I switch apps" back to
"log in once, move freely."
The lesson worth carrying: a major-version upgrade on auth infrastructure is
roughly 10% "change the image tag" and 90% "the data model, storage layout,
and proxy rules all shifted underneath you." Back up, go one version at a time,
and when something behaves in a way that makes no sense, read the field name
again before you reach for another config.
---
## Want single sign-on for your business?
If you're running several internal apps — a dashboard, an email server, a help
desk, a file server — and your team is still logging into each one separately
(or reusing the same password everywhere), I set up and maintain exactly this
kind of infrastructure. I'll deploy authentik, wire it to your existing apps,
add MFA, and make sure "log in once" actually works — then leave you with a
handover so you're never locked in.
Reach me at [[email protected]](mailto:[email protected]) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,201 @@
---
title: "Automating CyberPanel Without the UI: Reverse-Engineering an Undocumented v2 API"
description: "CyberPanel v2 killed the documented JSON API and left only the Angular UI's own session+CSRF endpoints. Here's the exact auth flow and how I found the real functions behind the pages."
pubDate: 2026-09-09
category: devops
tags: [cyberpanel, api, reverse-engineering, automation, django, self-hosting, curl]
ogImage: /og/automating-cyberpanel-without-the-ui.png
banner: /banners/automating-cyberpanel-without-the-ui.png
---
I run CyberPanel 2.4.4.1 inside an Ubuntu VM on my home lab to host a handful
of small sites. It sits at `https://panel.hoelee.com`, reverse-proxied to an
internal box. Recently I wanted to script two things against it — list my
sites and delete one — without opening the browser. This is the story of
finding that the "official" API doesn't exist anymore, and reverse-engineering
the real one from what the Angular UI quietly calls under the hood.
## The problem: the documented API is gone
Every search result, the Apiary docs, and the Knowledge Base point you at the
same thing — a JSON API where you `POST` your `adminUser` and `adminPass` to
`/api/verifyConnection`:
```bash
# What the old docs tell you to do. This does NOT work on CyberPanel v2.
curl -k -X POST https://panel.hoelee.com:8090/api/verifyConnection \
-d '{"adminUser":"admin","adminPass":"...","serverUserName":"..."}'
```
On CyberPanel v2 (2.4.x), this returns nothing useful. The `/api/` prefix is
the legacy surface — it was dropped. There's no `adminUser`/`adminPass`
exchange anymore. The official documentation is simply out of date, which is
the first trap: you can spend a long time trusting docs that describe a
version you're not running.
## What I tried, and why it failed
**Attempt 1 — trust the docs.** `POST /api/verifyConnection` with credentials,
exactly as Apiary says. Result: 404. The route doesn't exist.
**Attempt 2 — hammer the obvious paths.** I tried `/api/`, `/verifyLogin` with
an `email` field, a bare `GET` on a few guesses. I got back a response I kept
misreading:
```
"This request need session."
```
That message is actually *good news* — it means the API **is** mounted and
reachable, it just refuses to talk without a valid session. The endpoint isn't
missing; the auth is different.
**Attempt 3 — the CSRF wall.** I finally sent the right fields to
`/verifyLogin` and hit a hard **403 Forbidden**. That's the part that stalls
most people: POSTs to a Django-backed panel are protected by a CSRF token, and
a bare JSON POST with no token gets dropped before your credentials are even
looked at.
The breakthrough was treating the panel not as "a thing with an API" but as
**a Django app with an Angular front-end** — and then just watching what the
front-end sends.
## The fix: the real auth flow
The whole thing is three steps, all with plain `curl` (and `-k` since the
panel uses a self-signed cert):
### 1. Grab the CSRF token from the login page
Django sets a `csrftoken` cookie on the very first GET. That cookie's value is
the token you echo back in a header on every write:
```bash
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
```
### 2. Log in to get a session cookie
`POST /verifyLogin` with a JSON body and the CSRF token in an `X-CSRFToken`
header:
```bash
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/verifyLogin" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
```
`loginStatus: 1` means the session cookie is now valid. The `Referer` header
matters more than you'd expect — some of these views check it.
### 3. Call the data endpoints
The "API" is just the same POST endpoints the Angular UI calls.
`/<module>/<function>`, same cookie jar, same `X-CSRFToken` + `Referer`:
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"page":1,"recordsToShow":50}'
```
That returned every one of my sites, with SSL status, disk usage, PHP version,
and per-site days-until-cert-expiry. The same pattern deletes a site:
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-d '{"websiteName":"blog.hoelee.com"}'
```
## The two gotchas that ate most of the time
**1. The 404 on `/api/...` was never a network problem — it was the wrong era.**
The `/api/` prefix belongs to the old API. Modern CyberPanel uses
root-relative `/<module>/<function>` with no `/api/` segment. Once I stopped
looking for an "API" and started looking for the UI's own routes, everything
snapped into place.
**2. List responses are double-encoded JSON.** `fetchWebsitesList` returns
something shaped like:
```json
{ "data": "[{\"domain\":\"...\",\"ssl\":{\"days\":17}, ...}]" }
```
The `data` key is a **JSON-encoded string**, not an array. If you pipe it
straight into `jq` and try `.[]`, you get nothing sensible. You have to unwrap
one layer first:
```bash
curl ... | jq -r .data | jq # unwrap the string, then parse again
```
## How to find the other endpoints
The function names aren't random — they map directly to Django view functions
in the CyberPanel source (`usmannasir/cyberpanel` on GitHub, `stable` branch).
The relevant files are `websiteFunctions/views.py`, `mailFunctions/views.py`,
`manageSSL/views.py`, and so on. So the workflow is:
1. Find the page in the UI (say, "Create Website").
2. Open the browser's network tab and watch what URL it POSTs to — or grep the
matching `views.py` for the view name.
3. Call the same `/<module>/<function>` path with your session + CSRF headers.
A useful distinction: a `GET /<module>/<page>` usually returns the **HTML of
the UI page**, while the **data** comes from a POST to a sibling function. If
you GET a page and get markup back, you haven't found the real endpoint yet —
keep looking for the AJAX call.
## What I'd do differently
1. **Start from the front-end, not the docs.** Watching the Angular app's
network requests would have saved me the whole "trust the obsolete Apiary
docs" detour. The UI is always the ground truth for its own API.
2. **Treat "This request need session." as a beacon, not an error.** The first
few times I read it as "wrong endpoint" when it was really "right endpoint,
wrong session state."
3. **Script the login once into a reusable helper.** The session cookie dies
on VM reboot and on expiry, so every one-off `curl` started from scratch.
A tiny wrapper that logs in, captures the cookie, and re-logins on
`"This request need session."` would have made the whole session repeatable
instead of exploratory.
## The result
I can now list, create, and delete sites on my CyberPanel panel entirely from
the shell — no browser, no GUI — and the whole authenticated API surface is
open for scripting (SSL, mail, DNS, cron). I found this by scrapping the docs,
watching the real requests, and mapping them back to their Django views, and
the `cyberpanel` module + session flow now lives in my own automation toolkit.
The broader lesson: "it's not documented" rarely means "it's not possible."
When a tool exposes a web UI, that UI is a living, exact reference for the API
— you just have to watch what it actually sends.
---
## Want your server administration scripted?
If you're clicking through a hosting panel — or worse, doing the same manual
steps across several servers — I automate exactly this kind of thing: turn
repetitive admin into a tested script or a small internal tool, wired to your
existing stack, with a handover so you're never locked in. Whether it's
CyberPanel, cPanel, Docker, or a bespoke dashboard, if it has a web UI, it can
almost certainly be driven without one.
Reach me at [[email protected]](mailto:[email protected]?subject=Scripting%20my%20server%20admin) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,257 @@
---
title: "Best AI Video Generators in 2026: Free vs Paid, and My LTX-2.3 Test"
description: "Compare the best AI video generators in 2026 — free vs paid plans, real pricing, local open-weight models, and my hands-on LTX-2.3 test."
pubDate: 2026-09-11
category: ai
tags: [ai-video, video-generation, ltx, open-weights, pricing, veo]
ogImage: /og/best-ai-video-generators-2026.png
banner: /banners/best-ai-video-generators-2026.png
---
Which AI video generator should you actually use in 2026 — and how much of it can you really get for free?
AI video has changed faster than almost any other creative tool I've used. A year ago, decent video generation meant a paid subscription and hoping for the best. Today you can generate synchronized video *and* audio with an open-weight model on your own GPU, or get genuinely useful free credits from half a dozen platforms.
But there's a catch hiding in the word "free". It means at least four different things in this market, and most articles don't tell you which one they're talking about. This guide compares the options that are actually usable in 2026, separates marketing from reality, and ends with a hands-on test: I generated a short video with the open-weight model **LTX-2.3** through PinkCherry, and I'll show you the result.
*Pricing and free quotas below were checked on September 11, 2026. This market moves monthly — always verify against the provider's current pricing page before you commit.*
## What "free" actually means in 2026
Before comparing tools, you need to decode the word. There are four different deals hiding behind "free AI video generator":
1. **Free daily or monthly credits** — you get a set number of credits that refresh. Pika (80/month), PixVerse (daily), and Kling (66/day) work this way.
2. **A one-time free trial** — you get an allocation once, and it never comes back. Runway's 125 credits are the classic example.
3. **Open weights** — the model itself is free to download and run. But "free model" doesn't mean free *compute*: you still need a GPU, electricity, and setup time.
4. **A hosted demo or community platform** — someone else runs the model for you (like PinkCherry, where I tested LTX-2.3). Quotas, queues, and availability can change without notice.
Same word, wildly different economics. A platform advertising "free AI video" is usually meaning #1 or #2. A model card advertising "free" is meaning #3 — and it quietly shifts the cost to your hardware.
## Why "model" and "platform" aren't the same thing
Another distinction that trips people up: **Seedance 2.5, Kling, and Veo are models. PixVerse, fal.ai, and Runway are platforms** — and platforms often resell each other's models.
Concrete example: Seedance 2.5 is ByteDance's model. You can use it through ByteDance's own products, through PixVerse (where it generates up to 30-second clips), or through fal.ai's API. Each route has different pricing, and none of those prices is "the" Seedance price. Kling is even messier — its own subscription copy references "Video 2.6" while third parties resell a "Kling 3.0" endpoint, so trust the model picker, not the blog posts.
This matters because comparing "Seedance vs PixVerse" is comparing a model against a platform. Compare like with like: model-to-model, platform-to-platform, and API-to-API.
## Quick comparison
| Tool | Free option | Paid option | Max clip | Native audio | Open/local | Best for |
|---|---|---|---|---|---|---|
| Pika | 80 credits/mo, 480p, watermarked | from ~$8/mo | ~510s | SFX | No | fast social clips |
| PixVerse | 90 signup + 60 daily credits | from ~$10/mo | up to 30s (Seedance 2.5) | model-dependent | No | many models in one place |
| Kling | 66 credits/day, 5s, 720p, non-commercial | from ~$710/mo | 10s | yes (paid) | No | value + motion physics |
| Seedance 2.5 (via fal.ai API) | none | ~$0.221.16/s | up to 30s | yes | No | cinematic short clips |
| Veo 3.1 (Gemini API) | none on API (limited free in Gemini app) | $0.050.40/s | 8s (+ extend) | yes | No | top quality |
| Runway | 125 one-time credits | from ~$1215/mo | 10s + extend | via bundled models | No | creative editing workflow |
| Hailuo (MiniMax) | generous daily credits, watermarked | from ~$1015/mo | 6s | no | No | free previz |
| Luma (Ray3) | ~30 gen/mo, watermarked | from ~$2530/mo | ~10s | no | No | cinematic look |
| LTX-2.3 / LTX-2.5 | open weights, free under $10M ARR | LTX API from ~$0.09/s | ~10s | yes | Yes | local / self-hosted |
| Wan 2.2 | open weights (Apache 2.0) | — | ~510s | no | Yes | local, no audio needed |
| Sora 2 | — shutting down Sept 24, 2026 | $0.100.70/s until sunset | up to 20s | yes | No | avoid — being retired |
More detail on each below. Remember: **credits are not seconds**. "2,000 credits" sounds huge until you learn one 10-second 1080p clip costs 70100 of them.
## 1. Pika — the cheapest real starting point
Pika's free Basic plan gives you **80 monthly credits** — roughly five or six 5-second 480p clips a month. It's watermarked, capped at 480p, and has no commercial use and no watermark-free download (those start on paid plans). Several older articles claim Pika's free tier is watermark-free; as of September 2026 that is *not* true.
Paid plans start around **$8/month**. For the money you get faster generation, higher resolution, Pikaffects (explosion, melt, morph effects built for virality), and keyframe control.
**Best for:** people who want the lowest-friction way to make fun short-form clips for social media. Not for serious cinematic work — raw quality trails Veo, Kling, and Seedance.
## 2. PixVerse — many models, one credit balance
PixVerse is the clearest example of the model-vs-platform distinction. One account, one credit pool, and a model selector that includes **Seedance 2.5, Seedance 2.0, Kling, Veo 3.1, and its own V6** — which makes it the easiest place to compare models side-by-side without five subscriptions.
Free tier: **90 signup credits plus 60 daily credits** — roughly a video or two a day depending on model and resolution. Paid plans start around $10/month and unlock higher resolutions and the bigger models (Seedance 2.5 itself is currently gated to paid members).
Pricing is per-second and varies wildly by model and resolution — Seedance 2.5 costs far more per second than the in-house V6. That's why comparing tools by "price per video" without naming the model and resolution is meaningless.
**Best for:** people who want to try many models in one place and compare output quality directly.
## 3. Kling — best value for motion, with a confusing tier ladder
Kling (Kuaishou) is the value leader in 2026. Its **Kling 3.0** model (released February 2026) tops the Artificial Analysis image-to-video leaderboard, with notably believable physics — cloth, water, hair.
The free tier gives **66 credits per day** (they reset every 24 hours, they don't accumulate, and the exact amount can vary by region and account age). The catch is on three levels: output is **watermarked**, **non-commercial**, and — the part most reviews bury — free users get the *older* Kling 2.1 at 5 seconds and 720p. Kling 3.0 starts on paid plans.
Paid plans list from about **$10/month** (intro promos around $7, renewal higher) for 660 credits. Watch two gotchas: subscription credits don't roll over, and failed generations still deduct credits.
**Best for:** creators who want the strongest motion quality per dollar and don't need the free tier for anything commercial.
## 4. Seedance 2.5 — powerful, but read the pricing carefully
Seedance 2.5 is ByteDance's current flagship. It does text-to-video, image-to-video, and **reference-to-video** (multiple reference images for visual consistency), with native audio and generation up to **30 seconds** — unusually long for a single clip.
There is no universal Seedance price, because the model isn't sold directly at one price. On **fal.ai**, an independent API provider, approximate standard pricing is:
- **480p: ~$0.22/second**
- **720p: ~$0.47/second**
- **1080p: ~$1.16/second** (rolled out August 2026)
A 10-second 720p clip on fal runs about **$4.70**. WaveSpeed lists 1080p around $0.90/second. On PixVerse, Seedance 2.5 is available to paid members at per-second credit rates. These are *third-party* prices — ByteDance's own consumer products price differently — but they're the realistic numbers if you're building on an API.
**Best for:** filmmakers and developers who want cinematic, consistent short clips and are comparing API economics, not consumer subscriptions.
## 5. Google Veo 3.1 — the quality crown, with three speed tiers
Most independent testing in 2026 puts **Veo 3.1** at the top for realism and prompt accuracy, and it generates synchronized audio in the same pass. Google's official API documentation lists 8-second clips at 720p, 1080p, or 4K, with extension, first/last-frame control, and up to three reference images.
The API has **no free tier** — everything is pay-per-second:
- **Veo 3.1 Lite: $0.05/s** (720p) — cheap iteration
- **Veo 3.1 Fast: $0.10/s** (720p), $0.12/s (1080p), $0.30/s (4K)
- **Veo 3.1 Standard: $0.40/s** (720p and 1080p), $0.60/s (4K)
Consumers can try Veo free inside the Gemini app on limited daily usage — the API itself is paid-only. Note that Veo 3 was deprecated on June 30, 2026; 3.1 is the current line, and Google has also previewed **Gemini Omni Flash**, a video generation *and editing* model around $0.10/s.
**Best for:** anyone who wants the best output and is fine paying per clip — and developers who want predictable per-second API costs.
## 6. Runway — a creative workflow, not just a generator
Runway has become a video *workspace*: its Gen-4.5 model plus a real editing timeline, motion brushes, inpainting, and character consistency — and it now **resells Veo 3.1, Kling, and Seedance inside the same editor**. One subscription, many models. That aggregation is the real reason to pay.
The free tier is the clearest example of "one-time trial": **125 credits, once, ever**. They don't renew, the free tier is image-to-video only with a watermark, and it can't touch Gen-4.5 or Veo.
Paid: Standard ~$1215/month for 625 credits, Pro ~$2835/month for 2,250, Max ~$7695/month. Do the credit math before buying: Gen-4.5 burns roughly 1225 credits per second, so the Standard plan's 625 credits is only about **2550 seconds** of flagship video a month.
**Best for:** creators who need to stitch clips into longer cuts, hold a consistent character, and edit — not just prompt.
## 7. Hailuo and Luma — the "worth knowing" pair
**Hailuo (MiniMax)** has the most genuinely usable free tier for evaluation: generous daily credits, watermarked, 6-second clips. Quality for realistic human motion punches well above its price. Paid from ~$1015/month; API around $0.070.08/second makes it one of the cheapest serious options for developers.
**Luma (Ray3)** is the rare tool with a real HDR and color pipeline (16-bit HDR, EXR export) aimed at actual film workflows. Free tier gives roughly 30 watermarked generations a month; paid plans sit around $2530/month — pricier than Pika or Kling, and the free allowance is small. It's the pick if you want a cinematic look and work in film-oriented formats.
## What happened to Sora 2 — and why you should care
If you've read older guides, you'll notice Sora 2 is missing from the recommendations. OpenAI **discontinued the Sora app on April 26, 2026**, and the Sora API is scheduled to **shut down on September 24, 2026**. As of this writing, you cannot buy a Sora subscription, and building on the API means migrating in days.
It's a useful lesson in how fast this market turns: a model that was "best overall" in early 2026 is gone before the year ends. When you choose a platform or model for real work, check *when* it was last updated and whether the company is still shipping — the best model in the world is useless if the API sunset is next month.
## LTX-2.3 and LTX-2.5 — the interesting open/local route
This is the part that changes the economics. **LTX** is the open-weights video line from Lightricks (now spun out as LTX). It's an audio-video foundation model: video and synchronized audio generated in a single pass — dialogue, lip-sync, ambience — which was a hosted-only capability until recently.
- **LTX-2.3** (March 2026) — the version I tested. Open weights, synchronized audio, portrait and landscape.
- **LTX-2.5** (August 11, 2026) — the current release. 22B parameters, native **multishot** generation (several connected scenes in one output, holding character, lighting, and voice across cuts), 4K HDR, a new diffusion video decoder with fewer artifacts, and a **Gemma 4** text encoder. Day-one ComfyUI support.
Licensing matters here: it's the LTX Community License, **free for commercial use if your company earns under $10M ARR** — no mandatory branding, no per-seat fees. Above that, you negotiate a paid license.
You can run it three ways: self-hosted on your own GPU (roughly 1624GB VRAM reported for reasonable speeds — check the current requirements), through ComfyUI workflows, or via the LTX API (from about **$0.09/second at 720p**).
Why this is a big deal: an open-weights model with synchronized audio changes the cost question from "what's the subscription?" to "what's my GPU budget?". The trade-off is real — setup time, hardware cost, and results that depend heavily on your workflow, quantization, and settings. But for experimentation, learning, and full control, nothing else in this list compares.
## My LTX-2.3 test
I generated this short clip with **LTX-2.3** in my own ComfyUI setup — a local install I've nicknamed **PinkCherry**. It's a real-world test, not a benchmark — no claims about speed or quality leadership here, just what the model did when I pointed it at a prompt and let it work.
<video controls playsinline preload="metadata" poster="https://content.hoelee.com/file/hoelee/video/LTX23-Demo.jpg">
<source src="https://content.hoelee.com/file/h_480/hoelee/video/LTX23-Demo.mp4" type="video/mp4" />
Your browser does not support HTML5 video.
</video>
*My LTX-2.3 demo clip, generated locally in ComfyUI with the PinkCherry workflow (hosted on content.hoelee.com).*
What made this experiment interesting wasn't that LTX beats the commercial models — on raw polish it doesn't need to, and I'm not going to claim otherwise. It's that this is an **open-weight, local-oriented model**:
- **Different cost structure.** No subscription, no per-clip API bill. The cost moves to GPU hardware, electricity, and setup time — which is a *capital* cost you can amortize, not a recurring one.
- **More control.** You can run it in ComfyUI, swap checkpoints, quantize to fit your VRAM, and fine-tune under the community license.
- **It's the model's job to evolve fast.** I tested 2.3; 2.5 shipped a month later with multishot and 4K HDR. Open weights mean you don't wait for a company to upgrade you.
The honest caveats: your results depend heavily on hardware, workflow, model version, and quantization. A hosted playground (like a Hugging Face Space or ComfyUI.cloud) is meaning #4 of "free" — great for a first taste, but queues and quotas can change, and serious local use means learning ComfyUI.
If you want to try it yourself: grab the weights from Hugging Face and a community ComfyUI pack, or use a hosted playground for a first test, then decide if local setup is worth it. For me, it was — which is exactly why this article exists.
### How I made it: the LTX-2.3 ComfyUI workflow
Here's how the test above was actually made — the workflow behind the clip. Nothing exotic: just the standard LTX-2.3 parts, all free nodes:
- **Image-to-video pipeline with a second pass.** A reference image becomes a video latent, gets sampled, then runs through a **2× spatial latent upscaler** for the final pass.
- **Separate video and audio VAEs.** LTX-2.3 generates synchronized audio in the same pass, and it needs its own audio VAE alongside the video VAE — that's the piece that makes the sound *part of* the generation instead of an afterthought.
- **Gemma 3 12B text encoder.** The LTX line uses Gemma as its language backbone, loaded with the LTX text projection.
- **Distilled checkpoint + LoRA.** The fast distilled variant with the distilled LoRA at 0.6 strength, at **24 fps and 57 second clips** — the practical sweet spot for LTX.
- **GGUF quantization + Chunk FeedForward.** The model loaded GGUF-quantized, with chunked feed-forward layers — that's how a 22B model fits on consumer VRAM.
- **Negative audio guidance (NAG).** Separate negative prompts for the video track and the audio track (e.g. "voice over, narration, off-camera speech" for audio), which keeps the generated sound clean.
- **A tiny preview VAE** for fast in-sampler previews instead of full decodes.
The prompt matters more than any single node. LTX rewards prompts that describe action over time and weave the audio layer in from the start — its own guidance (reproduced from the workflow's notes):
1. **Core actions:** describe events and actions as they occur over time.
2. **Audio:** describe sounds and dialogue needed for the scene.
3. **Reference image:** don't repeat details already present.
4. **Consistency:** avoid instructions that don't match the reference image — they degrade results.
<details>
<summary>The exact prompt from my workflow (tap to expand)</summary>
> A cinematic futuristic night scene in a dense neon-lit city alley during a heavy rainstorm. A young Asian female courier in a dark waterproof jacket, black cargo pants and a compact glowing backpack runs quickly toward the camera, water splashing from her boots with every step, loose strands of wet hair moving naturally in the wind. The shot begins behind and slightly above her as she runs through the narrow alley, then the camera smoothly tracks alongside her and arcs around to the front, revealing her focused face as she looks briefly toward the camera while continuing to run. A small sleek hovering surveillance drone follows several meters behind her, its white searchlight sweeping through the rain. Neon signs reflect vividly across the wet pavement, puddles ripple from raindrops, mist drifts through the alley, and colored light flickers across her face and clothing. The camera movement remains smooth and cinematic with realistic handheld micro-motion, shallow depth of field, natural motion blur and strong foreground-to-background parallax. The final moment shows her rushing past the camera while the drone flies overhead, leaving the camera facing the glowing rainy alley. Realistic cinematic lighting, physically believable rain and water interaction, detailed skin, fabric and wet surfaces, high environmental detail, dramatic science-fiction atmosphere. Audio: heavy rainfall, footsteps splashing through puddles, distant city traffic, subtle electrical ambience and the quiet mechanical hum of the hovering drone.
</details>
Notice the shape: the action runs top to bottom, camera moves are specified but not over-specified, and the **audio layer is spelled out inline** ("heavy rainfall, footsteps splashing through puddles...") rather than tacked on at the end. That's the LTX prompting style in a nutshell.
If you want to reproduce this setup: the community-ready LTX-2.3 ComfyUI model pack is at [huggingface.co/Kijai/LTX2.3_comfy](https://huggingface.co/Kijai/LTX2.3_comfy), with the text encoder at [huggingface.co/Comfy-Org/ltx-2](https://huggingface.co/Comfy-Org/ltx-2). The nodes I used ship with ComfyUI core and KJNodes.
## Can you make a 3-minute AI video?
The honest answer: not in one shot. Even models with 30-second generation (Seedance 2.5) or extension (Veo) are **short-clip generators**, not feature-film machines. Maximum duration is not the same as the ability to produce a polished long-form video — coherence, consistency, and pacing all degrade the longer a single generation runs.
The practical workflow everyone actually uses:
```
Script → scenes → short AI clips (1015s each) → edit → voice/music/sound → final video
```
The math is simple: **15 × 12-second clips = 180 seconds = 3 minutes.** A 3-minute YouTube video is fifteen generated clips, cut together, with audio layered on top. That's why the "max clip length" column in my table matters less than you'd think — you're going to edit regardless.
## Free vs paid: what should you choose?
There is no single winner — the right tool depends on who you are:
| You are... | Try this |
|---|---|
| Complete beginner | Pika or PixVerse free credits — learn the workflow at $0 |
| Casual creator, no commercial need | Hailuo's daily credits — best free quality |
| Budget social-media creator | Kling Standard (~$710/mo) or Pika (~$8/mo) |
| YouTube / short-form creator | Kling for clips + Runway for editing, or PixVerse for multi-model comparison |
| Business / product video | Paid tier with commercial rights — Veo (via API), Kling, or Runway |
| Developer building an app | Compare APIs: Veo Lite ($0.05/s), Seedance via fal, Kling via partners, LTX API (~$0.09/s) |
| You own a decent GPU | LTX-2.5, Wan 2.2, or HunyuanVideo 1.5 — free per clip, cost is hardware |
The four questions that decide it: **how often** do you generate, **do you need commercial use**, **do you need native audio**, and **do you already own a suitable GPU**. Answer those and the table above picks itself.
## Final thoughts
2026's AI video landscape rewards people who read the fine print. "Free" is four different deals. Credits are not seconds. Models are not platforms. And the market moves fast enough that a category leader can vanish in a year — Sora is the cautionary tale.
My practical recommendation: start with **PixVerse** (many models, one credit pool) or **Kling's daily credits** to learn what you actually like, compare against **Veo 3.1** when quality matters, and seriously consider the **LTX open-weight route** if you have any interest in local generation — my own test convinced me it's a genuinely different (and cheaper) path.
This isn't my first ride on the open-weights train — I wrote about [generating product images locally with Flux Kontext](https://blog.hoelee.com/posts/ai-furniture-compositing-with-flux-kontext/) a while back, and the same lesson applies here: open weights trade setup effort for control and long-term cost.
And when an article quotes a price — including this one — check the provider's pricing page before you rely on it. **Pricing checked September 11, 2026.**
### Sources
- [Gemini API pricing — official Veo 3.1 rates](https://ai.google.dev/gemini-api/docs/pricing)
- [Veo 3.1 docs — capabilities and lengths](https://ai.google.dev/gemini-api/docs/veo)
- [fal.ai — Seedance 2.5 endpoints and pricing](https://fal.ai/models/bytedance/seedance-2.5/text-to-video)
- [OpenAI video generation API pricing](https://developers.openai.com/)
- [Pika pricing page](https://pika.art/pricing)
- [Kling membership plans](https://kling.ai/app/membership/membership-plan)
- [PixVerse — Seedance 2.5 announcement](https://pixverse.ai/en/blog/seedance-2-5-now-available-on-pixverse)
- [Runway pricing](https://runwayml.com/pricing)
- [LTX-2.5 model card (Hugging Face)](https://huggingface.co/Lightricks/LTX-2.5)
- [LTX-2.3 model card (Hugging Face)](https://huggingface.co/Lightricks/LTX-2.3)
- [Kijai's LTX-2.3 ComfyUI model pack (Hugging Face)](https://huggingface.co/Kijai/LTX2.3_comfy)
- [LTX-2 text encoder (Hugging Face)](https://huggingface.co/Comfy-Org/ltx-2)
---
*Interested in AI video, local models, or AI-powered content automation? I build AI pipelines, bots, and self-hosted infrastructure for businesses — if you'd like to generate video at scale, self-host an open-weight model, or automate your content workflow, I'd love to talk:*
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20video%20automation)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
@@ -0,0 +1,191 @@
---
title: "Hardening a Tor Onion Service: What Actually Matters"
description: "Out of curiosity I mirrored my portfolio and git server onto the darknet. Here's the hardening that held, the parts that silently broke, and whether it's worth offering to clients."
pubDate: 2026-09-09
category: devops
tags: [tor, docker, security, self-hosting, networking]
ogImage: /og/hardening-a-tor-onion-service.png
banner: /banners/hardening-a-tor-onion-service.png
---
I got curious about the darknet. Not the marketplaces, the boring half of it: people self-hosting things the way the internet worked before registrars and cloud dashboards, running software they control at an address they own.
The way I answer a curiosity like that is to build something, so the experiment became: mirror my own sites onto Tor. My interactive portfolio lives at [me.hoelee.com](https://me.hoelee.com), and my code lives on a Gitea instance at [git.hoelee.com](https://git.hoelee.com). Both now have darknet twins:
- `hoeleegitkcng572znkbpyffppyulsdwv3aurrzlk7y7vlhknogswoqd.onion` — the Gitea mirror
- `hoeleeaiwowgndbxswegtdzoeupz7lkkechtqmurbmnpvwa4k3vyuyid.onion` — the portfolio mirror
### If you've never opened a `.onion` before
These addresses don't work in a normal browser. You need Tor Browser, which routes your traffic through the Tor network. It's a five-minute setup:
1. Download it from **[torproject.org/download](https://www.torproject.org/download/)**.
2. **Verify the signature.** The download page links the `.asc` files, and this step matters — a tampered Tor Browser is the worst possible way to lose your anonymity. Fetch the Tor Browser signing key (the fingerprint is published on the download page and in the Tor Browser manual), then check the file:
```bash
# import the signing key — see the .asc link on the download page
gpg --verify tor-browser-*.tar.xz.asc tor-browser-*.tar.xz
```
3. Extract and run it. Windows and macOS get a normal installer; on Linux, extract and run `./start-tor-browser.desktop`.
4. **Click Connect.** The default settings are fine. If you're on a censored network, choose "Configure connection" and pick a bridge (Snowflake or obfs4).
5. Wait for "Connected" and the circuit display in the top-left corner. You're on Tor.
Now paste one of the addresses above into the URL bar, exactly as written. Two things to know:
- A v3 address is **56 characters** of base32 (`az`, `27`) plus `.onion`. There is no typo correction and no search suggestion — one wrong character gives you a dead address with no explanation.
- A 16-character address (v2) won't load at all. The v2 protocol was retired in 2021.
One thing that surprises first-timers: a brand-new onion can take **1060 minutes** before the network recognises it. Tor has to publish a descriptor and get it accepted into the hash ring. An onion that doesn't load on the first try is usually just young, not broken.
While I was researching how to do this right, a lot of what I read online talked up the benefits of obfs4. What actually kept my setup safe had nothing to do with that reading.
So what does actually keep an onion service safe? I went through this properly when I set the mirrors up, and again months later when I went back to check on them. Some of the setup held up. Some of it had quietly broken. And a couple of things I believed about Docker turned out to be wrong in ways I could measure.
## Tor and onion services, in thirty seconds
Tor is an anonymity network: your traffic hops through three relays, and no single relay sees both who you are and where you're going. An onion service is the reverse direction, a server that lives *inside* the network. It gets a `.onion` address instead of a domain, visitors reach it only through Tor, and the server's real location never shows up in the connection.
Four properties come out of that, and a normal website simply can't offer them:
- **No public IP, no ports, no DNS.** Nothing for scanners to find. You can host it from a home connection behind CGNAT.
- **No middlemen.** No CDN, registrar, or platform in the path recording who visits.
- **The address itself is the access control.** People who have it can reach the site; everyone else can't even tell it exists. Add client keys on top when that isn't strict enough.
- **It keeps working when the clearnet doesn't.** If a domain gets blocked or seized, the onion is untouched, because no registry is involved.
What onion hosting suits: private file drops, portfolio and git mirrors, lawyer-to-counsel drafts, digital-goods delivery, any archive that should never appear in a search index. The people who want it are the ones for whom "nobody can even see that it exists" is the feature rather than a quirk: lawyers, auditors, freelancers sending deliverables, sources talking to journalists, and shops selling digital products over a channel their competitors can't scan.
The honest tradeoff is speed. Tor circuits are slower than a CDN, so you wouldn't put a marketing site on one. It's the right tool for the private half of the internet, not the public half.
## What actually protects the origin
Three things, and only the last one requires any work:
1. **The protocol.** Visitors never connect to your server directly. Your tor process dials out, registers the service with introduction points, and rendezvous happens inside the network. Nobody who visits gets your IP from the visit itself.
2. **Vanguards-lite.** Built into Tor since 0.4.7, this makes guard-discovery attacks (an attacker forcing circuits until they can observe your guard relay) far less practical. You get it by simply running a current Tor.
3. **Not leaking the origin through other channels.** The realistic way an onion host gets exposed is not traffic analysis. It's your own machine leaking: a clearnet service gets compromised, and the attacker just reads your onion keys off the disk. Mirroring a public site to an onion is deliberate, so content correlation doesn't bother me. What matters is that the machine holding the keys isn't also running a pile of exposed services.
## Things that quietly broke
These are the parts where re-checking my own server paid for itself.
**The app container had full internet access.** I ran a one-liner inside the container against a public IP echo service, and my home IP came back. There had been an iptables-based block for this, but the rules were gone. Host firewalls get flushed silently by interface changes, container manager restarts, platform updates. A block that nothing re-applies and nothing alarms on is not a block, it's a superstition. This convinced me to stop filtering the app's egress and remove it entirely instead (below).
**The host firewall was off.** INPUT and FORWARD policy ACCEPT, and a few dozen ports listening on all interfaces from the other services on the same machine. If any one of those gets owned, the onion keys on that disk belong to the attacker. People spend hours on Tor-specific hardening and skip this.
**The running tor didn't match its config file.** The torrc on disk said `SocksPort 0`. The process, up for days, was still listening on 127.0.0.1:9050. I had edited the file and never restarted the container. Impact here was small, since the listener was container-local. The lesson generalizes: the config file you wrote is a wish, what the process is actually doing is the truth.
**Tor was a security release behind.** Older than I'd want on a box holding sensitive keys, and my logs carried the warn-spam signatures from a relay-descriptor parsing bug the newer releases fixed. It stopped after the upgrade.
## The fixes that stayed fixed
### Zero egress, by construction
Both containers go on a Docker network with `internal: true`, and only the tor container gets a second NIC for reaching the Tor network.
```yaml
networks:
service_net:
driver: bridge
internal: true # no gateway, no masquerade, no route out
egress:
external: true # ordinary bridge with internet
```
The app has no route to anything now. Not the internet, not the LAN, not even the host, because an internal network has no gateway at all. Nothing to flush, no boot task to remember, no way for it to silently decay. If the app gets compromised, the attacker gains a socket pointing at tor and nothing else. This is the one change I'd call non-negotiable for any app I run this way.
### Non-root, and the capability surprise
The image runs as non-root by default, and I wanted the app to keep listening on port 80. The standard advice is `cap_add: NET_BIND_SERVICE`. It didn't work. The container crash-looped with:
```
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
```
The reason surprised me enough that I measured it with a probe container: `grep Cap /proc/self/status` showed `CapBnd` with bit 10 (NET_BIND_SERVICE) set, and `CapEff` at zero. Docker grants capabilities to a non-root container only in the bounding set, not the effective set, and bind() checks the effective set. A non-root process running a binary without file capabilities gets an empty effective set, full stop. So the answer is boring: run on an unprivileged port inside.
```yaml
app:
user: "1000:1000"
cap_drop: [ALL]
security_opt: [no-new-privileges]
tor:
user: "100:101"
cap_drop: [ALL]
security_opt: [no-new-privileges]
```
One line on the tor side remaps the visit:
```
HiddenServicePort 80 app:8080
```
People still land on port 80 of the onion address. Only the internal port moved.
### Healthchecks that check the right thing
The tor image ships with a healthcheck that probes the SOCKS port. I had just turned SOCKS off, so healthy became unhealthy forever. Override it with the question you actually mean: is the process alive?
```yaml
healthcheck:
test: ["CMD", "pgrep", "-x", "tor"]
```
The nginx trap was subtler. My first version used `wget --spider` against the root path. The site returned 404 (I hadn't uploaded content yet), wget exited non-zero, and the container got marked unhealthy. The check was testing the content, not the service. `nc -z 127.0.0.1 80` tests the port and nothing else.
One more thing: tor resolves its `HiddenServicePort` target at startup. If the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched four failed starts in the old logs, unnoticed, because nothing was watching. `depends_on` in the compose file fixed the ordering.
### The ownership landmine
After generating new keys I copied the key material back through a file-share mount. The next containers crash-looped:
```
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
```
Files written through the share are owned by the share user, not by the uid tor runs as. The fix is one command, runnable anywhere, including a throwaway container with the volume attached:
```bash
docker run --rm -v /path/libTor:/var/lib/tor alpine \
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
```
### Verify, don't believe
Every fix above ends with a test I can run myself. The egress one is my favorite, because the two probes together are convincing: the same wget that fails inside the app's network succeeds from tor's egress network.
```bash
# inside the app: with internal:true, even DNS should fail
wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
# same probe, on tor's egress network, as a control
wget -T 6 -qO- http://ipv4.icanhazip.com # → <your IP>, exit 0
```
If you can't exec into a container, attach a throwaway probe container to the same network. It tests the network's properties, which is the thing you actually hardened.
## Would I offer this as a service?
I keep thinking about this, because the marginal cost is close to zero: the tor containers and their isolation are already running.
Most of my hosting clients want the opposite of an onion service. They want to be found on Google. Selling someone a website that only opens in Tor Browser means selling them secrecy they probably don't need, and it means supporting their visitors through installing Tor Browser.
But there is a real sliver of a market. Lawyers exchanging drafts, auditors, people delivering digital goods, anyone sharing an archive that should never show up in a search index. For those clients the pitch writes itself: no port forwards, no domain, no logs on some platform you don't control, just an address you physically hand to the people who should have it.
There's one trick that sells better than I expected, and it ties to a question everyone asks: can you choose how the address starts? v3 onion addresses are random, but only because the keys are. You can mine them: generate keypairs until the base32 address begins with the prefix you want. Every character costs a factor of 32 in work. Community mining tools on a modern GPU check addresses in the low millions per second, which makes an 8-character prefix a day-or-a-few-days job, 9 characters a patient weeks-long one, and 10 characters a serious multi-GPU commitment. A prefix that starts with the client's brand turns an unmemorable 56-character string into something they can verify is really yours, and in a niche where trust is the entire product, that's real value. I'd mine 8 happily, 9 for a paying client, and quote 10 with a straight face only if they're renting the GPUs.
So: as a bolt-on for a handful of specific clients, yes. As a product line, no. The market is too thin to build a funnel on, and the support burden doesn't shrink with volume. Privacy consulting with an onion attached, fine. Onion hosting as a web hosting tier, someone else's problem.
## What stuck
- **Run the tests, not the config file.** In-container probes and health states are the truth; the yaml is the intention.
- **Prefer mechanisms that can't silently unwind.** `internal: true` beats an iptables boot task every time.
- **Healthchecks are cheap. They caught a crash loop in minutes** where previously nothing watched for days.
- **The boring host firewall matters more than exotic Tor hardening.** Nobody de-anonymizes you with traffic analysis if they can just walk in through an open port.
## Want one of these?
I build and run these for clients: creating an onion site, hosting and maintaining it (updates, healthchecks, monitoring), and mining branded addresses when a client wants a name instead of gobbledygook. If you need a site that exists only for the people you choose, this is exactly what it does.
- WhatsApp: [wa.me/60127972969](https://wa.me/60127972969)
- Email: [[email protected]](mailto:[email protected]?subject=Onion%20service%20setup)
- What else I do: [hoelee.com](https://hoelee.com)
+13 -3
View File
@@ -1,9 +1,11 @@
---
title: "Hello, world — about this blog"
title: "Hello, World — About This Blog"
description: "What this blog is for: technical writing, self-hosting experience, and a record of what I build and learn."
pubDate: 2026-09-06
pubDate: 2026-09-05
category: notes
tags: [intro]
ogImage: /og/hello-world.png
banner: /banners/hello-world.png
---
Welcome. This is where I write about what I build and learn — mostly
@@ -22,11 +24,19 @@ Most posts will be one of a few shapes:
- **Tutorials** — a hard problem, what I tried, and the fix.
- **Gotchas & notes** — short entries on the small things that cost me a day.
## What I write about
- **Full-stack engineering** — PHP/CodeIgniter, Java/Spring, TypeScript/React, WordPress.
- **DevOps & self-hosting** — Docker, Traefik, Cloudflare tunnels, NAS/unRaid homelabs.
- **AI & automation** — Telegram bots, n8n workflows, local LLMs, memory systems.
- **Web3** — Solidity, Foundry, ERC-20/721 (learning, honestly framed).
## About me
I'm Lee Teong Hoe (Mr Hoelee), a full-stack developer and DevOps engineer
based in Malaysia. I build web applications, self-host a ~140-container
homelab, and run an email-hosting business for Malaysian SMEs.
homelab, and run Hoelee Enterprise — a website design & development business
for Malaysian SMEs (with email hosting as a side offering, not the focus).
If you're a recruiter, a client, or a fellow builder — the posts here are my
living portfolio. Start with the [latest posts](/posts/).
@@ -0,0 +1,196 @@
---
title: "How I Built the DigiKedai Telegram AI Bot"
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
pubDate: 2026-09-06
category: case-studies
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/how-i-built-the-digikedai-telegram-bot.png
banner: /banners/how-i-built-the-digikedai-telegram-bot.png
---
Digi Kedai sells digital products — online courses, ebooks, and templates — and
every sale involves a stream of the same customer questions: *"does this course
have a free preview?", "how do I get my account?", "which package should I
buy?"*. Answering them manually doesn't scale for a one-person business. So I
built an AI customer-support bot that answers those questions around the clock
and can even hand out free-trial accounts without a human in the loop.
This is the story of how it went together — the architecture, the LLM wiring,
and the bugs that ate an afternoon each.
## Why a business needs a bot like this
Before the architecture, the *why*. A support bot isn't a gimmick — it changes
the economics of a small business:
- **Saves money on staff.** Every routine question a bot answers is one your
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
support agent for a fraction of the cost of hiring one.
- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot
replies in seconds, in their language — no queues, no "we'll get back to you".
- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*.
A "does this have a free trial?" question turns into a claimed trial account
in a couple of taps, with no human in the loop.
- **Never forgets a customer.** Long-term memory means repeat customers are
greeted like regulars, not strangers.
- **Scales with your catalogue.** Add a product and the bot already knows it —
no retraining, no new FAQ pages.
For a one-person business like Digi Kedai, that's the difference between losing
sales at 2 a.m. and closing them.
## The stack
- **TypeScript + Node 20** running as a single container on my Synology NAS.
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
no public IP, no open ports.
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
providers or add fallbacks without touching bot code.
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
## The architecture
```
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
→ Hono POST /<secret>/webhook → grammY handler
→ save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
→ LiteLLM (model alias "mem0-openai") → persist + reply
```
A few decisions worth explaining:
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
updates only land if the caller knows the secret — a cheap first line of defense
on top of Telegram's own token auth.
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
fallback behind it). The bot never needs to know which provider is actually
serving the request. I set `temperature: 0.4` and a `45s` timeout.
**The bot knows its catalogue without prices or internal paths.** A build-time
generator turns a single `catalog_sku.csv` (the source of truth) into a
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
and *nothing else*. Prices are never baked in ("check the site for the current
price"), and internal resource paths never ship to the bot image. That keeps the
bundle lean and prevents the model from leaking internal structure.
## Retrieval: how the bot actually *knows* the catalogue
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
bot retrieves first, then answers. It has a three-tier retriever:
1. **SKU token match**`/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
2. **Whole-query substring** — for short, precise queries.
3. **Per-segment matching** — splits CJK runs (stripping question particles like
有/吗/哪些) and filters English stopwords.
The top-5 matches become the "facts" injected into the system prompt, and the
model is instructed to answer *only* from what it actually retrieved — and to say
so when it finds nothing.
This matters more than it looks. A customer asking *"does CZH01 have a free
version?"* gets a correct answer about `FREECZH01` only because the retriever,
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
second. That one detail turns a "no, sorry" into the correct upsell.
## Free-trial provisioning — no human in the loop
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
*issues* them. A customer can trigger it three ways — the `/trial` command, a
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
deep link like `?start=CZH01`.
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
a customer row and a customer-product row. My existing webhooks pick that up and
provision the actual account automatically, so the bot never touches the file
server. The whole thing is idempotent: if a customer already has an account, it
reuses it instead of creating a duplicate; if the product insert fails, it rolls
back the customer row so a retry doesn't dead-end.
There's even a multi-account selector. If the same Telegram user has several
accounts, the bot lists them as inline buttons and asks which one to attach the
trial to.
## The bugs that ate an afternoon each
Shipping this was not smooth. Three debugging stories stand out:
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
server doesn't acknowledge it within a timeout window. My handler was running
long (LLM latency), so Telegram re-sent the same message — and the bot answered
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
which stops the redelivery spiral dead.
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
must be the alias, never the raw upstream name.*
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
payload" setting with empty-string template values made the extension send the
literal field *name* `m_text` as the message text — so the bot replied *"I don't
understand m_text"*. The fix was to turn that setting off and use the default
payload, which the adapter reads correctly.
The honest takeaway from all three: the failures weren't in the hard parts — the
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
details**, the boring edges where integrations actually break.
## Deterministic flows on top of the LLM
An LLM is great for open-ended questions and terrible for *state*. So the
conversational pieces that need reliability — buying, trial redemption, deep
links — are **deterministic state machines**, not prompt engineering:
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
and drives an inline keyboard (online store → admin → back), never a free-form
reply.
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
and switched the separator from `buy:SKU` to `buy-SKU`.
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
that the agent turns into *"send nothing"* — so the bot stays silent instead of
babbling when it has nothing useful to say.
## What I'd do differently
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
I never registered it with Telegram, so the in-chat command menu was empty
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
- **Design the payload contract before the channel adapter.** The WhatsApp
`m_text` bug came from trusting a customization feature I hadn't read the
contract for.
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
the redelivery loop was avoidable if I'd thought about acknowledgement windows
on day one.
## The result
One container on the NAS now answers customer questions across two channels
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
language, and issues free-trial accounts end-to-end — with a prompt suite of
~175 unit tests covering the deterministic flows, retrieval, and the payload
contracts. The source lives at
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot).
If you're building your own Telegram bot backed by an LLM, the lesson is the
boring one: the model is the easy part. The webhook lifecycle, the payload
contract, and the idempotency of your provisioning are where it actually breaks —
design those first.
---
## Want a bot like this for your business?
I build custom Telegram/WhatsApp AI bots, websites, and self-hosted
infrastructure for businesses. If a bot like this could save you time and
money — or you'd like to hire me — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected])
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
+4 -2
View File
@@ -1,9 +1,11 @@
---
title: "How I host this blog: Astro, Gitea Actions, and self-hosted CI/CD"
title: "How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD"
description: "A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front."
pubDate: 2026-09-06
pubDate: 2026-09-04
category: case-studies
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
ogImage: /og/how-i-host-this-blog.png
banner: /banners/how-i-host-this-blog.png
---
This blog is itself a project I built to demonstrate the kind of work I do.
@@ -0,0 +1,420 @@
---
title: "How I Made My Own Songs with Suno AI"
description: "I wrote the lyrics, tuned the prompts, and shipped seven songs with Suno AI — folk ballads, a kids' tune, an anime-style Heart Sutra. Full workflow + the tracks."
pubDate: 2026-09-11
category: case-studies
tags: [suno, ai-music, music, lyrics, prompting, ai]
ogImage: /og/how-i-made-my-own-songs-with-suno-ai.png
banner: /banners/how-i-made-my-own-songs-with-suno-ai.png
---
I make my own songs. Not by playing an instrument — by writing lyrics,
crafting style prompts, and letting Suno AI produce the voice, the
arrangement, and the mix. This post is the full workflow: how the current
model family works, what I actually type into the prompt boxes, and seven
finished tracks you can play right here.
## Why make your own music with AI?
Two reasons, one personal and one practical.
**Personally:** I like experimenting with creative AI. A song is a tight,
self-contained project — lyrics, style, structure, performance — that
exercises the same prompt-crafting muscles as building a bot or wiring a
pipeline. And the output is shareable in a way a config file never is.
**Practically:** this is now a client skill. Small businesses need jingles,
background music for videos, event music, and branded audio — without a
studio budget. If I can deliver a usable track from a one-page brief,
that's a service, not a party trick. Suno's current **v6** models (released
Sep 9, 2026) made this dramatically more controllable than the V3-era
version I started with in April 2024.
## The current standard: what Suno's models look like in 2026
Version confusion is the first thing to clear up, because old guides
online reference models that don't exist anymore:
| Model | When | Who gets it |
|---|---|---|
| **v6 / v6-wild** | Sep 9, 2026 | Paid plans (flagship + experimental) |
| **v6-mini** | Sep 9, 2026 | Everyone — fast, efficient, great for drafts |
| **v5.5** | Mar 26, 2026 | Paid — added Voices, Custom Models, My Taste |
| **v4.5-all** | Oct 21, 2025 | Free plan (no commercial rights, attribution required) |
| V4 / V4.5 / V5 | 20242026 | Paid plans, superseded |
The v6 family added the control features that changed how I work:
- **Plain-language section edits** — "change the chorus so it's sung by a
gospel choir" edits one section without regenerating the song.
- **Single lyric swaps** — "change 'love' to 'light'" updates one line.
- **Mashups** — combine vocals from one song, drums from another, new
lyrics, in one request.
- **Sample → isolate → rebuild** — pull a riff at 0:45, isolate the
guitar, build a beat around it.
- **Multimodal input** — start from text, audio, an image, or even a video.
My own tracks below were made with the earlier V4-era workflow (April
2025). The fundamentals — style field, lyric metatags, iteration — are
unchanged, and I note where v6 would have saved me steps.
## My workflow: lyrics first, style second
The order matters more than people think. Most beginners open Suno, paste
a genre into Simple Mode, and get a generic song. I do the opposite:
1. **Write the song as a document first** — title, structure, lyrics. The
emotion and the story come from me; Suno supplies the performance.
2. **Craft the style prompt** — genre + mood + era + instruments + vocal
persona + production, often 200+ characters, describing the *journey*
of the song, not just its genre.
3. **Add metatags to the lyrics**`[Verse]`, `[Chorus]`, `[Bridge]`,
`[Whispered]`, `[Belted]`. Without these, Suno defaults to a flat
verse-chorus-verse with no emotional arc.
4. **Generate 35 takes** per song and keep the best. Repair via
Extend/Reuse Prompt, and restate the genre in every extension because
style drifts.
5. **Iterate on pronunciation** — AI singers read phonetically, so I
respell ("through" → "thru", hyphenate unusual syllables) and always
test proper nouns in a short clip first.
The worked example below shows exactly what this looks like.
## The songs
Seven tracks, hosted on my own media subdomain (`content.hoelee.com` via
Publitio) and embedded with plain HTML5 audio. Press play:
<style>
.suno-track{margin:2.2rem 0}
.suno-track audio{width:100%;max-width:560px}
.suno-box{max-height:190px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
.suno-box p{margin:.18rem 0;opacity:.55}
.suno-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
</style>
### 1. 轮回的渡船 — The Ferry of Reincarnation
Chinese folk ballad (古风民谣). The ferryman of the River of Forgetfulness,
Meng Po's soup gone cold, a soul that refuses to reboard the wheel of
rebirth. Written as a mythic love story about choosing someone across
lifetimes. Starts hushed over sparse strings and builds to a full,
anguished chorus.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-samsara" src="https://content.hoelee.com/file/hoelee/music/%E8%BD%AE%E5%9B%9E%E7%9A%84%E6%B8%A1%E8%88%B9.mp3"></audio>
<pre id="lrc-samsara" hidden>[00:12]河岸的雾漫过第三千个秋
[00:18]我的桨声碎在无人渡口
[00:26]忘川水打湿褪色的袖
[00:30]你背影是前世未燃尽的篝火
[00:33]佛说众生如萍聚散无由
[00:39]我却数遍每一颗星斗
[00:46]等残月照亮你回眸——
[00:53]偏偏人间雪,落满我舟头
[01:04]我摇着轮回的船,载不动红尘重如山
[01:10]孟婆的汤冷了又添,你宁化涟漪不成全
[01:20]若执念是穿心的箭,刺透因果的链
[01:27]我愿在彼岸花凋谢前 再为你搁浅
[01:34]那夜你魂在风里轻轻叹
[01:40]掌心符咒烫穿我掌纹的茧
[01:46]奈何桥断成半截诗篇
[01:53]我偷改命簿只换你半句谎言
[02:00]你吻过的铜铃锈在桅杆
[02:07]风一吹响了三生冬夏
[02:14]余生的河灯,照不亮对岸
[02:20]我摇着轮回的船,渡不完哀愁的深浅
[02:27]你眼泪凝成琥珀的盐,埋进我骨骼作谶言
[02:33]若爱是焚不尽的经卷,灰烬里写永远
[02:41]我跪在忘川最痛的流域,求一次擦肩
[03:00]梵音绕啊绕啊绕不过执念
[03:06]佛珠断啊断啊断在你眉间
[03:14]船沉时,天地裂开一道缝
[03:20]来世你为青山,我为雪
[03:27]轮回的渡船,碎成烟
[03:34]你是我 永世不靠岸的劫</pre>
</figure>
### 2. Happy Way to School
English children's song — the original prompt called for a "fizzy,
orange-soda indie folk" sound: acoustic guitar, playful whistling, and
bright kid-friendly vocals. It's the walk-to-school ritual turned into a
tiny anthem: ponytails bouncing, kittens at the café, friends in step.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-school" src="https://content.hoelee.com/file/hoelee/music/Happy-Way-to-School.mp3"></audio>
<pre id="lrc-school" hidden>[00:07.00]A little girl with a heart so bright,
[00:12.00]Skips down the street in morning light,
[00:16.00]Her smile's like sunshine in the air,
[00:20.00]A joyful moment everywhere.
[00:24.00]The café's window's full of cheer,
[00:28.00]Curious kittens drawing near,
[00:32.00]Their eyes meet hers, a happy sight,
[00:35.00]They wave her off in pure delight.
[00:39.00]Hand in hand with friends so sweet,
[00:43.00]They skip along the lively street,
[00:47.00]Ponytails bounce with every move,
[00:51.00]The world feels warm, a groove to prove.
[00:55.00]School is waiting, full of dreams,
[00:59.00]A place where friendship always beams,
[01:02.00]With every step, they laugh and play,
[01:08.00]The start of a beautiful day.
[01:17.00]Together they walk, hearts in sync,
[01:21.00]The world so bright, as if they think,
[01:25.00]No better way to face the day,
[01:28.00]In friendship's glow, they'll always stay.
[01:32.00]A happy way to school they go,
[01:35.00]Through the morning's gentle glow,
[01:38.00]With every step, the bond is clear,
[01:43.00]They'll cherish this moment year by year.</pre>
</figure>
### 3. 新靓 — Heart Sutra, Meditative Beats
The 般若波罗蜜多心经 (Heart Sutra) chanted over rhythmic, meditative
production — the brief was "meditation depth + groove energy". 观自在菩萨
opening, the full 色不異空 passage, ending on 揭諦揭諦. An attempt to make
ancient scripture something you can actually move to.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-xinliang" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E9%9D%93.mp3"></audio>
<pre id="lrc-xinliang" hidden>[00:00.00]拥凝练韵文引导心灵净化,
[00:06.43]在跃动节拍与禅意氛围间形成张力,
[00:12.87]达成冥想深度与律动活力的独特平衡随至…
[00:19.31]如梦如幻… 心境无常…
[00:25.74]众生皆空,见空即见真!
[00:32.18]觀自在菩薩,行深般若波羅密多時,
[00:38.62]照見五蘊皆空度一切苦厄,
[00:45.06]色不異空,空不異色,
[00:51.49]即是空,空即是色,
[00:57.93]受想行識亦復如是。
[01:04.37]色不異空,空不異色,
[01:10.80]受想行識亦復如是,
[01:17.24]般若波羅密多,無所畏懼,
[01:23.68]心無罣礙,涅槃即是此。
[01:30.12]舍利子,諸法空相,
[01:36.55]不生不滅,不垢不淨,
[01:42.99]無增無減,無老死,
[01:49.43]無苦集滅道,無智亦無得。
[01:55.87]色不異空,空不異色,
[02:02.30]受想行識亦復如是,
[02:08.74]般若波羅密多,無所畏懼,
[02:15.18]心無罣礙,涅槃即是此。
[02:21.61]三世諸佛,依般若波羅密多,
[02:28.05]得阿耨多羅三藐三菩提,
[02:34.49]般若波羅密多!
[02:40.93]是大神咒,是真實不虛!
[02:47.36]揭諦揭諦,波羅揭諦,
[02:53.80]波羅僧揭諦,菩提薩婆訶,
[03:00.24]心無罣礙,遠離顛倒夢想!</pre>
</figure>
### 4. 新劲 — Fresh Energy
A harder-edged companion to 新靓 — same meditative lineage, more percussive
drive. No lyric sheet on this one; it lives as a vibe piece.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E5%8A%B2.mp3"></audio>
</figure>
### 5. 泡泡星球漫游记 — Bubble Planet Adventure
Kids' indie folk, and the one with the most documented backstory — the
full original brief, style prompt, and lyric sheet are in the worked
example below. Bubble planets, candy fountains, a galactic playground.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-bubble" src="https://content.hoelee.com/file/hoelee/music/%E6%B3%A1%E6%B3%A1%E6%98%9F%E7%90%83%E6%BC%AB%E6%B8%B8%E8%AE%B0.mp3"></audio>
<pre id="lrc-bubble" hidden>[00:09.00]彩云兜着阳光转圈圈
[00:15.00]贝壳装满彩虹的碎片
[00:19.00]跳进橘子汽水的夏天
[00:24.00]海鸥掠过浪花的琴键
[00:48.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[00:53.00]尾巴挂着星星的秋千 (晃呀晃呀)
[00:57.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:02.00]魔法地图画满冒险线 (转个圈圈)
[01:08.00]橡皮艇划开银河水面
[01:12.00]萤火虫点亮薄荷月圆
[01:17.00]棉花糖云朵蓬松柔软
[01:20.00]流星滑梯通向我窗前
[01:27.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[01:31.00]尾巴挂着星星的秋千 (晃呀晃呀)
[01:35.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:40.00]魔法地图画满冒险线 (转个圈圈)
[02:05.00]水晶风筝追着蝴蝶结
[02:11.00]跳跳糖在舌尖开派对
[02:14.00]彩虹滑板穿过梧桐叶
[02:20.00]月亮船摇着光的湖水
[02:24.00]每个气泡都是新世界
[02:29.00]装得下所有奇妙遇见
[02:34.00]在泡泡星球蹦跳向前
[02:38.00]明天又是崭新的乐园</pre>
</figure>
### 6. 日漫心经 — Heart Sutra, Anime Style
The same classical text, reimagined with an anime-inspired sound palette —
big chorus energy, dramatic key lifts, the emotional grammar of
contemporary J-pop openings.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%97%A5%E6%BC%AB%E5%BF%83%E7%BB%8F.mp3"></audio>
</figure>
### 7. 未接来电 Unread Heartbeats (v2)
A mandopop ballad about that one missed call: the dial tone as a heartbeat,
the read receipts that stay unread. v2 is the final master — tighter
arrangement, cleaner vocal mix. (The original take stays in my archive.)
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%9C%AA%E6%8E%A5%E6%9D%A5%E7%94%B5-Unread-Heartbeats-v2.mp3"></audio>
</figure>
<script>
// suno-track: sync LRC lyrics with <audio> playback (no dependencies)
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
const raw = document.getElementById(audio.dataset.lrc).textContent;
const lines = raw.split('\n').map((l) => {
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
}).filter(Boolean);
const box = document.createElement('div');
box.className = 'suno-box';
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
audio.parentNode.insertBefore(box, audio.nextSibling);
let cur = -1;
audio.addEventListener('timeupdate', () => {
const now = audio.currentTime;
let i = lines.findIndex((l) => l.t > now) - 1;
if (i < 0) i = lines.length - 1;
if (i !== cur) {
if (cur >= 0) box.children[cur].classList.remove('on');
cur = i;
if (cur >= 0) { box.children[cur].classList.add('on'); box.children[cur].scrollIntoView({ block: 'nearest' }); }
}
});
});
</script>
## Worked example: Bubble Planet Adventure, from brief to track
This is the full chain for 泡泡星球漫游记 — the only one I kept every
scrap of paperwork for. It shows the workflow above in concrete form.
**Step 1 — the concept brief (my notes, in Chinese):**
> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感
> 人声选择「精灵少女」音色库,自带俏皮气声
> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象
> 副歌加入反向混响(reverse reverb)增强魔法感
**Step 2 — the English style prompt (what actually went into Suno):**
```text
Indie folk vibe with acoustic guitar and playful whistles, fizzy like
orange soda, featuring sprite-girl vocals and clinking glass bottle
beats, reverse reverb brings dreamlike magic
```
Four decisions compressed into one line: the *instrument* (acoustic
guitar + whistles), the *feel* (fizzy like orange soda), the *voice*
(sprite-girl with airy delivery), and the *production trick* (reverse
reverb on the chorus for the magic feel).
**Step 3 — the lyric sheet with metatags:**
```text
[Intro][Whistling][Indie][Girl]
彩云兜着阳光转圈圈
贝壳装满彩虹的碎片
跳进橘子汽水的夏天
海鸥掠过浪花的琴键
[Chorus][Indie Folk][Reverse Reverb][Whistling]
泡泡载着梦飞向屋檐(飞呀飞呀)
尾巴挂着星星的秋千(晃呀晃呀)
鲸鱼喷出糖果的喷泉(甜到脚尖)
魔法地图画满冒险线(转个圈圈)
[Verse][Indie][Girl]
橡皮艇划开银河水面
萤火虫点亮薄荷月圆
棉花糖云朵蓬松柔软
流星滑梯通向我窗前
[Chorus]… (repeat)
[Interlude]
[Bridge][Fantasy Pop][Giggles]
水晶风筝追着蝴蝶结
跳跳糖在舌尖开派对
彩虹滑板穿过梧桐叶
月亮船摇着光的湖水
[Outro][Indie Folk][Girl]
每个气泡都是新世界
装得下所有奇妙遇见
在泡泡星球蹦跳向前
明天又是崭新的乐园
[Fade to End]
```
Note what the metatags enforce that a bare lyric sheet wouldn't:
section *labels* (`[Bridge]`, `[Outro]`), *delivery* (`[Whistling]`,
`[Giggles]`), a *production change* mid-song (`[Reverse Reverb]` only on
choruses), and a stylistic shift in the bridge (`[Fantasy Pop]`). The
fill-in parentheticals — 飞呀飞呀, 晃呀晃呀 — tell the singer exactly
how to ornament each line.
**Step 4 — generate, listen, repeat.** 35 takes per section, keep the
best, extend the chorus that landed. The final track plays above.
## What I'd do differently with v6
If I made these today, four things change:
1. **Section edits instead of full rerolls.** 轮回的渡船's bridge took six
generations to nail. With v6 I'd say *"make the bridge slower and
sparser, just voice and a plucked guzheng"* and keep the rest intact.
2. **Single-line lyric swaps.** The 忘川 couplet went through four wordings;
v6 edits one line without rebuilding the song.
3. **Voices / custom models.** v5.5's Voices would let me keep one
consistent singer across all seven tracks instead of seven different
AI vocalists.
4. **Stems and Studio.** On the Premier plan, Studio (a browser DAW with
multitrack + MIDI export) means fixing a vocal artifact instead of
regenerating around it.
The craft layer — decent lyrics, an emotional arc, honest metatags —
carries straight across model versions. The models get more controllable;
the songwriting stays the job.
## The result
Seven original songs, written and produced with Suno AI, hosted on my own
infrastructure (`content.hoelee.com`), embedded here with plain HTML5 audio
and synced lyrics — no music-studio budget, no booking a vocalist, no
session musician. The whole catalog lives in one folder on my NAS,
versioned like any other project.
If you're curious about AI music, start with your own lyrics and an honest
style sentence — genre, feel, voice, one production trick — then iterate
until one take surprises you. That's the whole craft.
---
## Want original music — or AI-powered anything — for your business?
I build websites, Telegram/WhatsApp bots, and self-hosted infrastructure,
and I produce original AI-assisted music for brands that want a jingle,
background tracks, or event audio without a studio budget. There's no
session musician to book and no licence maze — you get an original track
you can actually use. If that sounds useful, I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=Music%20for%20my%20business)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
@@ -0,0 +1,163 @@
---
title: "How I Vetted 20 VPS Providers in Three Hours with Parallel Subagents"
description: "A case study in orchestrating parallel AI subagents to run due diligence on 20 hosting providers — WHOIS, AUP, and reputation checks — collapsing a multi-hour research task into a structured, verifiable vendor scorecard."
pubDate: 2026-09-09
category: case-studies
tags: [ai-orchestration, subagents, due-diligence, hosting, devops]
ogImage: /og/how-i-vetted-20-vps-providers-with-parallel-subagents.png
banner: /banners/how-i-vetted-20-vps-providers-with-parallel-subagents.png
---
## Why it matters
Choosing a hosting provider is a bet you place with a credit card and a DNS
change. Get it wrong and "guaranteed uptime" becomes a suspension email at 2 AM,
or "privacy-friendly" becomes a log-retention clause you never read.
The problem isn't a lack of information — it's that the information is scattered
across a dozen pages per provider (WHOIS records, acceptable-use policies,
privacy policies, pricing pages, and third-party review sites), and checking them
manually is slow, boring, and error-prone. One provider is five tabs. Twenty
providers is a hundred tabs and an afternoon you don't get back.
This is the story of how I collapsed that afternoon into about three hours — not
by working faster, but by orchestrating a small team of AI subagents to run the
boring part in parallel, and scoring everything against one checklist.
## The problem: verifying claims I couldn't take on faith
I needed to shortlist providers for a project with hard requirements: specific
jurisdictions, payment methods, and traffic terms. None of those are written
honestly on a homepage. They're written honestly in the boring documents — the
WHOIS record that shows a domain is four months old, the AUP that quietly bans
the exact service you wanted to run, the privacy policy that admits to log
retention.
The hard part is that verifying one provider means reading five documents that
disagree with each other. The marketing says "since 2012"; the WHOIS says "this
April." The features page says "all traffic allowed"; the AUP says "Tor relays
prohibited." One provider is a fact-checking exercise. Twenty is a research
project.
## What I tried first: one agent, one big loop
My first instinct was the obvious one — a single assistant that works through the
list, provider by provider, fetching each document, taking notes, moving on.
It worked. It was also the wrong tool for the shape of the job. The work is
*embarrassingly parallel*: provider #7's WHOIS lookup has nothing to do with
provider #3's privacy policy. Running them one after another meant the total time
was the sum of every fetch, and — more importantly — the context window filled
with half-finished notes from providers I'd already moved past. By provider
eight or nine, early findings were getting crowded out by later ones.
The lesson: a task that's a flat loop over independent items isn't a reasoning
problem, it's a fan-out problem. One long context is the wrong container for it.
## The fix: fan out with parallel subagents, then score once
The structure that worked was three layers:
**1. A checklist that doesn't care which provider it's pointed at.** Before
spawning anything, I wrote down exactly what "verified" means per provider:
- domain registration date vs. the "since" claim
- acceptable-use policy, searched for the specific service I cared about
- privacy policy, read for the retention clause
- third-party reputation (Trustpilot trend, not average; community mentions)
- traffic terms ("unmetered" vs. a metered cap)
That checklist was the contract. Every subagent got the same one, plus a list of
providers to run it against.
**2. Parallel subagents, one per batch of providers.** I split the pool into
clusters and handed each cluster to its own subagent. Each one worked in
isolation, with its own context and its own set of fetches, and returned a
structured fact sheet per provider — not a paragraph, but fields I could drop
straight into a scorecard.
The key here is that the subagents don't know about each other. That's the
point: nothing from provider #1 has to share context space with provider #14.
Each returns a self-contained result.
**3. A single scoring pass, done by me, not delegated.** The subagents produced
findings; I did the judgment. The moment you let a subagent both *gather* the
facts and *rank* the providers, you lose the audit trail — you get a verdict
without the evidence behind it. Keeping scoring central means I can always say
*why* something ranked where it did, and point at the exact WHOIS record or AUP
line that drove it.
This mirrors a pattern I'd use for any code review or refactor: parallelize the
mechanical collection, centralize the decisions.
### What the orchestration actually looked like
Roughly, per batch:
```text
subagent → "here's the checklist, here are your 5 providers"
→ per provider: fetch WHOIS, AUP, privacy policy, pricing, reviews
→ return { domain_age, aup_flags[], retention, reputations, traffic }
me → merge into one scorecard, apply the checklist, rank, write up
```
Three subagents ran side by side. The whole pass — twenty providers, five
documents each, one hundred-ish fetches — landed in the time it would have taken
me to do two or three providers carefully by hand.
## What the verification actually caught
The scorecard surfaced real problems that a homepage never would have:
- **A provider whose "trusted since 2012" claim was younger than the domain.**
WHOIS said the domain was registered that same year — a four-figure "years in
business" claim on a domain months old. That's either a re-branded shell or a
lie, and either way it downgraded every other claim on the page in my eyes.
- **Two providers whose AUP banned the exact service I wanted to run.** One
listed "TOR nodes" and "anonymizing services" in its prohibited-activity
clause; another banned "reverse proxies" and "tunnels." Both still advertised
the opposite on their features pages. Ten minutes of `Ctrl-F` on the AUP is all
it took to rule them out — but only once I *knew to check the AUP* instead of
the features page.
- **A provider that looked cheap until I read the traffic terms.** "Unlimited" on
a plan with a metered 1 TB cap is marketing. For a relay that both receives and
forwards traffic, the real cost doubles — the "cheap" option wasn't.
The pattern across all of them: the disqualifying information was never hidden.
It was *public*, sitting in a document the provider is legally required to
publish. The skill isn't secret access — it's knowing which document to read and
checking it against the marketing.
## What I'd do differently
The subagent hand-off worked, but it was blunt. Next time I'd give each subagent
the *exact* fields to return up front — a strict output schema — rather than a
prose summary I then have to re-parse. Structured output means the scorecard is
built by the time the last subagent returns, with no re-reading.
I'd also pin the "is this claim independently verifiable?" test earlier. Most of
the red flags weren't a provider lying outright; they were a claim I couldn't
check against any public record. Treat "unverifiable" as its own signal, and the
shortlist shrinks fast.
## The result
~20 providers audited across ~100 document fetches, three subagents running in
parallel, in the time a careful manual pass would have spent on two providers.
Every ranking in the final scorecard traces back to a specific public record —
a WHOIS date, an AUP line, a retention clause — not a vibe.
---
## Want this for your business?
Choosing a vendor is the same shape of problem whether it's a VPS, an API
gateway, or a payroll provider: verify the claims you're relying on *before* you
sign, against public records that can't be edited by marketing. If you've got a
shortlist of vendors or tools and you want a structured, evidence-backed
evaluation before you commit — I can run the due-diligence pass and hand you a
scorecard, not a hunch.
[WhatsApp me](https://wa.me/60127972969) or [email me](mailto:[email protected]?subject=Vendor%20due-diligence%20evaluation) at hoelee.com — I help businesses pick the right infrastructure and build the automation around it.
@@ -0,0 +1,131 @@
---
title: "How to Verify a Hosting Provider Before You Buy"
description: "A reusable checklist for separating real hosting claims from marketing: check the domain age against the 'since' claim, read the AUP for silent disqualifiers, and cross-check reputation on third-party sources."
pubDate: 2026-09-09
category: tutorials
tags: [hosting, vps, due-diligence, whois, devops]
ogImage: /og/how-to-verify-a-hosting-provider-before-you-buy.png
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.png
---
Every hosting provider's homepage is a list of promises: *"in business since 2005"*,
*"guaranteed uptime"*, *"privacy-friendly"*, *"unlimited bandwidth"*. Some of it is
true. A surprising amount of it isn't — and none of it is checked before you hand
over a card number and point DNS at their servers.
Recently I had to shortlist a pool of providers for a project with specific
requirements (privacy jurisdiction, payment methods, traffic limits). I couldn't
afford to trust the marketing, so I built a quick verification pass. It caught
real problems — providers whose "operating for years" claim was younger than my
last haircut, providers whose own terms quietly banned the exact thing I wanted
to run, and providers on third-party blacklists.
Here's the checklist, in the order I run it. None of it needs a paid tool.
## 1. Check the domain age against the "since" claim
A provider that says *"trusted since 2012"* should have a domain older than my
coffee order. If the domain is four months old, "since 2012" is either a
re-branded shell or a lie — either way it tells you something about how the
company describes itself.
The quickest check is a WHOIS/RDAP lookup. RDAP is the modern replacement for
WHOIS and returns JSON, which is easier to parse:
```bash
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
```
Look at the `events` array for the `registration` event — that's the original
creation date, not the last renewal. (Renewal dates and registrar changes will
hide further down the timeline; the *first* registration is the one you want.)
The false-negative rule cuts both ways: a brand-new domain *can* be a legitimate
new company. But an old "since" claim on a young domain is always a flag — it
means the historical claim isn't independently verifiable, and I treat anything
else on that page with the same skepticism.
## 2. Read the AUP (Acceptable Use Policy) — not the features page
This is the highest-signal step and the one most people skip. The features page
tells you what they *allow you to pay for*. The AUP tells you what they'll
suspend you for. Those are different lists.
The disqualifiers are usually in the "Prohibited activities" section, and the
phrasing is what matters:
- **"TOR nodes", "Tor relays", or "exit nodes"** — if you plan to run anonymity
tooling, this is a hard no, and it can hide in a list that *looks* like it's
only about abuse.
- **"reverse proxies", "anonymizing services", "tunnels"** — this bans far more
than you'd think. A lot of legitimate architecture (a caching proxy, a
GitOps webhook relay) technically trips this wording.
- **Jurisdiction and identity clauses** — "must provide accurate identity",
"complies with local law enforcement", or the AUP being bound to a specific
country's law. If your whole reason for choosing the provider is jurisdictional
distance, this line voids it.
The One Weird Trick: paste the AUP URL and search for the word you care about.
I spent ten minutes reading a whole AUP once before realizing a single
`Ctrl-F` for "tor" would have answered my question in five seconds.
## 3. Cross-check reputation — but on third-party sources, not their page
Testimonials on the provider's own site are decoration. You want places where
the provider can't delete the bad reviews:
- **Trustpilot** — but read the *trend*, not the average. A 3.5 with a long tail
is fine; a 3.5 where the last six months are all 1-star "they suspended my
server" reviews is a real signal.
- **Reddit, especially r/hosting, r/webhosting, r/sysadmin** — search the brand
name. The community has a long memory for exit-scams and mass-suspension
events, and it's usually blunt about which providers are "a well-known
scammer."
- **WHTop / HostAdvice** — the ratings are noisy, but a 1.8/10 with a pattern of
the same complaint repeated is different from a 1.8 with a handful of one-off
gripes.
The single most useful signal I've found: **suspension reports.** A provider that
responds to abuse complaints by suspending first and charging a "reinstatement
fee" second will say so in black and white in someone's review. That's a
business-model red flag, not a support incident.
## 4. Verify the "privacy" claim if it matters to you
"Privacy-friendly" and "offshore" are marketing words until they're reflected in
the actual documents:
- **The privacy policy's retention section.** A provider that says "we retain
records as long as necessary for legal / tax / accounting" is telling you they
keep logs. Full stop.
- **The payment rail.** Who actually processes crypto, and is it a third-party
gateway that itself requires KYC? A self-hosted gateway with no external
identity step is a very different privacy posture than "bitcoin via a
KYC-reseller."
- **The incorporated entity.** "Offshore hosting since 2000" is worth almost
nothing if the company is registered in, and bound by the law of, a country in
your threat model.
## 5. Check the traffic terms — "unlimited" rarely means unlimited
The word "unmetered" has a specific meaning that "unlimited" doesn't. Formally
unmetered = you pay a flat rate regardless of transfer, but the *port speed*
caps the ceiling. "Unlimited" on a plan with a metered 1 TB cap is just
marketing.
If you're paying for traffic (as opposed to a flat rate), model the real number
before you sign up: a relay that both receives *and* forwards a file pays for it
twice. A plan that looked cheap on "1 TB included" can be the expensive option
once you double the actual transit.
## The shape of the whole pass
Run these in order — domain age, AUP, reputation, privacy docs, traffic terms —
and score each provider on the same simple numbers. The goal isn't perfection;
it's to make the *claims you're actually relying on* explicit and checkable, so
you're not discovering the truth from a suspension email at 2 AM.
It's the same instinct as writing a unit test: state the assumption, then try to
break it. Most of the providers I eliminated weren't lying in a way a human
could spot from the homepage — they were lying in a way a *ten-minute checklist*
caught.
@@ -0,0 +1,240 @@
---
title: "Migrating a CodeIgniter 4 App From IIS to OpenLiteSpeed"
description: "I moved a 17,000-line CodeIgniter 4 app from Windows IIS to OpenLiteSpeed on CyberPanel. Two fatal errors appeared that IIS had been hiding behind SSO."
pubDate: 2026-09-20
category: engineering
tags: [codeigniter, php, openspeedway, cyberpanel, iis, migration, litespeed, linux]
ogImage: /og/migrating-codeigniter-iis-to-openlitespeed.png
banner: /banners/migrating-codeigniter-iis-to-openlitespeed.png
draft: true
---
I maintain a numerology report generator: a CodeIgniter 4 application that takes a birth date and a name, runs them through a numerology engine, and renders a 19-page A4 report. It had run on Windows Server with IIS for years. In September 2026 I moved it to OpenLiteSpeed on CyberPanel, on Linux, on a different domain.
The migration itself was unremarkable — copy the files, point the docroot at `public/`, install dependencies. What made it worth writing about is that **the first unauthenticated request to the new host fatally crashed on two routes that had been working fine on IIS for years.**
Both bugs were real. Both were present on IIS the whole time. Neither was visible, because IIS had an authentik SSO gate in front of the exact routes that were broken.
## Why this matters
If you run a self-hosted app behind an authentication gateway, your auth layer is doing more than protecting data — it is also **hiding your bugs**. Anything that only breaks for unauthenticated users never runs unauthenticated, so it never fails. The failure surfaces at the worst possible moment: when you migrate, remove the gate, or expose the route to the public internet.
In my case the app had been reporting "healthy" for as long as I'd owned it. The moment I pointed a new domain at it, two routes returned HTTP 500. Nothing had changed in the code. The only thing that changed was that the gate was gone.
The fix for both took about twenty minutes. Finding them took two hours, because the actual error messages were hidden behind CodeIgniter's CLI error renderer.
## The setup
The app is a fairly ordinary CI4 project with an unusual architecture detail: the report engine is not a library, it is a **set of HTTP endpoints on the same host**. The controllers generate a request, `curl` it back to `/api/single` on their own domain, and the engine returns JSON with the computed numbers. The controller then renders that into the printable report view.
That self-call design is what made `CONST_IIS_INTERNAL_BASE` necessary on IIS. Let me come back to it — it turns out to be the interesting part.
| | Before | After |
|---|---|---|
| OS | Windows Server | Ubuntu (CyberPanel) |
| Web server | IIS | OpenLiteSpeed 1.9.0 |
| PHP | 8.4 (Windows build) | 8.4.25 (lsphp84) |
| Docroot | `C:\inetpub\calc.hoelee.com` | `/home/<domain>/public_html/public` |
| Auth gate | authentik SSO on `/lifecode`, `/api/*` | none |
## Step 1: Point the docroot at `public/`, not the project root
CI4 ships with a two-folder layout. `app/`, `vendor/`, `writable/` and `.env` live in the project root. Only `public/` is meant to be web-accessible.
CyberPanel creates the document root as `public_html`. My first instinct was to extract the whole project into `public_html` and leave the docroot alone.
**That would have exposed `app/`, `vendor/`, the `.env` file, and `writable/` session data over HTTP.** The `.env` in this project contains a webhook credential. Anyone who guessed `/../.env` — or just fetched `/.env`, since the file sits directly under a served directory — gets it.
The correct layout is to extract into `public_html` and then repoint the vhost docroot one level deeper, at `public_html/public`:
```bash
# extract the project so that app/, vendor/ and .env sit UNDER public_html
cd /home/<domain>/public_html
tar -xzf /tmp/deploy.tar.gz
# the docroot must be public_html/public, NOT public_html
sudo sed -i 's#/home/<domain>/public_html\$#/home/<domain>/public_html/public#' \
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
sudo /usr/local/lsws/bin/lshttpd -t
sudo systemctl restart lsws
```
This is the single most important step in the migration, and it is the one most guides skip. If you do nothing else, do this.
## Step 2: The two fatals IIS was hiding
### Fatal 1 — `env()` called too early
The first 500 had no body at all. That is unusual — CI4 normally renders something. An empty 500 usually means PHP died before the framework's error handler was installed.
CI4's bootstrap loads `app/Config/Constants.php` very early in `Boot::bootWeb()`, via `Boot::loadConstants()`. That happens **before** the `Common.php` helper file is loaded, which is where `env()` is defined.
So this line:
```php
// app/Config/Constants.php — BROKEN
define('CONST_fullBase', env('hoelee.fullBase'));
```
fails with:
```
Fatal error: Uncaught Error: Call to undefined function env()
in app/Config/Constants.php:96
```
The rule is absolute: **`Constants.php` may only contain plain constants.** No `env()`, no `getenv()`, no config helper. If you need environment-dependent values there, define them further along the bootstrap — or make the constant a fallback and read the real value later.
That is exactly what I did. `CONST_fullBase` became a plain string, and the helper that consumes it checks the framework's own `app.baseURL` (which *is* `.env`-driven) first:
```php
// app/Helpers/hoelee_helper.php
function getFullBase(bool $selfCall = false): string
{
// the .env-driven baseURL wins; CONST_fullBase is only a fallback
if ($selfCall && defined('CONST_IIS_INTERNAL_BASE') && CONST_IIS_INTERNAL_BASE) {
return rtrim(CONST_IIS_INTERNAL_BASE, '/');
}
$appBase = config('App')->baseURL;
if ($appBase) return rtrim($appBase, '/');
if (defined('CONST_fullBase') && CONST_fullBase) return rtrim(CONST_fullBase, '/');
return '';
}
```
**This one was my own doing.** I had introduced it during a secrets-cleanup refactor in the same week — I moved a hardcoded URL into `.env` and called `env()` in `Constants.php` to read it. It worked on my machine because the local `.env` was being read through a different path. It never worked on a clean boot. I caught it within an hour *only because* I deployed and tested; a code review would plausibly have waved it through.
### Fatal 2 — `parent::__construct()` in a CI4 controller
The second failure was on `/lifecode`, and this one is a genuine pre-existing bug in the application — not something I introduced.
The error, once I got past the CLI renderer, was:
```
Error: Cannot call constructor
```
CI4's base `CodeIgniter\Controller` class **has no constructor**. It implements `initController()`, which the framework calls with the request, response, and logger objects. The standard pattern is:
```php
// correct CI4 pattern
public function initController(
RequestInterface $request,
ResponseInterface $response,
LoggerInterface $logger
) {
parent::initController($request, $response, $logger);
// your setup here
}
```
But `Lifecode.php` and `ApiEn.php` declared a classic constructor and called `parent::__construct()`:
```php
// BROKEN — CodeIgniter\Controller has no __construct()
public function __construct()
{
parent::__construct();
// ...
}
```
`parent::__construct()` on a parent class that does not define `__construct()` is a fatal in PHP 8. Converting both controllers to `initController()` fixed it — and required adding the three `use` statements for the interface types in the new signature.
I checked whether the base class *really* had no constructor rather than trusting the error message:
```bash
grep -n 'function __construct\|function initController' \
vendor/codeigniter4/framework/system/Controller.php
```
Only `initController()` came back. Worth doing — "Cannot call constructor" also fires when a parent *has* a constructor that errors internally, and you want to know which case you are in before you rewrite the signature.
## Why neither bug showed up on IIS
This is the part that changed how I think about the deployment.
On the IIS host, `/lifecode` and `/api/*` sat behind authentik SSO. An unauthenticated request to either returned **HTTP 302 to `auth.hoelee.com`** — it never reached the controller at all. The broken constructor was never executed. The route had presumably been broken since it was written, and it had never once been asked to serve a request.
I confirmed this by comparing the two hosts directly:
```bash
curl -sI https://calc.hoelee.com/lifecode | head -1
# HTTP/2 302 <- authentik redirect; controller never runs
curl -sI http://<new-host>/lifecode | head -1
# HTTP/1.1 500 <- no gate; controller runs and fatals
```
The 302 is why the bug survived. The app looked healthy because the unhealthy parts were unreachable.
**The lesson, stated plainly: an auth gate in front of a route means that route has no working test coverage for its own code.** If you migrate or remove the gate, budget time to hit every previously-gated route unauthenticated before you call the migration done. On a small app that is a ten-line `curl` loop. Here it would have found both bugs in seconds instead of two hours:
```bash
for p in / /read/single /read/partner /lifecode /api/date /api/single; do
printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://<host>$p)"
done
```
## Step 3: The self-call that stops being a loopback
Now the interesting architectural consequence.
The report engine is reached by the controller `curl`-ing its own host. On IIS, `CONST_IIS_INTERNAL_BASE` pointed that call at `http://localhost:7296` — the loopback interface — so the request never left the machine and never touched TLS or DNS.
On LiteSpeed that constant is deliberately left **undefined**, so `getFullBase()` falls through to `app.baseURL`. Which means every report generation now makes a **real outbound HTTPS request to the app's own public URL** and comes back in through Cloudflare.
Which raises the obvious question: does that work at all?
The answer is yes, but it is worth testing explicitly, because the failure mode is confusing. Here is the exact test — run it **on the server**, not from your laptop:
```bash
curl -s -o /dev/null -w 'HTTPS self-call: %{http_code}\n' \
-X POST 'https://<domain>/api/single' -d 'nameCn=test&dob=1990-01-01'
```
Two things to watch for:
- **Run it from the server.** From my Windows machine the same URL returned 404 and 500 — an artefact of DNS resolution and Cloudflare bot protection on my caller, not a server problem. Testing from the wrong host produces confident, wrong conclusions.
- **Watch the JSON, not just the status code.** A 200 with a truncated body is worse than a clean failure. I checked the response was parseable before trusting it.
There is a real trade-off hiding here, and I have not fully resolved it: a public-URL self-call means report generation depends on Cloudflare being up, costs a TLS handshake per report, and occupies two PHP workers for the duration. On LiteSpeed with a small worker pool, a burst of concurrent reports could deadlock — each request waiting on another request that has no free worker. For the current traffic level it is fine. Before this scales, the self-call should move to an internal path that bypasses the CDN.
## Step 4: Verify the actual output, not the status code
A 200 on `/` proves the landing page renders. It does not prove the report engine works, and the engine is the entire product. So the last step was generating a real report end to end:
```bash
curl -s -X POST 'https://<domain>/read/single' \
-d 'nameCn=test&dob=1990-01-01&gender=m' \
-o report.html -w 'HTTP:%{http_code} bytes:%{size_download}\n'
```
The result: **HTTP 200, 99,189 bytes, 19 A4 pages.** I ran it again with Chinese-character input to exercise the UTF-8 path through the engine — HTTP 200, 99,205 bytes, 19 pages, name rendering correctly, zero PHP warnings in the output.
Byte-comparable page counts between the old and new host is the check that matters. It proves the fonts, the DPI, and the pagination logic all survived the move.
A side note on the UTF-8 test: my first attempt at it returned a 500 and I nearly went bug-hunting. The cause was my own shell — the Chinese characters were being mangled by the terminal encoding on the way into `curl`, so the app received invalid bytes. Running the same request from a script on the server, where the encoding is under my control, returned 200. **Before you debug an encoding failure, confirm the bytes actually arriving at the server are the bytes you meant to send.**
## What I'd do differently
**Test unauthenticated routes before migrating, not after.** The whole two-hour debugging session was avoidable with a `curl` loop over the route list. I now treat "list every route, hit it unauthenticated, record the status" as step zero of any migration.
**Do not call `env()` in `Constants.php`.** I have left a comment in the file itself saying so, because the next person — probably me in six months — will be tempted.
**Check the framework version's project-space config before the move.** The same week, I upgraded CI4 from 4.6.3 to 4.7.4, and it fataled twice on properties the upgrade guide did not mention: `Config\App::$permittedURIChars`, required by the 4.7 Router, and `Config\Format::$jsonEncodeDepth`, required by the JSONFormatter — the second of which broke the engine's JSON self-call specifically. Composer updates `vendor/` but never merges `app/Config/*.php`, because those are project-space files. I ended up merging new properties into 14 config files. That is a separate post, but the migration lesson is the same shape: **the framework tells you what changed in `vendor/`; nothing tells you what changed in `app/`.**
**Confirm the docroot before anything else.** If I had extracted into `public_html` and stopped, the app would have worked — and quietly served `.env` over HTTP. A migration that works is not the same as a migration that is safe.
## The result
One evening. Six routes verified 200, byte-comparable report output on both Chinese and ASCII input, zero PHP warnings, and two long-standing latent bugs removed from the codebase that IIS had been concealing behind an auth gate.
The app is now running on OpenLiteSpeed with a properly separated docroot, on a host I can automate, with the credential in `.env` rather than a constant.
---
**Want this done for your own application?** I migrate PHP applications between IIS, Apache, nginx and LiteSpeed — including the awkward parts: self-calling architectures, SSO gates that hide bugs, and framework upgrades that touch project-space config.
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [[email protected]](mailto:[email protected]?subject=CodeIgniter%20migration) · [hoelee.com](https://hoelee.com)
@@ -0,0 +1,270 @@
---
title: "Upgrading n8n v1 to v2: Seven Deprecations in One Log File"
description: "My self-hosted n8n 1.x to 2.40.1 upgrade surfaced seven silent breakages at once — a telemetry schema rejection, deprecated webhook vars, and a DB override that quietly disabled the AI sandbox."
pubDate: 2026-09-18
category: devops
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.png
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.png
---
I run n8n as the automation backbone for my self-hosted stack — it handles
file-delivery permissions, database backups, and a text-to-speech API that a
reading app depends on. It had been sitting on the `1.123.x` line for the
better part of a year, quietly doing its job.
Then I pulled `n8nio/n8n:2.40.1` and restarted the container. The upgrade
itself took about ninety seconds. Understanding what it *broke* took the rest
of the evening — and almost all of it was already written down in a single log
file that n8n prints on boot. I just hadn't read it carefully enough the first
time.
This is that log file, decoded, so you can plan your own v1 → v2 jump instead
of discovering these at 11 PM.
## Why it matters
Major-version upgrades of an automation platform are different from upgrading
a leaf service. n8n is *the thing that runs everything else*: if it comes up
broken, your backups, your access-control syncs, and your internal APIs all
stop with it. Worse, most of what breaks in v2 doesn't throw an error — it
logs a deprecation notice once and then quietly does something different.
The seven items below are the ones that actually applied to a real, messy,
production-shaped install. Three of them changed behavior in my stack. One of
them silently turned a feature *off*.
## Start here: n8n tells you what's wrong at boot
Before touching a single workflow, read the container log from the top. On a
fresh v2 boot, n8n prints an explicit deprecation block:
```text
There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
both test and production webhooks.
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
change to `false` in a future version.
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
removed in a future version.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
reduced from 2 GiB to 256 MiB in a future version.
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
5000 to 1000 in a future version.
```
That block is your migration checklist. Six of my seven gotchas are in it.
## 1. Your env values can now fail *schema validation*
Here is the one that confused me most, because it looked like a nonsense error:
```text
Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
```
I had `EXECUTIONS_DATA_SAVE_ON_ERROR=error` set — a value that was perfectly
legal in v1, and that I'd chosen deliberately, because saving *only* failed
executions is the sane default for a busy instance. In v2 that value is no
longer in the allowed set, which is now `all` or `none`.
The failure mode is the interesting part. It didn't crash. It didn't even warn
in a way that reads like an error at a glance — it emitted a *telemetry schema
validation* message, which sounds like an n8n-internal problem, not a
configuration problem of mine. The setting was effectively ignored.
The fix is to move that intent somewhere coherent: pick a legal value and
control volume with pruning instead.
```env
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
```
**Lesson:** in v2, treat your environment variables as a typed interface with
a schema. An invalid value may be dropped silently rather than rejected loudly.
## 2. `WEBHOOK_URL` is deprecated for `N8N_WEBHOOK_URL`
If you publish webhooks behind a reverse proxy — which you almost certainly do,
because that's how they become reachable — the base URL variable is load-bearing.
It's what makes n8n report the *public* webhook path instead of
`http://localhost:5678/...`.
The old name still works today, so this one won't bite immediately. But note
the wording: the new variable sets the base URL for **both test and production
webhooks**. In my setup those had drifted apart in behavior, which is exactly
the class of bug this consolidation is meant to eliminate.
```env
# before (still functional, deprecated)
WEBHOOK_URL=https://auto.example.com/
# after
N8N_WEBHOOK_URL=https://auto.example.com/
```
## 3. Internal task runner mode is going away — and mine was already broken
This one was sitting in my logs the whole time, several lines above the
deprecation block, and I'd been reading past it for months:
```text
Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
```
If any workflow of yours uses a **Python** Code node, it has not been running
in internal mode at all — there's no Python in the stock image. JavaScript
Code nodes are fine (a JS runner registers normally), which is why this can go
unnoticed indefinitely: everything *looks* healthy.
v2 makes the direction of travel explicit: switch to `external` mode and share
an auth token with a separate launcher process.
```env
N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<a long random string>
```
**Lesson:** "internal mode is deprecated" is the headline, but the real
finding is that a runner type can be *silently non-functional* for months.
Check `docker logs` for the runner registration line, not just for up/down.
## 4. Task timeout drops from 300s to 60s
This is the one I'd flag hardest for anyone with slow workflows:
```text
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
```
Be honest about your own workloads. Do you have a Code node that loops over
thousands of records, or an HTTP call to a slow upstream? Mine do — a nightly
reconciliation walks every customer and calls an external API per record. On a
future upgrade, that stops at sixty seconds with no config change on my side.
Set it explicitly now, while you're already in the file:
```env
N8N_RUNNERS_TASK_TIMEOUT=300
```
The general principle for every deprecation of the form "the default will
change": **if you rely on the current default, pin it explicitly.** Otherwise
the upgrade is a silent behavior change, and you'll debug it as a bug rather
than recognise it as a stale default.
## 5 & 6. Two compression-node limits shrink (2 GiB → 256 MiB, 5000 → 1000 entries)
These two travel together and matter only if you use compression/decompression
nodes on large payloads — which is easy to end up doing when you're shuttling
database dumps or archives through a workflow.
```text
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
future version.
```
An eighth of the memory ceiling and a fifth of the entry limit. Nothing errors;
the node just refuses at a threshold you didn't set. Pin both if you're near
either.
## 7. The storage path renames in v3 — and you have a volume mounted at the old one
Not a v2 breakage, but v2 warns about it, and it's the one with real data-planning
implications:
```text
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
```
Read that last sentence again: *if you have a volume mounted at the old path,
update your mount configuration after migration.* If you set the migration flag
and keep your old bind mount, you now have two directories and your binary data
lives in whichever one the container is actually pointed at. Do the rename and
the mount change in the same maintenance window — not one now, one "later".
## The one that wasn't in the log: my AI sandbox disabled itself
Here's the finding that had nothing to do with a deprecation notice, and that
I'd never have caught without reading the full boot sequence:
```text
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
```
My environment said enabled. The database said otherwise. **The database won.**
The env var was `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`, and it was still set
correctly on the container. But a value persisted in n8n's own configuration
store overrode it at startup — and the only place that conflict is reported is
inside a parenthetical in a log line.
This is a genuinely valuable debugging lesson beyond n8n: when a feature is off
despite the env var being obviously right, suspect a **persisted settings layer
that outranks your environment**. The container config is not always the last
word. Grep for the feature name in the logs, and don't stop at the env value.
## What I'd do differently
1. **Read the boot log before declaring the upgrade done.** Every deprecation
that mattered to me was printed on startup, in one block, on the first run.
My v1 habit was to check "is it up, do the workflows run" — which is exactly
the check that misses all seven of these.
2. **Treat "the default will change" as a to-do, not a warning.** Five of the
seven items are future-default changes. Pinning them now costs one edit and
converts a mystery outage later into a config diff.
3. **Diff the container config against n8n's own stored config.** The sandbox
override taught me that env is one of two inputs, not the source of truth.
When behavior and configuration disagree, believe the behavior and go find
the higher-priority layer.
4. **Pin the image tag and keep the previous one.** I jumped v1 → `2.40.1`
directly. Having the old image on disk is what makes a rollback a
`docker run` instead of a rebuild.
## The result
n8n is on 2.40.1 with the full deprecation block resolved: the schema-invalid
value corrected, `N8N_WEBHOOK_URL` in place, the JS runner registering cleanly,
and the task timeout and compression limits pinned so the next upgrade is a
no-op rather than a surprise.
Everything downstream kept working — the file-permission syncs, the nightly
backups, the text-to-speech endpoint. That's the outcome worth aiming for with
an upgrade like this: not "it came back up", but "it came back up *and* the
next three upgrades are already paid for".
The uncomfortable part is how much of it I could have known in advance. n8n
handed me the entire list, unprompted, at startup. The upgrade was never the
hard part — reading the output was.
---
## Running automation you'd rather not babysit?
I build and maintain self-hosted automation — n8n workflows, Docker stacks,
and the glue between apps that were never designed to talk to each other. If
you're facing a major version upgrade, or you have automation that works until
it doesn't, I plan the migration, do it in a maintenance window, and document
every config decision so the next upgrade is boring.
Reach me at [[email protected]](mailto:[email protected]?subject=n8n%20upgrade) or
WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,191 @@
---
title: "Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File"
description: "My self-hosted Passbolt returned 504s for weeks: a null GPG fingerprint stalled email delivery, a container recreate wiped the fix, and ssl.force behind a proxy caused a redirect loop."
pubDate: 2026-09-13
category: devops
tags: [passbolt, docker, gpg, smtp, reverse-proxy, portainer]
ogImage: /og/passbolt-hang-three-failure-modes.png
banner: /banners/passbolt-hang-three-failure-modes.png
---
I run Passbolt — an open-source password manager for teams — on a Synology
NAS in Docker, behind an nginx reverse proxy. For a stretch of weeks it
developed a nasty habit: the web UI would hang, return `504 Gateway Timeout`,
and refuse to hand over a password right when I needed it. This is the story
of chasing that hang to its root — and the twist where my own "fix" caused a
brand-new failure mode.
## Why it matters
A password manager is the one app you cannot afford to be flaky. When I'm
mid-conversation with a client and need a server credential, a loading spinner
is not an option. The symptoms looked random — sometimes up, sometimes
504 — but the cause turned out to be a single missing file, plus two mistakes
stacked on top of it. If you self-host Passbolt in Docker, one of these three
is probably in your future.
## The symptom: cron hangs, and the UI follows
Passbolt runs a cron job every minute that processes its email queue — account
recovery links, share notifications, test messages. Normally it completes in
under a second. Mine was taking 3060 seconds every single run.
Why does that matter? Behind the scenes Passbolt serves the web UI through a
pool of PHP-FPM workers. When the cron job stalls on email sending, it ties up
a worker. Enough stalled crons, and the pool is exhausted — so ordinary page
requests queue up, nginx times out, and you get the 504.
The logs told the story plainly:
```text
not starting: job is still running since ... (1m elapsed)
```
## Root cause 1: a null GPG fingerprint
Passbolt stores its SMTP settings encrypted with the server's GPG key. To use
them, it needs to know which key to decrypt with — a value read from
`passbolt.gpg.serverFingerprint`. That value comes from a config file,
`/etc/passbolt/passbolt.php`.
That file didn't exist.
Without it, the fingerprint resolved to `null`, GPG decryption of the SMTP
settings failed, and every cron iteration that tried to send mail hung on the
failure. The healthcheck surfaced it as:
```text
SMTP Setting errors: ... setDecryptKeyFromFingerprint():
Argument #1 ($fingerprint) must be of type string, null given
```
There was also one genuinely dead email in the queue — an old `SMTP timeout`
record that had exhausted its retries months ago and would never send. It got
re-scanned every minute, adding to the stall.
```sql
DELETE FROM email_queue WHERE sent = 0 AND send_tries >= 4;
```
## Root cause 2: the fix that didn't survive (the relapse)
Here's the part that stung. I had fixed this exact bug once before — by
creating `/etc/passbolt/passbolt.php` *inside the container*. Two weeks later
the hang came back, and the file was gone.
The reason: I'd recreated the container during maintenance, and a Docker
container's writable layer is **ephemeral by design**. Anything written to the
filesystem inside the container (rather than into a mounted volume) vanishes on
recreate. My fix had the shelf life of the container, not of the deployment.
The durable version is three pieces that all live *outside* the container:
1. A config file on the host, bind-mounted into the image at
`/etc/passbolt/passbolt.php`:
```php
<?php
return [
'App' => [
'fullBaseUrl' => env('APP_FULL_BASE_URL', 'https://pass.example.com'),
],
'passbolt' => [
'gpg' => [
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
],
],
];
```
2. The bind mount in compose — the part that makes it survive recreate:
```yaml
volumes:
- /volume1/docker/passbolt/session-config/passbolt.php:/etc/passbolt/passbolt.php:ro
```
3. The correct environment variable name. I'd been using
`PASSBOLT_GPG_SERVER_FINGERPRINT`, which Passbolt silently ignores. The
real variable is `PASSBOLT_GPG_SERVER_KEY_FINGERPRINT` — and it wants the
**full 40-character** fingerprint, not a truncated one:
```yaml
PASSBOLT_GPG_SERVER_KEY_FINGERPRINT: "A3DD9B762D48722C10CF88DDB5372E46A54E1419"
```
## Root cause 3: the redirect loop I caused myself
With the hang fixed, I upgraded Passbolt from 5.14.3 to 5.15.0. Then, browsing
to `https://pass.example.com/`, the browser threw:
```text
ERR_TOO_MANY_REDIRECTS
```
I assumed the upgrade had broken something. It hadn't. The culprit was
`ssl.force`, which I'd left in my config file:
```php
'passbolt' => [
'ssl' => [
'force' => true,
],
],
```
Passbolt's SSL-force middleware inspects the request's scheme, and behind a
TLS-terminating reverse proxy that scheme is **always `http`** — because the
proxy (nginx) terminates the TLS connection, then forwards plain HTTP to the
container. So the middleware sees `http`, dutifully redirects to
`https://same-url`, nginx forwards it back as `http`, and the cycle repeats
forever.
The fix is to understand who owns SSL. If your reverse proxy already enforces
HTTPS at the edge, the app must **not** try to force it again. Removing
`ssl.force` from the config (leaving TLS to nginx) broke the loop immediately.
The general rule, which applies well beyond Passbolt to any app behind a
reverse proxy: **either** the proxy terminates TLS, **or** the app does — never
both, and never configure `ssl.force` in the app while the proxy already
handles it.
## What I'd do differently
1. **Config lives in volumes, not in containers.** The moment I wrote a file
into `docker exec`, I'd committed to redoing it on the next recreate. The
reflex should be: does this need to survive a `docker compose up`? Then it
goes in a bind mount, not the writable layer.
2. **Read the actual variable names.** The fingerprint env var cost me a clean
diagnosis because I trusted a wrong name that Passbolt ignored without
complaint. When a config value "doesn't work," check the upstream reference
for the exact key before assuming anything else is wrong.
3. **Test the public URL, not just the healthcheck.** The healthcheck passed
every time and missed the redirect loop entirely because it never exercised
the real public path through the proxy. A two-second `curl -I` against the
live URL would have caught it instantly.
## The result
The hang is gone: cron completes in under a second (down from 3060s), the
email queue is clear, and the web UI responds in milliseconds instead of
504-ing. Passbolt is now running the current 5.15.0, with every GPG and SMTP
healthcheck green — and the fix is written in a way that survives the next
container recreate.
The lesson worth carrying: a self-hosted service that "sometimes hangs" is
rarely a mystery. It's usually one missing config value, surfaced three
different ways. Fix the config, not the symptom.
---
## Want stable self-hosted infrastructure for your business?
If you run services like Passbolt, a password manager, email, or a dashboard
and they're flaky — hanging UIs, 504s, mysteries that vanish on restart — I
diagnose and fix exactly these problems. I work with Docker, reverse proxies,
and self-hosted stacks, and I hand everything back documented so the next
person (or future you) isn't left guessing.
Reach me at [[email protected]](mailto:[email protected]) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,299 @@
---
title: "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB"
description: "MySQL Workbench 26.7.0 connects to MySQL 8.4 but fails on MariaDB with a useless TypeError. Three patches, and why version numbers stopped meaning what you think."
pubDate: 2026-09-19
category: devops
tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, self-hosting]
ogImage: /og/patching-workbench-26-for-mariadb.png
banner: /banners/patching-workbench-26-for-mariadb.png
draft: false
---
I run four database servers from one Windows machine: two MySQL 8.4 instances, one local install, and a MariaDB 10.11 box behind CyberPanel on my LAN. Three of them opened fine in the shiny new MySQL Workbench 26.7.0. One didn't.
The error it gave me was this:
```
TypeError: DbModuleSession.on_session_message() missing 1 required positional argument: 'result'
```
That message contains no information about databases, networking, credentials, or MariaDB. It's a Python argument-count error inside the client. And it was the single most misleading error message I've chased this year — because it wasn't the bug. It was **the error reporter crashing while trying to report the bug**.
This post is the trail from that message to three patches in Oracle's own shipping code, and the underlying design assumption that causes all three.
## Why this matters
If you keep more than one database engine around — MySQL here, MariaDB there — you'll hit this class of failure eventually, and the error text will actively mislead you. Three things in this post are worth knowing even if you never open Workbench:
1. **A garbage error message is itself a finding.** When a tool reports something structurally impossible, suspect its error path before its logic.
2. **`major >= 8` is not a reliable "is this MySQL?" test.** MariaDB reports version 10.x and 11.x. A lot of software gets this wrong.
3. **The version-number collision is systemic**, not a one-off bug in one app — it's why the same fix had to be applied three times in three files.
## The setup
The four connections, and which one broke:
| Server | Engine | Version | Workbench 26.7.0 |
|---|---|---|---|
| DSM NAS | MySQL | 8.4 | works |
| unRaid | MySQL | 8.4 | works |
| localhost (since removed) | MySQL | 8.4.6 | works |
| CyberPanel VM | **MariaDB** | **10.11.19** | **fails** |
The pattern is obvious in hindsight: everything MySQL 8.4 works, the one MariaDB doesn't. But "it's the odd one out" is not a diagnosis, and I didn't trust it yet.
## What I tried first, and why it was wrong
**Theory 1: TLS.** The connection profile had `useSSL = 1` with empty `sslCA`, `sslCert`, `sslKey`. For MySQL 8.x that's fine — it auto-generates certificates. For MariaDB, maybe not.
This one turned out to be **half right**, and I'll come back to it, because it's a genuine second fault that produces a *different* error.
**Theory 2: credentials or grants.** Also wrong, and I could disprove it cheaply. Workbench 26 ships its own MySQL Shell binary. Running that binary by hand against the same host is a direct test of everything except Workbench's own code:
```bash
MSH="/c/Program Files/MySQL/MySQL Workbench/resources/app/shell/bin/mysqlsh.exe"
"$MSH" --sql --uri "admin:[email protected]:3306" \
-e "select current_user(), version();"
```
```
current_user() version()
admin@% 10.11.19-MariaDB-ubu2404
```
It connects. Same binary, same host, same credentials — works from the command line, fails from the GUI. That narrows it decisively: **the network, server, grants, and password are all fine.** Whatever is broken lives in Workbench's own session-handling code.
If you take one technique from this post, take that one. Before debugging a client, drive the client's own underlying tool directly to split "the thing is broken" from "this UI is broken."
## Getting the real error out of it
Workbench 26 stores its logs somewhere new. The useful ones:
```
%APPDATA%\MySQL Workbench\MySQL Workbench-electron.log # app-level, session stderr
%APPDATA%\MySQL\mysqlsh\mysqlsh.log # per-connect log
```
The connect log showed something odd. The session *connected*, then stopped:
```
Info: Connecting to MySQL at: [email protected]:3306
Info: main: tid=0: CONNECTED: 192.168.1.124:3306
Warning: Could not enable session tracking for sql_mode: Session tracker not supported by server
```
`CONNECTED`, and then nothing. No error, no disconnect. The connection succeeded and the session died silently afterwards.
Meanwhile the Electron log had the `TypeError` from the top of this post, repeated fifty-one times. That repetition is the clue: an error handler being called once per failure, crashing every time.
### The bug was in the error handler
From `gui_plugin\core\dbms\DbSession.py`:
```python
def terminate_thread(self):
self._close_database(True)
if self.thread_error is not None:
logger.error(f"Thread {self._id} exiting with code {self.thread_error}")
self._message_callback("ERROR", self.thread_error) # two arguments
self._term_complete.set()
```
And the callback it's calling, in `DbModuleSession.py`:
```python
def on_session_message(self, type, message, result, request_id=None):
```
Three required parameters. The call passes two. So **whenever a session thread dies, the code that reports it dies too** — and the `TypeError` replaces the original error on its way to the user.
That's the whole reason this bug was hard to see. Every real error in that session was being swallowed by a crash in the reporting path. Fixing the handler doesn't fix the underlying problem; it makes the problem **visible**. Which is what you need.
The correct call shape is right there in a sibling file, `DbMySQLSession.py`:
```python
self._message_callback(
"PENDING",
"Connection lost, reconnecting session...",
None,
self._current_task_id,
)
```
So the fix is to match it:
```python
self._message_callback(
"ERROR", str(self.thread_error), None, self._current_task_id
)
```
Restart, retry, and the error message finally tells the truth:
```
MySQL Error 1193 (HY000): Unknown system variable 'gtid_mode'
```
Now we're somewhere. That's a real error, and it names a real cause.
## The actual root cause
`gtid_mode` is a **MySQL-only** system variable. MariaDB doesn't have it — it has `gtid_strict_mode`, `gtid_binlog_pos`, `gtid_current_pos`, and others, but nothing named `gtid_mode`.
You can confirm both halves in one minute:
```bash
# On MariaDB — no gtid_mode anywhere in the list
"$MSH" --sql --uri "..." -e "show variables like 'gtid%';"
# On MySQL 8.4 — there it is
"$MSH" --sql --uri "..." -e "select @@gtid_mode;"
```
The offending code, in `gui_plugin\migration\lib\backend\replication.py`:
```python
if log_bin:
if session.nversion >= 56000:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
```
`session.nversion` for MariaDB 10.11.19 is `101119`. That's greater than `56000`, so the guard passes, so the query runs, so MariaDB rejects it and the session dies.
**And here is the design flaw underneath it.** The guard assumes a version number can tell you which engine you're talking to. It can't. MariaDB and MySQL share a single version register:
| Engine | Reports as |
|---|---|
| MySQL 5.7 | `5.7.x` |
| MySQL 8.0 / 8.4 | `8.0.x` / `8.4.x` |
| MariaDB 10.6 | `10.6.x` |
| MariaDB 10.11 | `10.11.x` |
| MariaDB 11.x | `11.x` |
Every MariaDB version is numerically *above* every MySQL version in production use. So `nversion >= 56000` is true for MariaDB. So is `>= 80000`. Any feature-gate written as a MySQL version comparison will pass against MariaDB and then send it syntax it doesn't understand.
The reliable discriminator is `@@version_comment`, which returns `mariadb.org binary distribution` on MariaDB and something else on MySQL. Workbench even **fetches** that value — it stores it as `VERSION_COMMENT` right next to `VERSION_INFO` — and then never uses it to make this decision.
This is not sloppiness in one place. It's a wrong assumption applied consistently, which is why the same bug appeared three separate times.
## Three patches, same mistake
Once I knew what to look for, the rest were mechanical. All three are the same fix: make the MySQL-only probe tolerant.
### Patch 1 — `replication.py`
```python
if session.nversion >= 56000:
try:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
except Exception:
# MariaDB has no gtid_mode system variable; ignore and continue.
gtid_mode = None
```
Worth noting: the *same* query is called elsewhere in the codebase, in `checks.py`, and **there it's already wrapped in `try/except`**. Oracle knew this call could fail on some servers. They just didn't apply that knowledge here. Only triggers when binlog is enabled, which it is on this box.
### Patch 2 — `DbSession.py`
The error handler from earlier. Strictly this fixes reporting, not MariaDB compatibility — but it's the patch that made patches 1 and 3 findable. Do it early.
### Patch 3 — `DbMySQLSessionSetupTasks.py`
Next failure, surfaced by patch 2:
```
MySQL Error 1193: Unknown system variable 'explain_json_format_version'
```
Which lives here, in a session-setup task that runs on every connect:
```python
version = self.get_data(common.MySQLData.VERSION_INFO)
major, minor, _ = version.split(".", 2)
if int(major) < 8 or int(major) == 8 and int(minor) < 3:
return
self.execute("SET SESSION explain_json_format_version=2")
```
`@@version` is `10.11.19-MariaDB-ubu2404`. Split it: `major = 10`, `minor = 11`. Is `int(major) < 8`? No. Guard passes. `SET SESSION explain_json_format_version=2` is MySQL-only. Error 1193 again.
Same fix:
```python
try:
self.execute("SET SESSION explain_json_format_version=2")
except Exception:
pass
```
### The separate TLS fault
Remember Theory 1? It was a real problem, just a different one — and it fails *before* authentication, which makes it look like a credential issue.
Your connection profile can hold `useSSL = 1` with no certificates. Against MySQL 8.x that's harmless. Against MariaDB with TLS switched off, the handshake is refused outright:
```bash
"$MSH" --sql --uri "..." -e "select @@have_ssl;"
# have_ssl: DISABLED
"$MSH" --sql --uri "...?ssl-mode=REQUIRED" -e "select 1;"
# ERROR 2026 (HY000): SSL connection error:
# SSL is required but the server doesn't support it
```
The fix is one value in `%APPDATA%\MySQL\Workbench\connections.xml` — set `useSSL` to `0` for that connection only. On a trusted LAN, a plain connection is fine. Back the file up first.
Two independent faults, two different error signatures. Worth separating in your head: **TLS failures happen before login; the `1193` failures happen after.** If the server rejects your handshake, credentials never get evaluated.
## How to patch files under Program Files
The Workbench files are shipped **read-only**, and this bit me twice in a way worth documenting: writes fail *silently*. PowerShell's `WriteAllText` reported success while the file on disk was unchanged — same size, same timestamp. A `.Replace()` on the content reported success and did nothing, because of line-ending differences.
What actually works:
1. **Back up in place**`Copy-Item $f "$f.bak"`, so the pristine original sits next to your version.
2. **Clear the read-only flag** and *confirm* it: `attrib -R`, then check `(Get-Item $f).Attributes` no longer says `ReadOnly`.
3. **Patch by line index**, not string replacement — read all lines, splice, write all lines. Immune to CRLF and quoting differences.
4. **Restore read-only** afterwards with `attrib +R`.
5. **Delete the stale `__pycache__` `.pyc`** — otherwise Python keeps running the old compiled module and you'll conclude your patch didn't work. This one is easy to forget and produces a genuinely confusing result.
6. **Verify syntax** before relaunching: `ast.parse()` on the file, or `node --check` for JS.
Then restart the app so the patched source is loaded.
## Test the patch before you hand it back
A patched build that hasn't been exercised is a guess. Since the failure depends on server behaviour, simulate the server rather than clicking hopefully at the GUI:
```python
class FakeSession:
nversion = 101119 # MariaDB 10.11.19
def run_sql(self, q):
if "gtid_mode" in q:
raise Exception("1193 (HY000): Unknown system variable 'gtid_mode'")
if "@@log_bin, @@binlog_format" in q:
return FakeResult((1, "ROW")) # binlog ON triggers the path
```
Load the patched function with `exec()`, call it with that fake, and assert it returns instead of raising. It's a five-minute test that turns "I think this works" into "this path is covered." It also confirmed the patch was genuinely being loaded, before I blamed the app.
## The result
All four connections work from Workbench 26.7.0 now, including the MariaDB box. Three patched files in Oracle's shipping code, each with a `.bak` beside it:
| File | Change |
|---|---|
| `replication.py` | guard `select @@gtid_mode` |
| `DbSession.py` | fix error-handler argument count |
| `DbMySQLSessionSetupTasks.py` | guard `SET SESSION explain_json_format_version` |
One thing I'd flag rather than dress up: these are patches to a closed application's bundled code, not a contribution upstream. They'll be **overwritten by the next Workbench update**, and the fault list may be longer than three — I stopped when the connections worked. If it breaks again after upgrading, expect to walk the same trail and possibly find a fourth instance of the same mistake.
## What I'd do differently
- **Drive the client's own binary first.** `mysqlsh` from Workbench's own install folder settled the "is it the server?" question in one command. I should have started there instead of theorising about TLS.
- **Treat an impossible error message as evidence.** `on_session_message() missing 1 required positional argument` is not a database error. When the text doesn't match the domain, suspect the error path itself — and fix that first, because it's blocking your view of everything else.
- **Read the sibling files.** Both the `gtid_mode` guard and the correct `_message_callback` signature existed correctly elsewhere in the same codebase. The bug wasn't ignorance of the right pattern; it was inconsistent application of it.
- **Notice version-gate arithmetic as a smell.** Any `if version >= N` branch that decides *which database engine* you're facing deserves suspicion. Ask what MariaDB reports, not what MySQL reports.
- **Keep the old client installed.** Workbench 8.0.x has no such probe and connects to MariaDB without complaint. Running 26.x for MySQL 8.4 and 8.0.x for MariaDB is a legitimate strategy — and it's the reason this was an evening's puzzle rather than a blocked workday.
Version numbers are the easiest thing to compare and the easiest thing to compare wrongly. Two projects sharing a numbering scheme is not the same as two projects sharing a feature set.

Some files were not shown because too many files have changed in this diff Show More