One-command Podman rig for running local GGUF models on your own NVIDIA GPU. Builds a custom CUDA llama.cpp, serves every model behind one OpenAI-compatible endpoint, per-model config in JSON.
  • Shell 45.6%
  • Dockerfile 27.3%
  • Python 27.1%
Find a file
2026-09-14 02:48:24 -07:00
config Enable thinking wherever the model supports it 2026-09-14 02:48:20 -07:00
Models Podman container for AbliteratedSuperModelRunner 2026-09-13 14:14:49 -07:00
scripts Enable thinking wherever the model supports it 2026-09-14 02:48:20 -07:00
.gitignore Podman container for AbliteratedSuperModelRunner 2026-09-13 14:14:49 -07:00
AGENTS.md Enable thinking wherever the model supports it 2026-09-14 02:48:20 -07:00
compose.yaml Rename to gguf-rig for public release 2026-09-14 01:54:15 -07:00
Containerfile Full 50-series support, no context ceiling, AGENTS.md 2026-09-14 02:34:17 -07:00
LICENSE Rename to gguf-rig for public release 2026-09-14 01:54:15 -07:00
README.md Enable thinking wherever the model supports it 2026-09-14 02:48:20 -07:00
rig Enable thinking wherever the model supports it 2026-09-14 02:48:20 -07:00

gguf-rig

A one-command Podman rig for running local GGUF models on your own GPU.

It builds a custom CUDA llama.cpp from source, then serves every model in your models directory behind a single OpenAI-compatible endpoint, loading them on demand and handing the GPU back when idle. Per-model settings live in one JSON file, so the same image works on a 2060 or a 5090 with whatever models you happen to own.

./rig build && ./rig scan && ./rig up
curl localhost:8099/v1/models

The engine is AbliteratedSuperModelRunner, which needs a specific llama.cpp fork (turboquant KV) rather than upstream. That fork is compiled at image build time from the pin in the app repo, so a clone of this repo plus ./rig build is everything a new machine needs.

Why this exists

Running llama.cpp on a GPU well means getting a dozen details right: the CUDA arch for your card, a fork with the KV cache types your profiles ask for, a context window your VRAM can actually hold, and a way to stop a model hogging the card when you want to play a game. This packages all of that, with the measured reasoning for each choice written down rather than folklore.

GPU access is shared, not exclusive. The container uses CDI, so your desktop, games and other containers keep using the cards. VRAM is only held while a model is loaded.

Requirements

  • An NVIDIA GPU of compute capability 7.5 or newer (RTX 20-series onward) and a working driver on the host. Check with nvidia-smi.
  • Podman, plus the NVIDIA Container Toolkit for CDI.
  • ~15 GB of disk for the image and build, and an hour for the first build. Everything after that is cached.
# Ubuntu / Debian
sudo apt install podman nvidia-container-toolkit

# One-time, and again after every driver update
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
nvidia-ctk cdi list          # should list nvidia.com/gpu=all

Docker

The image builds and runs under Docker; only the rig wrapper is Podman-specific. Use --gpus all instead of the CDI device (verified working with the nvidia runtime):

docker build -t gguf-rig:latest --build-arg CUDA_ARCHS=120 -f Containerfile .
docker run -d --name gguf-rig --gpus all \
  -p 127.0.0.1:8099:8099 --ipc=host --ulimit memlock=-1 \
  -v /path/to/models:/opt/asmr/models:ro \
  -v "$PWD/config":/config -v "$PWD/logs":/opt/asmr/logs \
  gguf-rig:latest

One real difference: under Podman, rootless user mapping means files written to bind mounts come back owned by you. Under Docker they come back owned by root, including config/models.json, which you then cannot edit without sudo.

Quick start

./rig build                     # compiles llama.cpp with CUDA. Slow, once.

export RIG_MODELS_DIR=/path/to/your/models
./rig scan                      # writes config/models.json, sized to your GPU
./rig up                        # serves on 127.0.0.1:8099
./rig test                      # loads a model and asks for a reply

