โ† Kevin Yoder
AI / ML Tooling

Local LLM Fine-Tuning โ€” "home-server brain"

A data-flywheel that harvests the homeserver's own operational exhaust, sterilizes it, and fine-tunes small open models โ€” a free local ops brain and a cloud dev brain โ€” served behind one LiteLLM proxy.

Built Mar 2026 Pipeline still harvesting Model serving dormant

โš™ 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.

Data ยท shared

Ops-history flywheel

Continuously harvests metrics, docker events, GPU-latency probes, and task summaries; sterilizes secrets out; synthesizes ~3,927 deduped instruction pairs.

Track A ยท local, free

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.

Track B ยท cloud

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.

home-server operational exhaust Collect cron + systemd · continuous td-collector · td-juice-latency · td-docker-events · distill-capture metrics · docker events · GPU-latency · LLM traces · task summaries · configs Sterilize td-sterilize.py redact keys/IPs/emails · dedup · self-verify Synthesize td-synthesize.py 16 generator categories · real data + templates → ~3,927 deduped Alpaca / ChatML pairs Track A local · free QLoRA 3B / 8B · +DPO on 8B merge → GGUF Q4_K_M → Ollama Track B cloud LoRA SFT · Qwen2.5-32B Together AI · 2×H100 endpoint LiteLLM proxy :4000 model groups + fallback chains · cloud → local on outage + callback injects a static home-server facts file

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.

  1. 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.
  2. 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.
  3. Synthesize โ€” 16 generator categories mix real collected data with templated scenarios (VRAM-fit math, degraded-mode routing) into ~3,927 deduped instruction pairs.
  4. 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.
  5. 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

Languages
Bash (advanced)PythonSQL / SQLitePowerShell / cmd over SSH
ML / AI
QLoRA (Unsloth)LoRA SFTDPO alignmenttransformers + PEFT + TRLGGUF + Q4_K_M quantizationRAG (embeddings + static-facts)instruction-dataset engineeringVRAM-fit model sizing
Infra / Ops
LiteLLM proxyOllama ModelfilesRunPod ยท Vast.ai ยท Together AIcron + systemd unitsllama.cpp CPU buildJuice GPU-over-IP
Data
JSONL envelope schemasecret redaction + self-auditdeduptrain / eval splitsAlpaca ยท ChatML ยท OpenAI-messages
Techniques
data flywheel / self-distillationunattended state-machine pipelinesself-removing cron watchdogterminate-pod-on-every-exit

05 Notable challenges & decisions

Most of the real work was in the plumbing around the models, not the training itself.

Provider outage

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.

Serving without a GPU

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.

Grounding beats size

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%.

Honest artifacts

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

0.26
final training loss, 3B on a Vast.ai 4090
0.923
3B token accuracy (training)
1.9 GB
shipped 3B GGUF, Q4_K_M
~3,927
deduped instruction pairs
$7.11
cost of the 32B cloud LoRA fine-tune
100%
20-question home-server-facts benchmark (vs 5% base Llama-8B)

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.

[ terminal ]
td-status.sh โ€” colorized pipeline health report
The data flywheel at a glance: per-phase record counts, an Alpaca-format validator, a secret-scan check, and a storage report.
[ benchmark ]
per-question โœ“ / โœ— table ยท 5% โ†’ 100%
The 20-question benchmark run: base model against the fine-tune-plus-facts variant.
[ config ]
LiteLLM model registry with provenance comments
The model list reads like a changelog โ€” each entry carries its job ID, loss, and hardware.

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-classifier recipe'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).

  1. Collect. Run collect-prep/td-collector.sh once 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).

  1. Sterilize. Edit the REDACTIONS list at the top of collect-prep/td-sterilize.py โ€” add the prefixes of your internal keys, your username, your hostnames (the shipped list has generic patterns plus clearly marked YOUR_... 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.

  2. Synthesize. This is the heart of the flywheel and the biggest editing job: open collect-prep/td-synthesize.py and 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

  3. 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).

  4. Format. Two exporters, two targets:

  5. td-format-for-cloud.py โ†’ single {"text": ...} JSONL in the Llama-3 chat template (what Workflow B's trainers eat). Edit its SYSTEM_PROMPT to describe your server.
  6. td-format-for-together.py โ†’ OpenAI messages format 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: scp the data up, run train.py (8B) or train-3b.py (3B) yourself in tmux, scp the GGUF home, terminate the pod in the console and verify it's gone. If the GGUF export step at the end of train.py fails (see the transformers 5.x note below), use the export_v3.py approach embedded in runpod-full-pipeline.sh instead.

  • Serve locally: put the GGUF next to eval-serve/Modelfile and

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 embedded export_v3.py loads 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.

  1. 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.
  2. 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.
  3. 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).
  4. DPO: together-dpo-prep.py builds 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, or together-dpo-coordinator.sh to 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 the PREFERENCE_OPENAI structure 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 output together-dpo-train.py points at.
  5. 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.