Reproducible setup scripts for wimpy (Ryzen 7 7700 / 32GB / dual GPU:
AMD R9700 32GB + NVIDIA RTX 5060 Ti 16GB). Wimpy is the bare metal inference
and VM host, running both GPUs concurrently. See HARDWARE.md for full specs.
This is not a guide. It comes with no promise of support or assistance. It is merely a way for me to safely share my setup in a manner I can restore my system with and perhaps give others some inspiration.
| Step | Script | What it does |
|---|---|---|
| 01 | 01-system-base.sh |
Base packages, build tools |
| 02 | 02-docker.sh |
Docker CE + Compose |
| 04 | 04-vscodium.sh |
VSCodium |
| 05 | 05-llama-cpp.sh |
llama.cpp (ROCm/HIP gfx1201, R9700) → /usr/local + llama-swap on 0.0.0.0:8080 |
| 06 | 06-llama-cpp-cuda.sh |
Second llama.cpp (CUDA sm_120, RTX 5060 Ti) → isolated /opt/llama-cuda |
| 07 | 07-claude-code.sh |
Claude Code |
| 08 | 08-networking.sh |
br0 bridge on lan0 (MAC-pinned NIC name, DHCP), firewall open 8080 |
| 09 | 09-kvm.sh |
KVM / QEMU / libvirt / virt-manager, registers br0 as host-bridge |
Step 03 (postgres) is not on the host — it's manual. Hermes is not a host step either; it lives inside hermesvm01. Step 06 is optional: run it only if you want the RTX 5060 Ti serving models alongside the R9700 (see "Dual-GPU" below).
git clone <this-repo> ~/wimpy-setup
cd ~/wimpy-setup
chmod +x *.sh lib/*.sh
bash run-all.sh # run all steps
bash run-all.sh --from 05 # resume from step 5
bash run-all.sh --only 08 # run one step
bash run-all.sh --list # list stepsRun step 08 before 09 — the bridge must exist before libvirt can register it.
ip addr show br0— confirm it got an IP from DHCP- Add DHCP reservation in OPNsense (MAC is printed at the end of step 08)
- Add DNS records in Unbound (see DNS section below)
- Add model paths to
/etc/llama-swap/config.yaml sudo systemctl enable --now llama-swap- Re-login for docker/libvirt groups
- 4 vCPU, 16 GB RAM, 500 GB qcow2
- Network: host-bridge (br0)
- Install CachyOS + MATE desktop
bash hermesvm-setup.sh --hostname hermesvm01- Clone the hermesvm01 template in virt-manager
- Boot the clone
- Run
bash hermesvm-setup.sh --hostname <new-name>
The script is fully idempotent and parameterised — same script, different hostname.
# If your domain or port differs from defaults
bash hermesvm-setup.sh --hostname hermesvm02 --wimpy wimpy.home.lan --port 8080Two scripts recreate the hermes automation SSH setup on a fresh Linux install:
ssh-hermes-user.sh— per-host: creates the dedicatedhermesuser (no sudo by default) and writes the hardened sshd dropin/etc/ssh/sshd_config.d/99-hermes-automation.conf. The dropin'sAllowUsersline is merged, never replaced — existing entries are always preserved, so re-running the script (or running it with different--allowflags) can never lock a user out.ssh-trust-pair.sh— key exchange between two hosts: generates ed25519 keypairs for the admin user and thehermesuser if missing, then installs this host's pubkeys into the peer'sauthorized_keys(admin → admin, hermes → hermes). Run it on BOTH hosts — each run pushes one direction; the first push to a fresh peer prompts for the peer's password.
# On host A (as your admin user)
git clone <this-repo> ~/wimpy-setup && cd ~/wimpy-setup
sudo bash ssh-hermes-user.sh --user hermes --allow <admin-user-on-A>
bash ssh-trust-pair.sh --peer <host-B> --local-user <admin-user-on-A> --peer-user <admin-user-on-B>
# On host B (as your admin user)
sudo bash ssh-hermes-user.sh --user hermes --allow <admin-user-on-B>
bash ssh-trust-pair.sh --peer <host-A> --local-user <admin-user-on-B> --peer-user <admin-user-on-A>After both hosts have run the pair script, these are all passwordless:
hermes@A → hermes@B, hermes@B → hermes@A, <admin>@A → <admin>@B,
<admin>@B → <admin>@A.
Both scripts are idempotent and safe to re-run.
See DNS-DHCP-INSTRUCTIONS.md for step-by-step Unbound and dnsmasq config.
# On wimpy host
sudo systemctl status llama-swap
journalctl -u llama-swap -f
sudo virsh list --all
sudo virsh domifaddr hermesvm01 # get VM's IP after boot
# Inside hermesvm01
sudo systemctl status hermes
journalctl -u hermes -f
hermes doctor
curl http://wimpy.home.lan:8080/v1/models # verify wimpy reachable- Models are added one at a time with
fetch-model.sh(see below), which downloads the GGUF into~/.cache/llama.cpp/and registers it in the config. llama-swap-config.yaml— canonical source for/etc/llama-swap/config.yaml. After a model passes its smoke test and registration,fetch-model.shautomatically deploys this config through the narrow root-ownedllama-swap-deployhelper. Every model uses an explicit--modelpath to the downloaded file (one consistent method — no-hfre-downloads). Native GGUF context is used when it is at least 64000; smaller models receive an explicit--ctx-size 64000compatibility override, with flash attention + Q4 quantized KV cache. Split into two GPU groups (see "Dual-GPU" below): the R9700 entries pin by UUID +--device ROCm0, the-cudaentries use/opt/llama-cuda/bin/llama-server+--device CUDA0. On the 32GB R9700 all MoE experts now fit on the GPU (--n-cpu-moe 0); if you re-tune for tighter VRAM, smoke-test the count per hardware/context.fetch-model.sh— multi-source GGUF acquisition pipeline. Accepts a Hugging Face paste, a direct HTTP(S) URL, or a local.ggufpath. Covers inspection, metadata validation, GPU smoke-test, registration, and automatic deployment. It does not commit or push repository changes; review those changes manually. See "Adding a model" below.model-inventory.html— generated tracked inventory: llama-swap alias, filename, added date, GGUF architecture/native context, description, and effective custom llama.cpp parameters.llama-swap.service— the systemd unit (also installed by05-llama-cpp.sh).
./fetch-model.sh [options] <spec>Source classes:
| Class | Examples |
|---|---|
| Hugging Face | hf download hf://owner/repo/file.gguf 21, hf://owner/repo/file.gguf, https://huggingface.co/owner/repo/resolve/main/file.gguf |
| HTTP(S) URL | https://example.com/path/model.gguf (uses curl) |
| Local file | /abs/or/rel/path/to/model.gguf |
A trailing number (e.g. 21) sets --n-cpu-moe N — only accepted when GGUF
metadata confirms the model is MoE. Rejected for dense models.
Some vision-language GGUF repositories provide the image encoder/projector as a
separate mmproj-*.gguf file. fetch-model.sh handles this automatically for
Hugging Face model specs:
-
It inspects the Hugging Face repository file tree, not local GGUF tensor keys, to determine whether the repository ships an external projector.
-
It selects the repository's F16 projector when available, otherwise BF16 or the remaining projector candidate.
-
It downloads the projector into
MODELS_DIRand renames it to:<base-model-filename-without-.gguf>.mmproj.ggufThis prevents collisions when several repositories all publish a file named
mmproj-F16.gguf. -
It adds
--mmproj <namespaced-projector-path>to the smoke-test command and the generated llama-swap registration. The projector path is also recorded in the model metadata sidecar asmmproj_path.
Example:
./fetch-model.sh 'hf download hf://owner/vision-repo/VisionModel-Q4_K_M.gguf'The resulting files are conceptually:
~/.cache/llama.cpp/VisionModel-Q4_K_M.gguf
~/.cache/llama.cpp/VisionModel-Q4_K_M.mmproj.gguf
The repository must be reachable through the Hugging Face API. Export
HF_TOKEN for private or gated repositories and for reliable API access. Do
not commit the token or projector binaries:
export HF_TOKEN='<token in your secret manager>'If projector resolution fails, registration stops rather than silently registering a vision model as text-only. To deliberately bypass projector handling—for example, when registering a text-only model from a repository that also contains vision artifacts—use:
./fetch-model.sh --no-mmproj 'hf download hf://owner/repo/model.gguf'--no-mmproj is an explicit override. Do not use it for a model that needs
image input. Direct HTTP(S) and local-file sources do not contain enough origin
metadata for automatic projector resolution; place and wire the matching
projector manually, or use the Hugging Face source form instead.
The resolver is implemented in
tools/resolve_and_fetch_mmproj.py. The
base model and projector must come from the same model repository/version. A
projector from another model family or size may load incorrectly or fail with a
dimension mismatch.
One-time automatic deployment setup:
Run this once as root on wimpy from the repository directory:
sudo ./install-llama-swap-autodeploy.shIt installs a root-owned, fixed-path deployment helper and a narrow sudoers rule. The helper backs up the live config, atomically installs the validated repository config, waits for llama-swap to expose every configured model ID, and rolls back automatically if live validation fails. The normal fetch path deploys automatically after smoke testing; it does not preload the model.
Key flags:
| Flag | Effect |
|---|---|
-n <id> |
Explicit model id (default: derived from filename) |
-c <ctx> |
Explicit context override (default/minimum 64000; must not exceed native context when native context is at least 64000) |
| `-d ROCm0 | CUDA0` |
--keep-source |
Retain original local file after pipeline success (default: deleted) |
--no-smoke |
Skip GPU smoke test |
--no-register |
Download and inspect only, skip config registration |
--no-deploy |
Skip automatic live-config deployment |
Error recovery: Mid-pipeline failures (inspect/validate/smoke/register) write
a *.dossier.md containing the stage, partial state, and a ready-to-run resume
command. Paste the fenced block to Hermes to recover. Bad specs (classify
failure) produce no dossier — that is a usage error, not a pipeline stall.
Idempotency: Re-registering the same model (same name, same repo/file) exits 0 with a warning. Registering the same name with different content exits 1 (name collision) — config and existing model are untouched.
Models directory: MODELS_DIR env var (default ~/.cache/llama.cpp/).
Tests: bash tests/run_tests.sh — uses stubs for hf, curl, and
llama-server; no network or GPU required.
fetch-model.sh relies on the hf CLI being logged in. Authenticate once:
hf auth login # paste a token from https://huggingface.co/settings/tokensDo NOT commit token files to the repo. .gitignore blocks *token* and *.env
for this reason. The token is stored by the CLI in ~/.cache/huggingface/.
Both GPUs serve inference at the same time, behind one llama-swap:
- Two llama.cpp builds, isolated. The ROCm/HIP build (
05-llama-cpp.sh) installs to/usr/local; the CUDA build (06-llama-cpp-cuda.sh) installs to/opt/llama-cuda. They must stay in separate prefixes — llama.cpp's HIP and CUDA backends share library filenames, so a shared prefix would clobber one. You can't combine both backends in a single binary. - One llama-swap, two groups.
llama-swap-config.yamldefinesamd-r9700(26 models,--device ROCm0) andnvidia-5060ti(19<16GBmodels,--device CUDA0). Both groups areexclusive: false, so one model per GPU can be resident at once and two agents run in parallel — one per card. Every model must belong to a group or it lands in the default exclusive group and breaks concurrency. - Same model, both cards. A
-cudaentry is the same GGUF as its AMD twin with the CUDA binary + pin, so either GPU can serve it (routed by model name). - Pinning. R9700 by stable UUID (
HIP_VISIBLE_DEVICES=GPU-…) so the Ryzen iGPU — which also enumerates as a ROCm device — can't be picked by accident; 5060 Ti byCUDA_VISIBLE_DEVICES=0. Both add--device …0as a hard-fail guard against silent CPU fallback.
See NETWORK-DIAGRAM.md for the host/VM bridge layout and traffic flow
(rendered diagram: network-diagram.svg).
01-system-base.sh 05-llama-cpp.sh 08-networking.sh
02-docker.sh 06-llama-cpp-cuda.sh 09-kvm.sh
04-vscodium.sh 07-claude-code.sh 10-create-vm-example.sh
run-all.sh lib/common.sh
fetch-model.sh llama-swap-config.yaml llama-swap.service
hermesvm-setup.sh (VM post-install, --hostname parameterised)
README.md CLAUDE.md HARDWARE.md
DNS-DHCP-INSTRUCTIONS.md NETWORK-DIAGRAM.md CHANGELOG.md
ssh-hermes-user.sh (per-host hermes account + hardened sshd dropin)
ssh-trust-pair.sh (bidirectional passwordless key exchange, run both ways)