./rig build detects your card and compiles for exactly it. Nothing to look up. Weights are usually large and already somewhere else, so point at them rather than copying; if you leave RIG_MODELS_DIR unset it uses ./Models.

Do not symlink a GGUF into Models/. The container resolves the link inside its own namespace, where the target does not exist, so the model shows up in listings and then reads as absent. scan warns about this.

If port 8099 is taken (a non-containerized copy of the stack uses it too), set RIG_PORT=8098.

Point any OpenAI-compatible client at http://127.0.0.1:8099/v1:

curl -s localhost:8099/v1/models
curl -s localhost:8099/v1/chat/completions -H 'Content-Type: application/json' \
  -d '{"model":"local-qwen-9b","messages":[{"role":"user","content":"hi"}]}'

Setting this up with an AI agent? Point it at AGENTS.md.

Commands

Command Does
./rig build [--archs N] [--cuda TAG] [--jobs N] Build the image. Arch is auto-detected
./rig up / down / restart Lifecycle
./rig shell Interactive bash inside the running container
./rig status What is resident, VRAM, queue
./rig models What /v1/models advertises
./rig scan Regenerate config/models.json from your models directory
./rig unload Free the GPU now, keep serving
./rig logs -f Follow logs
./rig test End-to-end check against the running server

GPU access: shared, not passthrough

--device nvidia.com/gpu=all is CDI. It bind-mounts the driver libraries and /dev/nvidia* into the container. It is not VFIO passthrough: the host keeps full use of the cards, your desktop and games are unaffected, and other containers can use them too. VRAM is only occupied while a model is actually loaded, and llmd unloads after LLMD_IDLE_SECONDS (default 900).

If the GPU is not visible inside the container, regenerate the CDI spec on the host. This is needed after every driver update:

sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
nvidia-ctk cdi list

Per-model configuration

config/models.json is the whole model layer. It is a bind mount, so editing it needs no rebuild, only ./rig restart.

{
  "defaults": {
    "args": ["-ngl", "99", "-fa", "on", "--jinja", "--load-mode", "none"]
  },
  "models": [
    {
      "name": "qwen-9b-128k",
      "gguf": "Qwen3.5-9B-abliterated-Q5_K_M.gguf",
      "aliases": ["fast", "day"],
      "context": 131072,
      "args": ["-ctk", "f16", "-ctv", "f16"],
      "thinks": true,
      "note": "9B at 131k, f16 KV"
    },
    {
      "name": "qwen-9b-256k",
      "gguf": "Qwen3.5-9B-abliterated-Q5_K_M.gguf",
      "aliases": ["long"],
      "context": 262144,
      "args": ["-ctk", "turbo4", "-ctv", "turbo3"]
    }
  ]
}
Field Meaning
name Advertised as local-<name>
gguf Filename in ./Models
aliases Extra names clients may send. Must be unique across models
context Window per slot. Defaults to the GGUF's declared context
args Appended to defaults.args, passed to llama-server
thinks Usually omit. Detected from the chat template
kv_kib_per_token Usually omit. Computed from GGUF metadata

The same GGUF can appear repeatedly at different windows or cache types, which is how the upstream profile table works. defaults.args come first and a model's own args can override them, since llama-server honours the last occurrence of a repeated flag.

Thinking models

Reasoning is on by default wherever the model supports it, because that is where a reasoning model's quality comes from. Turning it off would quietly give you a worse model.

You do not have to declare it. scan reads tokenizer.chat_template from the GGUF and looks for enable_thinking, <think> or reasoning_content; omitting thinks from a hand-written config triggers the same detection. An explicit true or false always wins.

The one trap is the token budget. A thinking model writes into reasoning_content before it produces any content, so a request with a small max_tokens spends the whole budget reasoning and returns an empty string with finish_reason: "length". That reads as a broken server.

llmd handles this: below a 2000-token floor (LLMD_THINKING_FLOOR) it disables thinking for that one request, so a client asking for a short answer gets a short answer rather than nothing. Above the floor, reasoning is kept. Verified both ways: at max_tokens: 4096 the model reasoned for 1382 characters then answered, and at max_tokens: 200 it answered directly.

