A self-hosted LLM inference server exposing an OpenAI-compatible Chat Completions API, fronted by a Caddy reverse proxy and protected by GPU thermal monitoring.
The repository ships ready-to-use compose templates for a selection of models, but is not limited to them — any model supported by vLLM can be added by following the same pattern.
All services are deployed via Docker Compose.
selfhost-ai/
├── .env.example # Template for all env variables (copy to .env)
├── requirements.txt # Python dependencies for the test client
├── Makefile # Root-level management commands (run make help)
├── models/ # Model weights — one subdirectory per model,
│ # mounted read-only into the vLLM container
│
└── vllm/
├── deployment-templates/ # Per-model vLLM + Caddy Docker Compose stacks
│ ├── Caddyfile
│ └── docker-compose.<model-name>.yml # one file per model
│
├── testing/ # API smoke-test script
│ └── test_llm_api.py
│
└── thermal-guard/ # GPU temperature monitoring & auto-shutdown
└── docker/ # Docker Compose-based deployment
├── Dockerfile
├── docker-compose.thermal.yml
├── thermal-guard-docker.sh
└── README-docker.md
| Component | Image / Tool |
|---|---|
| Inference engine | nvcr.io/nvidia/vllm:26.01-py3 (Qwen3.6-27B uses 26.04-py3) |
| Reverse proxy | caddy:latest |
| GPU metrics | nvidia/dcgm-exporter:4.5.2-4.8.1-ubuntu22.04 |
| Thermal guard | alpine:latest (custom script) |
| Orchestration | Docker Compose v2 |
- Docker and Docker Compose v2
- NVIDIA Container Toolkit installed and configured
- NVIDIA GPU(s) with CUDA support
- Model weights downloaded into
models/
make gpu-checkmake env # copies .env.example → .env
# Edit .env and set VLLM_API_KEY:
# openssl rand -hex 32make deploy-ministral-8b # shortcut
make deploy MODEL=Qwen3.6-27B # any model by nameCaddy will not accept traffic until vLLM reports healthy. Allow up to 10 minutes for large models to load.
make thermal-upmake venv # first time only — creates .venv and installs requirements
make testRun make help to see all available commands.
Each model has its own Docker Compose file under vllm/deployment-templates/. Every stack starts two services:
| Service | Image | Description |
|---|---|---|
vllm-server |
nvcr.io/nvidia/vllm:26.01-py3 |
Inference engine, listens on port 8000 (internal) |
caddy |
caddy:latest |
Reverse proxy, exposes HTTP (80) / HTTPS (443) |
Caddy forwards all traffic to vLLM with response streaming enabled (flush_interval -1) and a 20-minute read timeout to accommodate slow generation on large models.
Create the .env file from the example and set your API key:
cp .env.example .env
# Edit .env and set VLLM_API_KEYThe only required variable is:
# Generate a strong key: openssl rand -hex 32
VLLM_API_KEY="your-secret-key-here"
.envis git-ignored and must never be committed.
# Using a Makefile shortcut:
make deploy-ministral-8b
make deploy-devstral-123b
# Or any model by name:
make deploy MODEL=Qwen3.6-27B
# Low-level equivalent:
docker compose -f vllm/deployment-templates/docker-compose.<model-name>.yml up -dvLLM performs a health check at GET /health every 15 seconds with a 10-minute startup grace period. Caddy will not accept traffic until vLLM reports healthy. Allow up to 10 minutes for large models to load.
make undeploy MODEL=<model-name>
# or:
docker compose -f vllm/deployment-templates/docker-compose.<model>.yml downThe API is OpenAI-compatible and reachable at http://<host>/v1/.
curl http://localhost/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $VLLM_API_KEY" \
-d '{
"model": "Ministral-3-8B-Instruct-2512",
"messages": [{"role": "user", "content": "Hello!"}]
}'Note for Mistral-family models: The compose files pass
--tokenizer_mode mistral,--config_format mistral, and--load_format mistralto use Mistral's native weight format. Without these flags vLLM falls back to the HuggingFace loader, which misidentifies these models as a Pixtral vision model and crashes at startup.
Note for structured output (xgrammar): Ministral models with structured output enabled can generate indefinitely without hitting a natural stop token. Always pass
max_tokensin client API calls when using structured output.
The following models have ready-to-use compose files. These serve as templates — the repo is not limited to these models.
| Model | Compose File | Notes |
|---|---|---|
| Ministral-3-3B-Instruct-2512 | docker-compose.Ministral-3-3B-Instruct-2512.yml |
Mistral native format, tool calling |
| Ministral-3-8B-Instruct-2512 | docker-compose.Ministral-3-8B-Instruct-2512.yml |
Mistral native format, tool calling |
| Ministral-3-14B-Instruct-2512 | docker-compose.Ministral-3-14B-Instruct-2512.yml |
Mistral native format, tool calling |
| Devstral-2-123B-Instruct-2512 | docker-compose.Devstral-2-123B-Instruct-2512.yml |
123B, async scheduling |
| gpt-oss-20B | docker-compose.gpt-oss-20B.yml |
async scheduling |
| gpt-oss-120B | docker-compose.gpt-oss-120B.yml |
async scheduling |
| Qwen3.6-27B | docker-compose.Qwen3.6-27B.yml |
27B VLM, reasoning parser, tool calling, 131K context, requires vLLM 26.04 |
| Qwen3-Coder-Next-FP8 | docker-compose.Qwen3-Coder-Next-FP8.yml |
80B MoE (3B active), FP8-quantized (~80 GB), tool calling, 256K context, no thinking mode, requires vLLM 26.04 |
Model weights must be present in the corresponding models/<model-name>/ directory before starting the stack.
Use the Hugging Face CLI to download weights into models/:
pip install huggingface-hub
huggingface-cli download <org>/<model-name> --local-dir models/<model-name>The directory name under models/ is what gets mounted into the container and passed to vllm serve.
Copy the closest existing template and rename it:
cp vllm/deployment-templates/docker-compose.Ministral-3-8B-Instruct-2512.yml \
vllm/deployment-templates/docker-compose.<model-name>.ymlEdit the new file and update:
volumes— change the host path to../../models/<model-name>command— update the model path and any model-specific vLLM flags- Mistral-family models require
--tokenizer_mode mistral --config_format mistral --load_format mistral - Standard HuggingFace models do not need these flags
- Mistral-family models require
mem_limit/memswap_limit— adjust to the model's sizecontainer_name— keep asvllmunless running multiple stacks simultaneously
make deploy MODEL=<model-name>The thermal guard protects GPU(s) from overheating by stopping the vLLM container when a temperature threshold is exceeded. It does not auto-restart the container after cooling — that decision is left to the operator to prevent thermal oscillation.
┌──────────────────┐
│ DCGM Exporter │ ──► GPU metrics at http://dcgm-exporter:9400/metrics
└──────────────────┘
▲
│ polls every N seconds
│
┌──────────────────┐
│ Thermal Guard │ ──► Reads DCGM_FI_DEV_GPU_TEMP
└──────────────────┘ └─► docker stop vllm (if temp >= threshold)
│
▼
┌──────────────────┐
│ vLLM Server │
└──────────────────┘
See vllm/thermal-guard/docker/README-docker.md for full details.
All thermal commands are available from the repo root via the root Makefile:
| Command | Description |
|---|---|
make thermal-up |
Start dcgm-exporter + thermal-guard |
make thermal-down |
Stop thermal monitoring |
make thermal-logs |
Follow all thermal service logs |
make thermal-logs-guard |
Follow thermal-guard logs only |
make thermal-logs-dcgm |
Follow DCGM exporter logs only |
make thermal-status |
Show service status |
make thermal-health |
Show health check status |
make thermal-metrics |
Verify DCGM exporter is returning GPU temp data |
make thermal-rebuild |
Rebuild the thermal-guard image and restart |
# Quick reference:
make thermal-up
make thermal-status
make thermal-logs-guard
make thermal-downDCGM exporter won't start
make thermal-logs-dcgm
docker info | grep -i nvidiaThermal guard shows connection warnings
make thermal-status # check dcgm-exporter is healthy
make thermal-metrics # verify metrics endpoint is respondingvLLM keeps stopping
# Check current GPU temperature:
nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader
# Review thermal guard decisions:
make thermal-logs-guard
# If legitimate overtemp: improve cooling, reduce load, or raise THERMAL_THRESHOLD_C in .env| Variable | Default | Description |
|---|---|---|
THERMAL_THRESHOLD_C |
80 |
Stop vLLM when any GPU reaches this temperature (°C) |
THERMAL_POLL_SECONDS |
5 |
Polling interval in seconds |
EXPORTER_URL |
http://dcgm-exporter:9400/metrics |
DCGM exporter Prometheus endpoint |
CONTAINER_NAME |
vllm |
Name of the container to stop on overtemp |
DOCKER_STOP_TIMEOUT |
30 |
Graceful stop timeout in seconds |
The DCGM exporter also exposes metrics on host port 9400, which can be scraped by Prometheus.
A smoke-test script is provided at vllm/testing/test_llm_api.py. It reads VLLM_API_KEY, API_BASE_URL, and MODEL from the root .env.
make venv # first time only — creates .venv, installs requirements.txt
make testEquivalent manual steps:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python vllm/testing/test_llm_api.pyThe script sends a single chat completion request to $API_BASE_URL/v1/chat/completions and prints the HTTP status code and response body.
All secrets live in a single .env at the repo root — git-ignored, sourced by all three stacks. On a fresh clone:
cp .env.example .env
# Edit .env and fill in the required values| Variable | Used by | Description |
|---|---|---|
VLLM_API_KEY |
deployment, testing | API key for the vLLM server |
API_BASE_URL |
testing | Base URL of the running vLLM stack (no trailing slash) |
MODEL |
testing | Model name as reported by /v1/models |
THERMAL_THRESHOLD_C |
thermal-guard | Stop vLLM when any GPU reaches this temperature (°C) |
THERMAL_POLL_SECONDS |
thermal-guard | GPU polling interval in seconds |
LLM_CONTAINER_NAME |
thermal-guard | Name of the container to stop on overtemp |
- Never commit
.envfiles. They are git-ignored. - Generate a strong API key:
openssl rand -hex 32. SetVLLM_API_KEYin the root.env. - vLLM binds only on an internal Docker bridge network; Caddy is the sole public-facing ingress point.
- The thermal guard container mounts the Docker socket (
/var/run/docker.sock) to stop containers on overtemp. Restrict access to the host socket accordingly.
- TLS (HTTPS) is not configured in the provided
Caddyfile. To enable it, updateCaddyfilewith your domain and Caddy will obtain a certificate automatically via ACME.