โ How it works ๐ Results โ Honest status
01 Overview
An end-to-end system that lets the home server fine-tune small open language models on its own telemetry and docs, then serves them through the LiteLLM proxy it already runs. The idea was a free local ops model that knows this specific machine, plus a cloud dev model with the same knowledge baked in โ both fed by a single training-data pipeline that collects, sterilizes, and synthesizes the server's operational history. It is deliberately headless: there is no UI of its own, only models registered behind an API.
Ops-history flywheel
Continuously harvests metrics, docker events, GPU-latency probes, and task summaries; sterilizes secrets out; synthesizes ~3,927 deduped instruction pairs.
Ollama ops brain
QLoRA on Llama-3.2-3B and Llama-3.1-8B, plus a DPO alignment pass on the 8B (the home-server-controller-dpo variant); merged, converted to GGUF Q4_K_M, and registered in Ollama as a LiteLLM fallback tier.
Qwen dev brain
LoRA SFT on Qwen2.5-32B via Together AI, served from a dedicated 2รH100 endpoint.
02 Why I built it
The plan put it plainly: "two models, two jobs, one LiteLLM proxy" โ a free local ops model and a paid cloud dev model, meant to take over part of the day-to-day Claude and Gemini API spend. Two motives beyond cost drove it. The first was graceful degradation: if the internet or the GPU-over-IP node goes down, the server should still have something that knows how it is wired, rather than failing outright. The second was owning the whole loop โ every improvement to the collected data makes both models a little better, without depending on anyone else's dataset. The plan's own estimate was modest: roughly $5โ7 one-time and $15โ50/month.
03 What I built & how it works
Collect the server's own exhaust, scrub it, turn it into training pairs, fine-tune on rented GPUs, and serve the result behind the existing proxy.
Fig. 1 โ one dataset feeds two training tracks; both land in the same proxy, with a fallback chain that drops from cloud to local when the network or GPU node is unavailable.
- Collect โ four collectors (two cron jobs, a systemd docker-events stream, a 6-hourly snapshot) log the server's own telemetry, each with rotation and unmounted-drive guards.
- Sterilize โ regex redaction of keys, IPs, emails, and hashes, plus dedup โ then a second secret scan that re-checks its own output and records any violations.
- Synthesize โ 16 generator categories mix real collected data with templated scenarios (VRAM-fit math, degraded-mode routing) into ~3,927 deduped instruction pairs.
- Train โ Track A runs QLoRA on a rented GPU (plus a DPO alignment pass on the Llama-3.1-8B controller), then merges and quantizes to GGUF Q4_K_M for Ollama; Track B runs LoRA SFT on Together AI.
- Serve โ every model is registered in LiteLLM with provenance comments and fallback chains, and a callback injects a static home-server facts file into any request that names one of these models.
04 ๐ Skills & tech used
05 Notable challenges & decisions
Most of the real work was in the plumbing around the models, not the training itself.
A triple pivot in a single day
A platform-wide RunPod outage hit mid-project. The training moved RunPod โ a local Windows GPU (QLoRA) โ Vast.ai in one day, each environment with its own quirks (Windows multiprocessing guards, a TRL API rename, gated-model and library issues on Vast.ai). The 3B model finished on a Vast.ai RTX 4090; the abandoned local-GPU run had been killed part-way.
Quantizing a 16 GB checkpoint on the server's CPU
The GPU-over-IP node dropped out (a STUN failure), stranding the models off the RTX 3080. Rather than wait, I built llama.cpp on the home server itself and did the BF16 convert and Q4_K_M quantize of the 16 GB merged checkpoint entirely on CPU, then re-registered the models on a CPU Ollama with num_gpu 0.
A static facts file, after vector RAG wouldn't load
On a narrow 20-question home-server-facts benchmark, the fine-tune paired with a static-facts injection scored 100% vs 5% for base Llama-8B. The embeddings RAG index I first built could not be imported inside the LiteLLM container, so I dropped it for a plain facts file injected by a callback โ simpler, and it scored the same 100%.
An automated step that failed, on the record
The auto-registration watchdog waited on the wrong path and reported 8B FAILED / 3B TIMEOUT in its own summary; manual registration succeeded later. The sterilizer self-audits its output for leaked secrets โ though the scripts themselves still carried hardcoded keys that must be redacted before any screenshot.
The unglamorous decision that mattered most: terminate the pod on every exit path. Track A's self-managed RunPod pods were chosen for price (an RTX 3090 at $0.22/hr) and torn down on every failure branch. Track B was a different shape entirely: a fully managed Together AI fine-tune with no pod to tear down, and that whole cloud LoRA of a 32B model came to $7.11.
06 Results
Sources: session summaries, live pipeline checks, and LiteLLM config comments. The benchmark is a small, self-authored home-server-specific test, not a general capability measure.
07 Screenshots
No live UI to capture โ the honest media here is terminal output. Placeholders below; screenshots would need secrets redacted first.
td-status.sh โ colorized pipeline health report
per-question โ / โ table ยท 5% โ 100%
LiteLLM model registry with provenance comments
08 Honest status
The training-data pipeline is the part that is genuinely alive: the collectors still run on cron and systemd, and the collected corpus has grown to about 5.6 GB. The model-serving side, though, is dormant. No Ollama instance is currently running, so the fine-tuned local models are registered but not being served, and there has been no retrain since March 2026 despite the newer data sitting unused. The cloud-hosted variants may still respond through LiteLLM if the Together account and credits remain valid, but that was not exercised during this review to avoid spend. Everything here is LAN-only, has no UI of its own, and would need the hardcoded keys in its scripts removed before any of it were shared. Treat this as a working prototype that reached "five model variants trained and wired in," then settled into passive data accumulation.
llm-finetune โ Build Recipe
Fine-tune a small LLM to be a "cluster controller" for your own home server: a data flywheel turns the machine's own telemetry and docs into an instruction dataset, a rented cloud GPU does a QLoRA fine-tune of Llama-3.1-8B (served locally as a 4-bit GGUF in Ollama), and an optional cloud track runs LoRA SFT + DPO alignment on Together AI. The result is a local model that answers questions about that specific machine โ its services, ports, storage layout, and failure playbooks.
Status: โ ๏ธ Assembled and de-identified 2026-08-02 from the original working scripts; not re-executed for this recipe. The scripts are the real ones that produced five model variants in March 2026 (numbers below are from those runs). What was re-verified recently: the rent โ bootstrap โ train โ retrieve โ terminate GPU-rental pattern these scripts depend on was re-run for real on 2026-07-31 (RunPod SECURE RTX 3090, ~$0.10, 10โ15 min) as part of the
bw-pa-classifierrecipe's Tier-3b retrain โ same provider, same pod lifecycle.Recipe-only project โ no runnable clone ships, deliberately. This deviates from the usual 3-tier container template because the two artifacts you'd expect in a clone can't be shipped: the fine-tuned weights encode the original server's real topology (including IPs) by design, and the dataset is machine-specific by definition. You reproduce the workflow on your own machine and accounts, with the real (de-identified) scripts shipped alongside.
Sensitive data: the originals hardcoded a RunPod API key in one script and a local gateway key in the benchmark; shipped copies take all keys from environment variables only (see
.env.example). Server hostnames, LAN/ Tailscale IPs, usernames, account IDs, and personal mount paths were replaced with placeholders or neutral paths throughout.
What you can actually do
Three independent workflows. Each stands alone โ do any one of them.
| Workflow | What it produces | Accounts needed | Measured cost |
|---|---|---|---|
| A โ dataset flywheel | an instruction-tuning dataset built from your own Linux box | none (no GPU either) | $0 |
| B โ QLoRA on a rented GPU โ Ollama | a Q4_K_M GGUF served locally | RunPod (or Vast.ai), a few dollars | 8B: ~2โ3 h @ $0.22/hr (RTX 3090); 3B: 180 steps in 38:26 (RTX 4090) |
| C โ Together AI SFT + DPO | a cloud-hosted LoRA fine-tune, optionally quantized locally on CPU | Together AI | 32B LoRA SFT: $7.11 measured; 8B SFT + DPO is cheaper |
Zero-account path: Workflow A runs on any Linux machine with cron and
python3 โ no keys, no GPU, no cloud. It is the honest starting point, and its
output is what B and C consume.
Shipped files
llm-finetune/
โโโ RECIPE.md โ this file
โโโ .env.example โ key placeholders (env-vars only, never hardcoded)
โโโ collect-prep/ [A] build + sterilize + format the dataset
โ โโโ td-collector.sh cron collector: /proc metrics + docker stats โ JSONL
โ โโโ td-sterilize.py redact โ dedup โ self-audit (second independent scan)
โ โโโ td-synthesize.py 2,400-line generator: 16 categories of Q&A/scenarios
โ โโโ td-format-for-cloud.py โ Llama-3 chat-template "text" format (for B)
โ โโโ td-format-for-together.pyโ OpenAI messages + 5% eval split + token report (for C)
โโโ train-local-gpu/ [B] rented-GPU QLoRA โ GGUF
โ โโโ train.py Llama-3.1-8B QLoRA (Unsloth, r=16/ฮฑ=32, 3 ep, seed 3407)
โ โโโ train-3b.py Llama-3.2-3B variant (r=32/ฮฑ=64, 5 ep, packing)
โ โโโ runpod-full-pipeline.sh one script: SSH-wait โ deps โ upload โ train โ GGUF export โ download โ terminate
โโโ together-dpo/ [C] cloud SFT + DPO chain
โ โโโ together-upload.sh upload train/eval files (SDK, not curl โ see gotchas)
โ โโโ together-finetune.sh launch LoRA SFT (--dry-run mode; pricing notes in header)
โ โโโ together-sft-optimized.py rerun with tuned hyperparameters (cosine, warmup, r=32)
โ โโโ together-dpo-prep.py build preference pairs: your answers = chosen; base-model @ temp 0.9 = rejected
โ โโโ together-dpo-train.py launch the DPO job (beta 0.1, 3 epochs)
โ โโโ together-dpo-coordinator.sh glue: wait for prep โ upload โ fire DPO
โ โโโ together-live-monitor.py loss-plateau watcher; auto-continues +6-epoch chunks, 30-epoch cap
โ โโโ together-download-and-convert.sh merged checkpoint โ BF16 โ Q4_K_M **entirely on CPU**
โโโ eval-serve/ serve + check
โโโ Modelfile Ollama packaging (ctx 4096, temp 0.3, Llama-3 stop tokens)
โโโ test-controller.sh 5-test keyword verification harness (edit tests for your infra)
โโโ together-benchmark.py 20-question keyword-scored eval, base vs SFT vs DPO
Not shipped (and why): the trained GGUF/safetensors weights (they memorize the
original server's facts, including addresses โ that is the whole point of the
project, and exactly why they can't be published), the dataset (same reason),
the original machine's database extractor (td-extract.sh โ every line was
specific to that box's apps; write your own against whatever SQLite/logs you have),
and a parallel legacy normalizer that the flywheel superseded.
Workflow A โ the dataset flywheel (any Linux box, $0)
Prerequisites: a Linux machine you administer, python3 (3.10+), optionally
docker (for container stats) and nvidia-smi (for GPU metrics โ both degrade
gracefully if absent).
- Collect. Run
collect-prep/td-collector.shonce by hand, then via cron every 5 minutes if you want a real time-series:
*/5 * * * * $HOME/llm-finetune/collect-prep/td-collector.sh
It appends envelope-format JSONL (CPU, RAM, swap, load, disk, GPU, per-container
stats) to ~/training-data/collected/, rotating files at 50 MB. If your output
dir is on a removable mount, set DATA_MOUNT=/your/mount so an unmounted disk
skips the run instead of quietly filling the root filesystem (that failure mode
is why the guard exists).
-
Sterilize. Edit the
REDACTIONSlist at the top ofcollect-prep/td-sterilize.pyโ add the prefixes of your internal keys, your username, your hostnames (the shipped list has generic patterns plus clearly markedYOUR_...placeholders). Then run it. It redacts, dedups by record id, and finishes with a second, independent secret scan over its own output โ keep that check list in sync with your redaction list. -
Synthesize. This is the heart of the flywheel and the biggest editing job: open
collect-prep/td-synthesize.pyand replace the KNOWLEDGE BASE constants with your own infra facts (the header comment marks everything to change โ container list, node specs, hardware/network/storage facts, and the hand-written Q&A answers). As shipped they are neutralized placeholders; train on them verbatim and your model will confidently describe a server that does not exist. The reusable part is the structure: 16 generator categories turn your telemetry -
facts into a few thousand instruction/input/output pairs. The original run produced 5,884 raw pairs โ 3,927 after dedup (3,534 train / 393 val).
-
Format. Two exporters, two targets:
td-format-for-cloud.pyโ single{"text": ...}JSONL in the Llama-3 chat template (what Workflow B's trainers eat). Edit itsSYSTEM_PROMPTto describe your server.td-format-for-together.pyโ OpenAImessagesformat with a 5% eval split and a token/cost report (what Workflow C uploads; original split: 3,886/204).
Workflow B โ QLoRA on a rented GPU, served in Ollama
Prerequisites: a RunPod account (Vast.ai also works โ the scripts are
provider-thin: SSH + scp + one terminate call) with a few dollars of credit; an
ed25519 keypair registered with the provider (the shipped script expects the
private key at ~/.ssh/cloud_gpu โ edit SSH_KEY at the top if yours lives
elsewhere); ollama installed locally to serve the result.
export RUNPOD_API_KEY=... first โ the shipped script refuses to run
without it. No local GPU needed at any step.
- Automated path: rent a pod (RTX 3090-class, โฅ16 GB VRAM, CUDA 12.1 PyTorch image), then
bash
./train-local-gpu/runpod-full-pipeline.sh <pod_id> <pod_ip> <ssh_port>
One script does everything: wait-for-SSH, install Unsloth/TRL, upload your
combined_formatted.jsonl, train the 8B (3 epochs โ 1,473 steps, ~2โ3 h at
$0.22/hr on the original run), export to Q4_K_M GGUF on the pod, download,
train + export the 3B variant, and terminate the pod on every exit path โ
an EXIT trap covers timeout, training death (3-strike process check), setup
failures, and success alike. A forgotten pod
bills all night; the guaranteed-terminate design is not optional politeness.
-
Manual path:
scpthe data up, runtrain.py(8B) ortrain-3b.py(3B) yourself in tmux,scpthe GGUF home, terminate the pod in the console and verify it's gone. If the GGUF export step at the end oftrain.pyfails (see the transformers 5.x note below), use theexport_v3.pyapproach embedded inrunpod-full-pipeline.shinstead. -
Serve locally: put the GGUF next to
eval-serve/Modelfileand
bash
ollama create homelab-controller -f eval-serve/Modelfile
ollama run homelab-controller "what's on this server?"
Then edit the five example tests in eval-serve/test-controller.sh to ask about
your facts and run it.
-
Original measured results: 8B โ ~4.6 GB Q4_K_M GGUF (that size was measured on the Together-track CPU conversion of the same 8B). 3B โ 180/180 steps in 38:26 on an RTX 4090 (finished on Vast.ai after a RunPod platform outage forced a provider swap mid-project), final loss 0.26, token accuracy 0.923, 1.9 GB GGUF โ small enough to run CPU-only on a modest server.
-
Why the pipeline exports the way it does: transformers 5.x broke
PeftModel.merge_and_unload, so the embeddedexport_v3.pyloads the LoRA through Unsloth natively, saves merged safetensors, and calls llama.cpp's converter + quantizer directly. That workaround is the pipeline's most transferable lesson.
Workflow C โ Together AI SFT + DPO
Prerequisites: a Together AI account; pip install together; your key in
~/.env.together (see .env.example). The .sh helpers source that file
themselves, but the Python steps read TOGETHER_API_KEY from the environment โ
run set -a; source ~/.env.together; set +a (or export TOGETHER_API_KEY=...)
before them. No GPU anywhere โ even the final
quantization runs on CPU.
together-upload.shโ uploads train/eval files and records the file IDs. Gotcha: curl multipart against the files endpoint fails; the script uses the Python SDK for exactly that reason.together-finetune.sh(start with--dry-run) โ launches the LoRA SFT. Gotcha in the header: Qwen2.5-Coder-32B is not LoRA-supported on Together; the general Qwen2.5-32B-Instruct is. The original 32B run cost $7.11; the Together-trained 8B controller's v2 reached eval loss 0.2891 โ about 23% lower than the local 8B track, at the price of living in someone else's cloud.together-live-monitor.py <job_id> <train_file_id> <eval_file_id>โ watches eval loss per epoch and auto-continues in +6-epoch chunks until the drop falls under 5% (30-epoch hard cap).- DPO:
together-dpo-prep.pybuilds preference pairs โ your dataset's answers as chosen, base Llama-3.1-8B-Turbo at temperature 0.9 as rejected (high temp makes the base model drift, which is the contrast you want). The original run kept 497 pairs; DPO then ran at beta 0.1 for 3 epochs (together-dpo-train.py, ortogether-dpo-coordinator.shto chain it after prep). Gotchas (both tripped the original build): the shipped prep script emits simple{prompt, chosen, rejected}lines, which Together's validator rejects โ convert them to thePREFERENCE_OPENAIstructure in the current docs before uploading. And check which models DPO accepts: at the time it applied to base models only, not fine-tuned checkpoints like the SFT outputtogether-dpo-train.pypoints at. - Optional:
together-download-and-convert.shโ pulls the ~16 GB merged checkpoint and converts BF16 โ Q4_K_M on CPU only (builds llama.cpp locally), so the cloud fine-tune can still end up in your local Ollama.
Evaluating honestly
eval-serve/together-benchmark.py is a keyword-scored recall test: ~20 questions
about your server, each with a keyword the right answer must contain. Write your
own questions (the shipped ones are placeholders showing the shape). On the
original build the fine-tuned model + static-facts injection scored 100% vs 5%
for base Llama-3.1-8B โ a big gap, but read it for what it is: a narrow,
self-authored recall test of facts the model was trained on, not a capability
measure. A base model has no way to know a private server's port map; the test
confirms the knowledge transferred, nothing more.
Known limitations (stated honestly)
- Nothing here was re-executed for this recipe (2026-08-02 assembly). Costs, step counts, and losses are from the March 2026 runs; cloud pricing and both providers' APIs drift. The GPU-rental pattern itself was re-verified 2026-07-31 (see Status).
- No weights, no dataset ship โ both are irreducibly specific to the original machine. Your results depend on the dataset you build in Workflow A.
- The original system served the model through a multi-provider LLM gateway with fallback routing; that layer is deliberately stripped โ the recipe serves via plain Ollama (or any OpenAI-compatible endpoint) to stay dependency-free.
- The original's live serving side has been dormant since March 2026; this recipe reproduces the build, not an in-production service.
td-synthesize.py's value is its structure; the shipped knowledge base is neutralized and must be replaced โ there is no shortcut around encoding your own facts.- Cut from the original on purpose (machine-specific, not needed on a fresh box): a GPU-over-IP sidecar, a local-GPU fallback trainer, and the original's app-database extractors.