To force it off for a single request, send it yourself:

{"chat_template_kwargs": {"enable_thinking": false}}

Why kv_kib_per_token is derived, not guessed

llmd divides the VRAM budget by this number to decide how many concurrent slots fit. Too low and it promises more concurrency than the card can hold; that does not fail loudly, it silently pages through CUDA unified memory and reads as "the model got slow for no reason".

So it is computed from the GGUF's own metadata with the same formula as upstream's bin/kv-calc, including the hybrid-attention case where only one layer in full_attention_interval pays per-token KV. Verified against kv-calc on a real model: both report 32.00 KiB/token for the 9B at f16.

Pushing the window

There is no artificial context ceiling. scan picks the largest power-of-two window your card can actually pay for, bounded only by the model's own trained context, and you can raise it further by hand. 256k is verified working here on a 12 GB card; 512k and 1M are reachable with enough VRAM and the right cache type.

Three things decide how far you get.

1. The KV cache has to fit. Cost per token comes from the model's architecture, and scan reads it from the GGUF. A hybrid-attention model is dramatically cheaper than a dense one, because only the periodic full-attention layers pay per token:

podman exec gguf-rig python3 bin/kv-calc models/your-model.gguf

2. Quantise the cache for large windows. This is what the turboquant fork exists for. f16 at 262144 on a 9B is ~8 GiB of cache on top of the weights, which does not fit in 12 GB; turbo4/turbo3 does:

{ "name": "9b-256k", "gguf": "Qwen3.5-9B.gguf", "context": 262144,
  "args": ["-ctk", "turbo4", "-ctv", "turbo3"] }

Worth knowing: f16 is faster wherever it fits. Measured on the 9B, f16 and turbo are within 2% on an empty cache, but turbo costs ~40% once the window is actually full. Use turbo to reach a size f16 cannot, not as a default.

3. Above the model's declared context you need rope scaling. llama-server silently clamps every slot to n_ctx_train, so asking for more is not enough on its own: slots stay small and long prompts are rejected with "exceeds the available context size". llmd patches that metadata field for you via gguf-set-ctx, but that rewrites the GGUF in place, so the models directory must be writable:

RIG_MODELS_RO=0 ./rig up

Add the rope arguments in the profile, e.g. "args": ["--rope-scaling", "yarn", "--rope-scale", "2"].

Quality is the real limit, not loading. Retrieval on this stack held at 431k tokens up to mid-depth under YaRN 2x, but failed at 90% depth. A 1M window loads and runs; treat it as a ceiling for bulk ingest rather than a place to hide important facts.

Splitting across GPUs and CPU

Any llama-server flag works in args, so the usual placement tricks apply:

"args": ["-ts", "9,3"]          // split weights across two GPUs, 9:3 ratio
"args": ["-ot", "exps=CPU"]     // MoE experts in system RAM, attention on GPU
"args": ["-ngl", "20"]          // only 20 layers on the GPU, rest on CPU

-ot exps=CPU is how a 30B MoE runs on a 12 GB card at all. -ngl below the layer count is the general fallback when weights simply exceed VRAM; it works, and it is much slower, so it is never a generated default.

Two traps, both measured:

  • Never use --no-kv-offload. Putting the KV cache in system RAM measured 1.03 tok/s against 91.6 tok/s with it on the GPU, roughly 90x. Overflow is handled by CUDA unified memory instead, which pages on demand rather than routing every token over PCIe.
  • A second GPU is not free. Whichever card holds a tensor runs that tensor's math, so a slow card on a narrow PCIe link drags generation down. On this machine, letting a 2060 on a gen1 x4 link participate cost ~40%. Some older cards also lack kernels for newer quant types and abort at load.

Other GPUs

./rig build reads your cards from nvidia-smi and compiles for exactly those, so normally there is nothing to choose:

./rig build                      # detects, e.g. "archs 120" on a 5070 Ti

Override when you are building for a machine other than this one, or want to trim the image:

