{"slug": "friday-fixes-housekeeping-the-homelab-and-hub", "title": "Friday Fixes: Housekeeping the Homelab and Hub", "summary": "A developer updated a homelab's local LLM stack, catching up llama.cpp by 469 builds and upgrading the Qwen generation model from 3.5 to 3.6, the embedding model from nomic v1.5 to v2-moe, and adding a new Qwen3-Coder-30B-A3B coding model. The maintenance also involved fixing a vacation planning site with calendar sync, activity voting, and expense tracking features, while a Substack syndication pipeline required a GitHub Action to handle two undocumented quirks discovered during repeated use.", "body_md": "Some weeks you ship a big feature. Other weeks you sweep the floor so the big features keep working. This was a floor-sweeping week — two completely unrelated workstreams that both needed attention.\n\n**Track one**: the homelab's local LLM stack hadn't been touched in a month. Models were stale, llama.cpp was 469 builds behind, and the embedding model was a generation old.\n\n**Track two**: the [vacation planning site I open-sourced](https://dev.to/blog/forking-and-open-sourcing-a-single-purpose-site) needed to actually be useful for a group trip. Calendar sync, activity voting, expense tracking — the features that turn a brochure into a tool.\n\n**Track three**: the [Substack syndication pipeline](https://dev.to/blog/syndicating-to-substack-the-undocumented-path) I wrote about earlier this week? Turns out doing it once was the easy part. Doing it *every time* surfaced two more undocumented quirks and required a GitHub Action to paper over them.\n\nNone of these stories is glamorous on its own. Together they're a snapshot of what maintenance week looks like when you're building with an agent.\n\nThe homelab runs llama.cpp on an RTX 5090 with six switchable models. The agent audited everything and came back with a report card:\n\n| Component | Before | Verdict |\n|---|---|---|\n| llama.cpp | b8933 | 469 builds behind |\n| Qwen (daily driver) | 3.5 35B-A3B | 3.6 available |\n| Embedding | nomic-embed v1.5 | v2-moe available |\n| Gemma 4, Devstral, DeepSeek | Current | No action needed |\n| Codestral | v0.1 (2024) | Dead end — Mistral pivoted to Devstral |\n\nThree downloads, ~38 GB total: Qwen 3.6, nomic-embed v2-moe, and a new addition — Qwen3-Coder-30B-A3B, a coding-specialized MoE that fits at 17 GB.\n\nThe interesting discovery was about quant provenance. Our Qwen model uses `UD-Q4_K_XL`\n\nquantization — the \"XL\" quants use higher precision on attention layers while keeping MoE expert layers smaller. These are **unsloth-specific**. Bartowski (the other major GGUF publisher) doesn't offer them. The agent initially found the bartowski version and we had to redirect it to unsloth to get the same quant type we were already running.\n\nThis matters because quant format affects output quality in ways that aren't obvious from the model name alone. `Q4_K_M`\n\nand `Q4_K_XL`\n\nare both \"4-bit\" but they allocate precision differently. Swapping quant types during an upgrade is an uncontrolled variable.\n\nThe homelab's model switching lives in a shell script (`llm-switch.sh`\n\n) that maps model names to file paths and llama-server flags. Updates: Qwen path from 3.5 to 3.6, new `qwen-coder`\n\ncase with 128K context, embedding path from v1.5 to v2-moe, Codestral marked `[legacy]`\n\n.\n\n**Gotcha**: Pasting heredoc scripts into the terminal mangled backslashes and quoting. We switched to writing the scripts in the workspace, pushing to GitHub, and giving me a `git pull && cp`\n\none-liner. Lesson: don't paste shell scripts through chat — commit them.\n\n| Component | Before | After |\n|---|---|---|\n| llama.cpp | b8933 | b9402 |\n| Generation model | Qwen 3.5 | Qwen 3.6 |\n| Embedding model | nomic v1.5 (262 MB) |\nnomic v2-moe (914 MB) |\n| Switchable models | 5 |\n6 (added qwen-coder) |\n| VRAM | 26,262 MiB | 26,682 MiB (+420 MiB) |\n\nAbout 20 minutes wall clock from audit to fully updated, zero downtime. The old models still serve until you restart the service with the new binary.\n\nThe [vacation hub](https://github.com/carryologist/vacation-hub) is a forkable trip-planning site — deploy to Vercel, run the setup wizard, and your group has a private site for travel notes, itinerary, lodging, activities, photos. I [wrote about open-sourcing it](https://dev.to/blog/forking-and-open-sourcing-a-single-purpose-site) last week. This week was about making it useful.\n\nFour features across three days, 11 commits, 3,484 lines added. But the features aren't the interesting part. The bugs are.\n\nPeople need trip events in their phone's calendar. Two options: download a `.ics`\n\nfile (one-time import) or subscribe to a URL (auto-syncing).\n\nThe download is trivial — click a button, get a file. The subscription is the interesting engineering problem. Google Calendar, Apple Calendar, and Outlook all fetch subscription URLs from their servers. No browser, no cookies. So the endpoint needs an auth mechanism that works without a session.\n\nWe went with a deterministic HMAC token: `HMAC-SHA-256('calendar-subscribe', VACATION_HUB_SECRET)`\n\n. The export endpoint accepts either a cookie (for browser downloads) or a `?token=`\n\nparam (for calendar clients). No expiry — a time-limited token would silently break subscriptions when it expires and there's no user present to re-authenticate.\n\nThe iCal generator itself is 202 lines, built from scratch against RFC 5545. The subtle part is line folding — the spec requires max 75 *octets* per line, not characters. You can't just `.slice(75)`\n\nbecause you might split a UTF-8 multi-byte character. The fold function walks backward from the cut point checking continuation bytes. Most iCal libraries get this wrong and corrupt non-ASCII event names.\n\nReddit-style upvote/downvote on suggested activities. Name-based identity (localStorage, no accounts). Upsert voting so changing your mind is idempotent.\n\nThis feature worked perfectly in development and completely failed in production. Twice, for two different reasons.\n\n**Bug 1 — The Trailing Slash Massacre**: `next.config.ts`\n\nhas `trailingSlash: true`\n\n, which makes Next.js issue 308 redirects from `/api/foo`\n\nto `/api/foo/`\n\n. The redirect preserves the HTTP method but the browser drops the request body. Every POST, PUT, and DELETE arrived at the API with an empty body. GET requests (page loads, data fetching) worked fine, so the site *looked* healthy — only mutations were silently failing.\n\nThe fix: add trailing slashes to all 28 `fetch()`\n\ncalls across 12 files. Eight minutes to fix, 40 minutes to diagnose. `trailingSlash: true`\n\nis a foot-gun for API routes — fine for page navigation, lethal for `fetch()`\n\n.\n\n**Bug 2 — The Table That Never Existed**: After fixing trailing slashes, voting *still* didn't work. The `activity_votes`\n\ntable didn't exist on production. It existed in development because the dev database didn't have duplicate activity titles.\n\nThe `initializeDatabase()`\n\nfunction runs CREATE TABLE statements sequentially in a single try block. After creating the `activity_suggestions`\n\ntable, it tries to create a unique index on the `title`\n\ncolumn. Production had duplicate titles (imported via LLM-generated suggestions). The index creation threw, the catch block caught it, and the function exited before reaching `CREATE TABLE activity_votes`\n\n.\n\nThe debugging journey: deploy a temporary `/api/db/debug/`\n\nendpoint → confirm the table is missing → trace the init function → find the ordering dependency → wrap the index creation in its own try/catch → re-run init → delete the debug endpoint. Two commits, two minutes apart.\n\nThe lesson: every DDL statement in an init function should be its own try/catch. A failure to create an index on table A should never prevent table B from being created.\n\nThis one predated the feature sprint but came up during testing. PDF itinerary uploads worked locally, failed on Vercel with a cryptic module error.\n\nThe `pdf-parse`\n\nnpm package bundles an ancient version of PDF.js that uses dynamic `require()`\n\n. Vercel's bundler traces imports statically and prunes anything it can't resolve. The module exists in `node_modules`\n\nlocally but vanishes after bundling.\n\nBonus discoveries while debugging:\n\nReplaced `pdf-parse`\n\nwith `unpdf`\n\n(serverless-compatible). Three files changed, 21 insertions, 38 deletions. The kind of fix that's trivial once you know the root cause and impossible until you do.\n\n2,108 lines across 13 files. Track who paid for what, scan receipts with AI, show who owes whom.\n\nThe receipt scanning supports three LLM providers — same ones the site already uses for itinerary parsing. Each has its own quirks: OpenAI accepts image URLs directly, Anthropic and Gemini require base64 encoding. OpenAI and Gemini support structured JSON output, Anthropic requires regex extraction from prose. For PDFs, all three get extracted text rather than the visual layout.\n\n**The design pivot that mattered**: The original plan had per-expense split counts. \"This $200 dinner was split 4 ways.\" In practice, the form was cluttered and the answer was almost always the same number. We changed to a global \"Splitting between N people\" control at the top of the page. The form went from three columns to two. Settlement computation moved from a server endpoint to a `useMemo`\n\nhook — because the split count is a UI concern (you might flip between values while looking at the numbers), not persistent data.\n\nWe built the server endpoint, shipped it, realized it was wrong, moved the logic client-side, and deleted the endpoint. Normal lifecycle.\n\nAfter the feature sprint, we went back and deleted dead code:\n\n`/api/expenses/settle/route.ts`\n\n— settlement moved client-side`/api/og-image/route.ts`\n\n— only consumer was the activity POST handler, which we'd stripped during the Things to Do redesign363 lines deleted. We also went back to the expense feature's design doc and annotated it with what actually shipped versus what was planned. There's something honest about marking your own plan with \"this part we built differently.\" The plan is the record of what you thought before you knew better. The code is what you actually shipped.\n\nI [wrote up the initial Substack import](https://dev.to/blog/syndicating-to-substack-the-undocumented-path) earlier this week — 13 curated posts, an RSS feed filtered by a `syndicate: true`\n\nfrontmatter flag, and a GitHub mirror repo to work around Substack rejecting feeds from our domain. That got the backlog in. This week's Thursday Thoughts post was the first one I needed to push *after* the initial import.\n\nIt didn't go smoothly.\n\n**Quirk 1 — per-feed-URL dedup.** Substack doesn't just dedup by GUID. It dedupes by *feed URL*. If you add a new post to `syndicate.xml`\n\nand re-import the same URL, Substack silently skips the new item. The existing 13 posts aren't reimported (good), but the new 14th post isn't imported either (bad). No error. The import API returns 200 and reports it found 14 posts. It just doesn't do anything with the new one.\n\nThe workaround: a separate `single-import.xml`\n\nfile containing only the new post, with a timestamped GUID that Substack has never seen. Different URL, different GUID, different dedup bucket.\n\n**Quirk 2 — Cloudflare blocks GitHub Actions.** The live feed at `vibescoder.dev/syndicate.xml`\n\nreturns 403 when fetched from GitHub Actions runners. Same IP reputation issue that made Substack reject the feed in the first place — Vercel sits behind Cloudflare, and Cloudflare's bot protection doesn't love datacenter IP ranges. `curl`\n\nfrom a laptop works fine. `curl`\n\nfrom `ubuntu-latest`\n\non Actions gets a wall.\n\nThe automation lives as a GitHub Action in the content repo (where posts are pushed). On any push to `content/posts/`\n\n:\n\n`syndicate.xml`\n\n(with retry and user-agent headers to appease Cloudflare)`syndicate.xml`\n\nin the mirror, preserving existing GUID busts from prior imports`single-import.xml`\n\nwith a unique timestamped GUIDThe last step is manual — you paste the URL into Substack's import UI. Substack's import API exists but requires session authentication, and there's no official way to get a token. Fully automated posting would need the [ python-substack](https://github.com/ma2za/python-substack) library, which reverse-engineers the auth flow. That's a project for when I have more than one subscriber.\n\nFor now: push a post with `syndicate: true`\n\n, wait for the Action to run, paste one URL. Three minutes end-to-end, zero chance of forgetting to update the mirror.\n\n**Homelab:**\n\n**Vacation Hub:**\n\n**Substack Syndication:**", "url": "https://wpnews.pro/news/friday-fixes-housekeeping-the-homelab-and-hub", "canonical_source": "https://dev.to/carryologist/friday-fixes-housekeeping-the-homelab-and-hub-4961", "published_at": "2026-06-05 15:24:37+00:00", "updated_at": "2026-06-05 15:43:12.014550+00:00", "lang": "en", "topics": ["large-language-models", "ai-infrastructure", "ai-agents", "ai-tools", "ai-products"], "entities": ["llama.cpp", "Qwen", "nomic-embed", "Gemma", "Devstral", "DeepSeek", "RTX 5090", "GitHub Action"], "alternates": {"html": "https://wpnews.pro/news/friday-fixes-housekeeping-the-homelab-and-hub", "markdown": "https://wpnews.pro/news/friday-fixes-housekeeping-the-homelab-and-hub.md", "text": "https://wpnews.pro/news/friday-fixes-housekeeping-the-homelab-and-hub.txt", "jsonld": "https://wpnews.pro/news/friday-fixes-housekeeping-the-homelab-and-hub.jsonld"}}