Running a large language model does not need a big cluster. With a single NVIDIA GPU, a quantized GGUF model, and llama-server, you can serve an OpenAI-compatible API for your apps, tools, and team.
In this guide, you will set up llama.cpp speculative decoding on a real server and make it faster without buying more hardware. If you want a fast start, you can also look at AI hosting options built for this kind of work.
Prerequisites
Before you start, make sure you have the items below. They are all simple, and you can set most of them up in a few minutes:
- A Linux server with Ubuntu 24.04 or newer and sudo access.
- One NVIDIA GPU. 12 GB of VRAM or more is a comfortable size for the models in this guide.
- At least 30 GB of free disk space.
- A domain name that points to your server. In this guide, we use
llm.example.com.
If you do not have a GPU machine yet, a dedicated GPU server for LLM inference gives you full access to the card, so you can run all the steps below as they are.
Step 1: Check the NVIDIA Driver
llama.cpp needs a working NVIDIA driver to use your GPU. In this step, you must check that the driver is installed and that the system can see your card. Run the command below:
You should see your GPU name, the driver version, and the CUDA version. If the command is not found, you can install a driver with the commands below:
sudo apt updatesudo ubuntu-drivers installsudo reboot
After the reboot, run nvidia-smi again to confirm that the GPU shows up.
Step 2: Install the CUDA Toolkit
The driver is enough to run models, but you need the CUDA toolkit to build llama.cpp. Run the commands below to add the NVIDIA repository and install the toolkit:
cd /tmpwget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.debsudo dpkg -i cuda-keyring_1.1-1_all.debsudo apt updatesudo apt install cuda-toolkit -y
Then, add CUDA to your path and check that the compiler works:
echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrcsource ~/.bashrcnvcc --version
Step 3: Build llama.cpp with CUDA
Now you must install the build tools. libssl-dev is needed if you want HTTPS features such as downloading models from Hugging Face with -hf:
sudo apt install build-essential cmake git ccache libssl-dev curl jq -y
Then, download the source code. We use the v0.6.0 tag, which is the latest stable release at the time of writing.
cd ~git clone --branch v0.6.0 --depth 1 https://github.com/ggml-org/llama.cppcd llama.cpp
Use the commands below to configure and build with CUDA turned on:
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Releasecmake --build build --config Release -j $(nproc)
The build can take 10 to 20 minutes. If CMake cannot detect your GPU, you must set the architecture manually. For example, use 86 for RTX 30 series cards or 89 for RTX 40 series cards:
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_CUDA_ARCHITECTURES="89"
Check the build and confirm that llama.cpp can see your GPU:
./build/bin/llama-server --version./build/bin/llama-server --list-devices
Now copy the binaries to a fixed folder. We will use this path in the service file later:
sudo mkdir -p /opt/llama.cppsudo cp -r build/bin /opt/llama.cpp/ls /opt/llama.cpp/bin | head
Step 4: Download the GGUF Models
In this step, you must download the two model files that the server will use. We use two models from the same family. The big one answers your users. The small one is the draft model that we add in Step 9.
- Main model: Qwen3 8B in
Q4_K_M (about 5 GB).
- Draft model: Qwen3 0.6B in
Q8_0 (about 0.6 GB).
Create a folder and download both files with the commands below:
sudo mkdir -p /opt/llama-modelssudo chown $USER:$USER /opt/llama-modelscd /opt/llama-models curl -L -O https://huggingface.co/Qwen/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.ggufcurl -L -O https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf ls -lh /opt/llama-models
A draft model must use the same tokenizer as the main model. Models from the same family, like Qwen3 8B and Qwen3 0.6B, match. Mixing different families will fail or give poor results.
Step 5: Create an API Key
Now you must create an API key to protect your server. Only people who send this key can use your models, so others cannot use your GPU.
Create a folder for the config files and generate a strong key:
sudo mkdir -p /etc/llamaopenssl rand -hex 32 | sudo tee /etc/llama/api-keys.txtsudo chmod 640 /etc/llama/api-keys.txt
Create a service user. It will run the server and read the key file:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin llamasudo chown root:llama /etc/llama/api-keys.txt
Save the key in your shell so you can use it in tests:
export LLAMA_API_KEY=$(sudo cat /etc/llama/api-keys.txt)
The key file can hold many keys, one per line. Lines that start with # are comments.
Step 6: Start the Server and Test It
For the first time, you must start the server manually. This is a baseline run, without a draft model:
/opt/llama.cpp/bin/llama-server \ -m /opt/llama-models/Qwen3-8B-Q4_K_M.gguf \ --alias qwen3-8b \ --host 127.0.0.1 --port 8080 \ -ngl all \ -fa on \ -c 16384 \ -np 4 \ --api-key-file /etc/llama/api-keys.txt \ --metrics \ --no-webui
Here is what each option does:
| Option |
What it does |
-m |
Path to the GGUF model file. |
--alias |
The model name that clients use in API calls. |
--host 127.0.0.1 |
Listen on localhost only. Nginx will handle public traffic. |
-ngl all |
Put all model layers in GPU memory. |
-fa on |
Turn on Flash Attention. It saves memory and is faster with long context. |
-c 16384 |
Total context size in tokens. |
-np 4 |
Number of parallel slots, so 4 requests can run at the same time. |
--api-key-file |
Read allowed API keys from a file. |
--metrics |
Turn on the Prometheus /metrics endpoint. |
--no-webui |
Turn off the built-in web page. |
Wait until the log says the server is listening. Then open a second terminal and test:
curl -s http://127.0.0.1:8080/health curl -s http://127.0.0.1:8080/v1/models \ -H "Authorization: Bearer $LLAMA_API_KEY" | jq curl -s http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $LLAMA_API_KEY" \ -d '{ "model": "qwen3-8b", "messages": [{"role": "user", "content": "Say hello in one short sentence."}], "max_tokens": 100 }' | jq
Then, test that the key is really needed. This call has no key and should return a 401 error:
curl -i http://127.0.0.1:8080/v1/models
Step 7: Set Parallel Requests and Context Size
These two settings decide how many users you can serve and how much VRAM you need.
-np sets the number of slots. Each slot handles one request at once.
-c sets the total context. It is shared by the slots. With -c 16384 and -np 4, plan for about 4096 tokens per request.
You can pick the numbers like this:
- Decide how many users will call the API at the same time. Set
-np to that number.
- Decide the longest prompt plus answer you need. Multiply it by
-np. Set -c to the result.
- Watch VRAM with
nvidia-smi. If you run out of memory, lower -c or -np.
If VRAM is tight, you can store the KV cache in 8-bit. This cuts the cache size by about half compared to the default 16-bit. Add these options:
Keep Flash Attention on (-fa on) when you use a quantized KV cache. Test the quality of your answers after this change.
You can see what each slot is doing at any time with the command below:
curl -s http://127.0.0.1:8080/slots -H "Authorization: Bearer $LLAMA_API_KEY" | jq
Step 8: Run a Baseline Benchmark
Before we turn on llama.cpp speculative decoding, we need a baseline. You can create the benchmark script with:
Paste this code, then save the file (Ctrl+O, Enter, Ctrl+X):
import json, os, time, urllib.requestfrom concurrent.futures import ThreadPoolExecutor URL = os.environ.get("LLAMA_URL", "http://127.0.0.1:8080") + "/v1/chat/completions"KEY = os.environ["LLAMA_API_KEY"]MODEL = os.environ.get("LLAMA_MODEL", "qwen3-8b") PROMPTS = [ "Write a Python function that reads a CSV file and prints the average of each numeric column.", "Explain how DNS works, step by step, in simple words.", "Write a bash script that backs up a folder to a tar.gz file with the date in the name.", "Explain the difference between a process and a thread.",] def ask(prompt): body = json.dumps({ "model": MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": 256, "temperature": 0, }).encode() req = urllib.request.Request( URL, data=body, headers={"Content-Type": "application/json", "Authorization": "Bearer " + KEY}, ) with urllib.request.urlopen(req, timeout=600) as r: return json.load(r)["usage"]["completion_tokens"] def run(concurrency, total): prompts = [PROMPTS[i % len(PROMPTS)] for i in range(total)] start = time.time() with ThreadPoolExecutor(max_workers=concurrency) as pool: tokens = sum(pool.map(ask, prompts)) secs = time.time() - start print(f"concurrency={concurrency} requests={total} tokens={tokens} " f"time={secs:.1f}s speed={tokens / secs:.1f} tok/s") ask(PROMPTS[0])for c in (1, 2, 4): run(c, 8)
The script uses temperature 0, so both runs with and without a draft model. This makes the comparison fair.
Run it and, in another terminal, watch GPU memory:
python3 ~/bench.py nvidia-smi --query-gpu=memory.used,memory.total --format=csv -l 2
Write down the tok/s for each concurrency level and the highest memory.used value. Then stop the server with Ctrl+C.
Also, you can measure model speed without the server. llama-bench does this:
/opt/llama.cpp/bin/llama-bench -m /opt/llama-models/Qwen3-8B-Q4_K_M.gguf -ngl 99 -p 512 -n 128
It reports prompt processing (pp) and text generation (tg) in tokens per second. Note that llama-bench does not test speculative decoding. Use the server script above for that.
Step 9: Enable llama.cpp Speculative Decoding with a Draft Model
Text generation is slow because the model creates one token at once. With llama.cpp speculative decoding, a small draft model guesses the next few tokens fast. Then, the big model checks all the guesses in one pass. Correct guesses are kept, so you get several tokens for the price of one big step. Wrong guesses are thrown away, so the output stays the same as the big model would give.
Start the server again with the draft model added:
/opt/llama.cpp/bin/llama-server \ -m /opt/llama-models/Qwen3-8B-Q4_K_M.gguf \ --alias qwen3-8b \ --host 127.0.0.1 --port 8080 \ -ngl all \ -fa on \ -c 16384 \ -np 4 \ --spec-type draft-simple \ --spec-draft-model /opt/llama-models/Qwen3-0.6B-Q8_0.gguf \ --spec-draft-ngl all \ --spec-draft-n-max 8 \ --api-key-file /etc/llama/api-keys.txt \ --metrics \ --no-webui
These are the new options:
| Option |
What it does |
--spec-type draft-simple |
Use a small draft model for speculation. |
--spec-draft-model |
Path to the draft GGUF file. |
--spec-draft-ngl all |
Put the draft model fully on the GPU. |
--spec-draft-n-max 8 |
Draft up to 8 tokens per step. The default is 3. |
Old guides use --model-draft, -md, and --draft-max. In current llama.cpp, --draft-max and --draft-min were removed. You can use --spec-draft-n-max and --spec-draft-n-min instead. The -md short form still works. If you are unsure about an option on your build, run llama-server --help | grep spec.
Run the same benchmark again:
Now look at the server log. After each request, llama-server prints the draft acceptance rate, for example draft acceptance rate = 0.70312 ( 90 accepted / 128 generated). A higher rate means more speed. If the rate is low, the draft model is a poor match for your prompts.
Try a few values for --spec-draft-n-max, such as 4, 8, and 16. Pick the one that gives the best tok/s in your tests. Gains depend on your prompts, your GPU, and the model pair. Code and structured text usually gain more than free-form creative writing.
Step 10: Compare Speed and Memory
Use this table to judge if llama.cpp speculative decoding helps your load. Fill it in with your own numbers from Step 8 and Step 9:
| Test |
Without draft |
With draft |
| Speed, 1 request (tok/s) |
your number |
your number |
| Speed, 2 requests (tok/s) |
your number |
your number |
| Speed, 4 requests (tok/s) |
your number |
your number |
| Peak VRAM (MiB) |
your number |
your number |
| Draft acceptance rate |
not used |
from the log |
Expect VRAM to go up a little, because the draft model and its own KV cache also live on the GPU. For the models in this guide, the draft weights are about 0.6 GB. Expect the biggest gain with 1 or 2 requests. When many requests run at once, the GPU is already busy, so the gain gets smaller. If speculation does not help at your load, you can remove the draft options.
Step 11: Run llama-server as a systemd Service
Stop the manual server with Ctrl+C. Then, create the service file. It keeps llama.cpp speculative decoding on after every reboot:
sudo nano /etc/systemd/system/llama-server.service
Add the following content to the file:
[Unit]Description=llama.cpp OpenAI-compatible serverAfter=network-online.targetWants=network-online.target [Service]Type=simpleUser=llamaGroup=llamaWorkingDirectory=/opt/llama.cppExecStart=/opt/llama.cpp/bin/llama-server \ -m /opt/llama-models/Qwen3-8B-Q4_K_M.gguf \ --alias qwen3-8b \ --host 127.0.0.1 --port 8080 \ -ngl all \ -fa on \ -c 16384 \ -np 4 \ --spec-type draft-simple \ --spec-draft-model /opt/llama-models/Qwen3-0.6B-Q8_0.gguf \ --spec-draft-ngl all \ --spec-draft-n-max 8 \ --api-key-file /etc/llama/api-keys.txt \ --metrics \ --no-webuiRestart=alwaysRestartSec=5LimitNOFILE=65535 [Install]WantedBy=multi-user.target
Make the model files readable by the service user, start the service, then watch the logs:
sudo chmod -R a+rX /opt/llama-models /opt/llama.cppsudo systemctl daemon-reloadsudo systemctl enable --now llama-serversudo systemctl status llama-server --no-pagersudo journalctl -u llama-server -f
To change any option later, edit the file, then run sudo systemctl daemon-reload and sudo systemctl restart llama-server.
Step 12: Add Nginx Reverse Proxy with HTTPS
Never open port 8080 to the internet. Keep the server on 127.0.0.1 and let Nginx handle outside traffic. For this purpose, install Nginx:
sudo apt install nginx -y
Create the site file with your desired text editor:
sudo nano /etc/nginx/sites-available/llm.example.com
Paste this content. Replace llm.example.com with your domain:
limit_req_zone $binary_remote_addr zone=llm_limit:10m rate=10r/s; server { listen 80; server_name llm.example.com; client_max_body_size 10m; location ~ ^/(metrics|slots|props) { return 403; } location / { limit_req zone=llm_limit burst=20 nodelay; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }}
Enable the site and reload Nginx with the following commands:
sudo ln -s /etc/nginx/sites-available/llm.example.com /etc/nginx/sites-enabled/sudo nginx -tsudo systemctl reload nginx
Now you must obtain a free TLS certificate with Certbot. Install Certbot and get your certificates with the commands below:
sudo apt install certbot python3-certbot-nginx -ysudo certbot --nginx -d llm.example.com
Then, open only the ports you need in the UFW firewall:
sudo ufw allow OpenSSHsudo ufw allow 'Nginx Full'sudo ufw enablesudo ufw status
At this point, you have three layers of control, including the firewall, the Nginx rate limit, and the API key.
Step 13: Test the Final Setup
Now you must call the API through your domain. A request with the key must work:
curl -s https://llm.example.com/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $LLAMA_API_KEY" \ -d '{ "model": "qwen3-8b", "messages": [{"role": "user", "content": "What is speculative decoding in one sentence?"}], "max_tokens": 100 }' | jq -r '.choices[0].message.content'
A request without the key must fail, and the hidden endpoints must be blocked:
curl -i https://llm.example.com/v1/modelscurl -i https://llm.example.com/metrics
You can use the server with any OpenAI client. Set the base URL to https://llm.example.com/v1 and use your key. Here is an example with the Python SDK:
1from openai import OpenAI2 3client = OpenAI(base_url="https://llm.example.com/v1", api_key="YOUR_API_KEY")4 5reply = client.chat.completions.create(6 model="qwen3-8b",7 messages=[{"role": "user", "content": "Give me three tips for Linux server security."}],8 max_tokens=200,9)10print(reply.choices[0].message.content)
Conclusion
Now you have a complete single-GPU inference server. It uses a CUDA build of llama.cpp, a quantized GGUF model, API key protection, an Nginx proxy with HTTPS, and a draft model for faster output.
The benchmark script gives you real numbers, so you can decide if llama.cpp speculative decoding is worth it for your own load. You can find all server options in the llama.cpp server documentation.
You can run quantized models on a PerLod GPU server when VRAM efficiency matters more than maximum distributed throughput.