./rig build --archs 120          # every RTX 50-series card
./rig build --archs "89;120"     # a mixed 40 + 50 fleet
Card Arch
RTX 2050 - 2080 Ti (Turing) 75
RTX 3050 - 3090 Ti (Ampere) 86
RTX 4050 - 4090 (Ada) 89
RTX 5050, 5060, 5060 Ti, 5070, 5070 Ti, 5080, 5080 Super, 5090 120

The entire RTX 50-series is one arch. Every GeForce Blackwell part, from the 5050 to the 5090 and including every Ti and Super variant, is compute capability 12.0, so --archs 120 covers all of them and a build for one runs on any other. (sm_121 exists but is DGX Spark, not a GeForce card.)

RTX PRO Blackwell workstation cards are also 12.0, so they work with the same 120 build.

Two constraints worth knowing:

  • 50-series needs CUDA >= 12.8. The default toolkit here is 13.2.1, so this only matters if you pin an older --cuda by hand; rig refuses that combination rather than letting nvcc fail an hour in. Note that some reports find CUDA 13.1 specifically slow or crash-prone on sm_120, which is why the default is 13.2.1.
  • CUDA 13 dropped everything below compute 7.5. Maxwell, Pascal and Volta cannot be targeted at all and need an older toolkit:
./rig build --cuda 12.9.1-devel-ubuntu24.04 --archs 61

Making changes inside the container

./rig shell

Lands in /opt/asmr as root, with the full source tree and toolchain. The app is a git checkout, so you can edit, rebuild and test in place:

vi bin/llmd
cmake --build src/llama.cpp/build -j"$(nproc)"   # after a llama.cpp edit

Changes inside the container are lost when it is removed, since the code lives in the image, not on a mount. That is deliberate: the image is reproducible. Make a change permanent by pushing it upstream and running ./rig build, or by bind-mounting your own checkout over /opt/asmr while developing.

./rig up also runs llama-server and every upstream tool:

podman exec -it gguf-rig llama-server --help | grep turbo
podman exec -it gguf-rig python3 bin/kv-calc models/your-model.gguf

Layout

Containerfile                 image: CUDA base + llama.cpp fork build
rig                           the wrapper you actually use
AGENTS.md                     setup procedure for an AI agent
compose.yaml                  for existing compose stacks
scripts/entrypoint.sh         GPU check, config generation, launches llmd
scripts/profiles_overlay.py   replaces llmd's hardcoded PROFILES with JSON
config/models.json            per-model configuration (generated by scan)
config/models.example.json    a tuned reference config
Models/                       default models dir; see RIG_MODELS_DIR
logs/  kv-slots/              llmd logs and KV snapshots

Build notes and traps

These two cost an hour each to discover. Both are container-specific and neither happens when building on a normal machine with a driver installed.

undefined reference to cuMemCreate at the final link. A *-devel image ships the CUDA toolkit but deliberately has no driver: the real libcuda.so.1 belongs to the host and is injected at run time by CDI. ggml-cuda calls the driver API and links it PRIVATE, which satisfies libggml-cuda.so itself but does not propagate, so linking llama-server against it fails with a dozen unresolved cuMem*/cuDevice* symbols. This is llama.cpp#23357, open and not fork-specific. The fix is to link the stub explicitly:

-DCMAKE_EXE_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs -lcuda"
-DCMAKE_SHARED_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs -lcuda"

Safe because the stub's SONAME is libcuda.so.1, so the recorded NEEDED entry is the real driver's name. What must never happen is the stubs directory reaching the runtime search path: the process would then bind the do-nothing stub instead of the injected driver and every CUDA call would fail. The build asserts against that with readelf.

llama-server --help cannot run at build time. The verification step originally just ran --help | grep turbo2 and failed, which reads as "the build has no turbo KV" and sends you hunting the wrong fork. The binary was fine; the dynamic loader could not resolve libcuda.so.1 and killed the process before main(). LD_LIBRARY_PATH=$CUDA_STUBS for that one command fixes it, and is never set at runtime.

Verification is a separate layer from the compile. The compile is ~50 minutes of nvcc. When an assertion lived in the same RUN, a failed check discarded the whole layer and recompiled from scratch. Split, a failed check costs seconds.

BUILD_JOBS defaults to three quarters of the cores via ./rig build, not all of them. A full-core build pins every thread for most of an hour on a machine that is also a desktop, and the wall-clock saving is small because the build is bandwidth-bound before it is thread-bound. Override with --jobs N.

Design notes and traps

Why the profiles are overlaid rather than forked. Upstream bin/llmd has a PROFILES tuple hardcoded to four GGUF filenames and to a specific 4070+2060 box. In a shared image that means /v1/models advertising four files most people do not have. scripts/profiles_overlay.py is imported before llmd.main() and rebuilds llmd.PROFILES from JSON. Nothing is patched, since PROFILES is read at request time by resolve() and /v1/models.

bin/llmd has no .py suffix, so it cannot be imported normally. The entrypoint loads it with SourceFileLoader, which also means main() is not invoked at import and the overlay can be applied first.

Models is mounted read-only by default. Loading a profile can call gguf-set-ctx, which rewrites the GGUF's declared n_ctx_train in place via a writable mmap, because llama-server otherwise silently clamps every slot to that value. Useful upstream, surprising when it edits weights you copied in. Set RIG_MODELS_RO=0 ./rig up to allow it; contexts above the model's declared value will not otherwise take effect.

The build verifies turbo KV and fails if absent. Upstream ggml-org/llama.cpp has no turbo2/3/4 cache types, and a build without them gets all the way to serve time before dying on an unknown -ctk. The image checks llama-server --help for turbo2 at build time instead.

The llama.cpp pin is read from the app repo, not duplicated here. The Containerfile greps src/LLAMA-CPP-PIN.md for the remote, tag and branch, and fails loudly if any comes back empty. Bumping the pin upstream needs no edit here.

--restart unless-stopped plus a 30s stop timeout. llmd handles SIGTERM so it can stop llama-server and release VRAM; killing it faster orphans a process holding the GPU.

Never publish this on 0.0.0.0 casually. There is no authentication. RIG_BIND exists but loopback is the default for a reason.

Verified on

Built and run end to end on 2026-09-13, Ubuntu 26.04, Podman 5.7.0 rootless, driver 595.84, RTX 4070 Super + RTX 2060, --archs 75;89:

Check Result
Image build 8.83 GB, ~50 min at 9/12 jobs
turbo2/3/4 KV types present (asserted during the build)
GPUs inside the container both visible via CDI
Arch auto-detection reported 75;89, matching a hand-built image bit for bit
9B Q5_K_M at 131k (f16 KV) 11.3 GiB VRAM, replied over /v1/chat/completions
9B Q5_K_M at 262k (turbo KV) n_ctx_slot = 262144, replied correctly
KV auto-derivation 32.00 KiB/token, matches bin/kv-calc exactly
VRAM-aware scan 65,536 for the 9B, warned that a 12.6 GiB model exceeds 12 GiB
./rig unload VRAM back to 1.4 GiB (desktop only)
./rig down container removed, no orphaned llama-server
Docker --gpus all both GPUs visible (build not run end to end)
Host impact desktop and a host llmd on :8099 unaffected throughout
Public clone anonymous clone rebuilt to an identical image hash

Environment variables

Variable Default Effect
RIG_PORT 8099 Host port
RIG_BIND 127.0.0.1 Host interface
RIG_MODELS_DIR ./Models Where the GGUFs really are
RIG_GPUS all CDI selector, e.g. 0 for one card
RIG_MODELS_RO 1 Mount ./Models read-only
LLMD_IDLE_SECONDS 900 Unload after this much idle time
LLMD_CUDA_DEVICES 0 Which visible GPU serves
LLMD_MAX_CONCURRENT 4 Slot ceiling, VRAM permitting
LLMD_DESKTOP_RESERVE_MIB 1500 VRAM left for the desktop