A self-hosted LLM proxy stack built around LiteLLM, CLIProxyAPI, PostgreSQL, and Netdata. It provides centralized API key management, model routing, access-group control, Claude Code request validation, and optional monitoring for a Docker Swarm deployment managed through Portainer.
This repository is deployed as multiple Docker Swarm stacks:
db: PostgreSQL database for LiteLLM state, usage logs, and model configuration
cli-proxy-api: Anthropic-compatible proxy and auth servicelitellm: Core routing layer and LiteLLM admin UI
netdata: Host and container monitoring dashboardconfig-generator: Sidecar that watches Docker labels and generates Netdata collector configs
internal: private overlay network between application services and PostgreSQLpublic: external Traefik network for HTTPS routingmonitoring: shared overlay network used by Netdata auto-discovery
# Install ptctools
uv tool install ptctools --from git+https://github.com/tamntlib/ptctools.gitAdd the following record to your DNS:
portainer.example.com
scp portainer/portainer.yaml root@<ip>:/root/portainer.yamlhttps://docs.docker.com/engine/install/ubuntu/#install-using-the-repository
docker swarm init
LETSENCRYPT_EMAIL=<email> PORTAINER_HOST=<host> docker stack deploy -c /root/portainer.yaml portainerDeploy this first so the shared monitoring overlay network exists before the application stacks join it.
Add the following record to your DNS:
netdata.example.com
Copy monitoring/.env.example to monitoring/.env and fill in the values:
cp monitoring/.env.example monitoring/.envRequired environment variables:
NETDATA_HOST: Hostname for the Netdata dashboardNETDATA_BASIC_AUTH: Basic auth credentials for Traefik
ptctools docker config set -n monitoring_netdata-conf -f 'monitoring/configs/netdata.conf'
ptctools docker config set -n monitoring_config-generator-script -f 'monitoring/scripts/netdata-config-generator.sh'
ptctools docker stack deploy -n monitoring -f 'monitoring/netdata.yaml' --ownership teamAdd the following records to your DNS:
llm.example.com(LiteLLM)cli-proxy-api.llm.example.com(CLIProxyAPI)
Copy .env.example to .env and fill in the values:
cp .env.example .envRequired environment variables:
DB_USER,DB_PASSWORD,DB_NAME: PostgreSQL credentialsLITELLM_HOST,LITELLM_MASTER_KEY,LITELLM_SALT_KEY: LiteLLM configurationCLI_PROXY_API_HOST: CLIProxyAPI hostname
Optional environment variables used by the stack:
CLAUDE_CODE_MODELS: Comma-separated model names that should enforce Claude Code checksCLAUDE_CODE_MIN_VERSION: Minimum allowed Claude Code version for those modelsSLACK_WEBHOOK_URL: LiteLLM Slack webhook
export PORTAINER_URL=https://portainer.example.com
export PORTAINER_ACCESS_TOKEN=<token>
ptctools docker config set -n llmproxy_litellm-config-yaml -f 'configs/litellm.yaml' --ownership team
ptctools docker config set -n llmproxy_litellm-claude-code-hook-py -f 'configs/claude_code_hook.py' --ownership team
ptctools docker config set -n llmproxy_cli-proxy-api-config-yaml -f 'configs/cli-proxy-api.yaml' --ownership team
ptctools docker stack deploy -n llmproxy-data -f 'llmproxy-data.yaml' --ownership team
ptctools docker stack deploy -n llmproxy -f 'llmproxy.yaml' --ownership teamcd litellm_scripts
# Generate a resolved config from config.json + config.local.json
python3 gen_config.py
# Full sync of credentials, models, aliases, fallbacks, and public model hub
python3 config.py --only credentials,models,aliases,fallbacks,public_model_hub --force --prune
# Sync specific components
python3 config.py --only models --force
python3 config.py --only aliases,fallbacks,public_model_hub
python3 config.py --only public_model_hub
# Create a LiteLLM user and API key
python3 create_api_key.py user@example.com
python3 create_api_key.py user@example.com --alias my-keyRequired environment variables in litellm_scripts/.env:
LITELLM_API_KEYLITELLM_BASE_URL
| File | Description |
|---|---|
llmproxy-data.yaml |
PostgreSQL Docker Swarm stack |
llmproxy.yaml |
Application Docker Swarm stack for LiteLLM and CLIProxyAPI |
monitoring/netdata.yaml |
Monitoring stack with Netdata and the label-watching config generator |
configs/litellm.yaml |
LiteLLM runtime config (callbacks, DB batching, connection pool settings) |
configs/cli-proxy-api.yaml |
CLIProxyAPI runtime config |
configs/claude_code_hook.py |
LiteLLM callback that enforces Claude Code User-Agent and minimum version rules |
litellm_scripts/config.json |
Base provider/model/alias/fallback/public-model-hub config |
litellm_scripts/config.local.json |
Local overrides including API keys (gitignored, deep-merged with config.json) |
litellm_scripts/config.gen.json |
Generated resolved config output from gen_config.py with LiteLLM-ready credential and model request bodies |
.env |
Environment variables for the application stacks |
monitoring/.env |
Environment variables for the monitoring stack |
Create litellm_scripts/config.local.json to add API keys and local overrides:
{
"providers": {
"my-provider": {
"api_key": "sk-your-api-key-here"
},
"another-provider": {
"api_key": "sk-another-key"
}
}
}This file is deep-merged with config.json, so you only need to specify overrides. Provider configs can also use $extend in config.json and override or disable inheritance in config.local.json.
Each interface may override the provider-level api_base. This is useful when a single provider exposes different OpenAI-compatible and Anthropic-compatible endpoints.
{
"providers": {
"my-provider": {
"api_base": "https://shared-gateway.example.com",
"interfaces": {
"anthropic": {
"api_base": "https://custom-anthropic.example.com"
},
"openai": {
"api_base": "https://custom-openai.example.com/v1"
}
}
}
}
}Rules:
- interface-level
api_baseoverrides the provider-levelapi_basefor credential generation and interface-specific model discovery - interface-level
models_api_basemay be set separately when the/modelsendpoint lives on a different base URL - if interface
models_api_baseis omitted, model discovery falls back to interfaceapi_base, then provider-levelmodels_api_base, then provider-levelapi_base
Use public_model_hub to add explicit model groups or aliases to LiteLLM's public model hub:
{
"public_model_hub": [
"claude-opus-4-7"
]
}Use is_public_model_hub to derive public model hub entries from config defaults:
{
"providers": {
"my-provider": {
"is_public_model_hub": true,
"interfaces": {
"openai": {
"models": {
"model-a": null,
"model-b": {
"is_public_model_hub": false
}
}
}
}
}
}
}Rules:
- provider-level
is_public_model_hubis the default for all models under that provider - model-level
is_public_model_huboverrides the provider default - if
is_public_model_hubis omitted, it is treated asfalse public_model_hubentries are combined from three sources by default: derived model entries, alias names, and the explicitpublic_model_hubarray- set
public_model_hub_autofill_disabled: trueto disable derived model entry autofill - set
public_model_hub_aliases_autofill_disabled: trueto disable alias-name autofill - in
config.local.json, thepublic_model_hubarray replaces the base list instead of merging element-by-element
Each interface may define model_name_prefix to control derived model group names. When omitted, it defaults to the interface name.
Default examples:
interfaces.anthropic.models.claude-sonnet-4-6resolves toanthropic/claude-sonnet-4-6interfaces.openai.models.gpt-5.4resolves toopenai/gpt-5.4interfaces.gemini.models.gemini-2.5-proresolves togemini/gemini-2.5-pro
{
"providers": {
"my-provider": {
"interfaces": {
"anthropic": {
"model_name_prefix": "anthropic",
"models": {
"claude-sonnet-4-6": null
}
}
}
}
}
}With no explicit model_name, the generated model group name becomes <model_name_prefix>/<model-id>. In the example above, claude-sonnet-4-6 resolves to anthropic/claude-sonnet-4-6.
If model_name is set on a model, it still wins and fully overrides the derived prefix-based name.
These resolved prefixed names are the ones used by generated models and should be the names you reference in:
aliasestargetsfallbackspublic_model_hubmodel_name_base_model_mapentries when you want to key by resolved model name instead of raw provider model ID
Individual models can override the provider-level access_groups by specifying access_groups in their model config:
{
"providers": {
"my-provider": {
"access_groups": ["General"],
"models": {
"model-a": null,
"model-b": {
"access_groups": ["Premium"]
}
}
}
}
}model-ainherits the provider-levelaccess_groups:["General"]model-buses its ownaccess_groups:["Premium"]
# Volume backup/restore (uses Duplicati)
ptctools docker volume backup -v vol1,vol2 -o s3://mybucket
ptctools docker volume restore -i s3://mybucket/vol1
ptctools docker volume restore -v vol1 -i s3://mybucket/vol1
# Database backup/restore (uses minio/mc for S3)
ptctools docker db backup -c container_id -v db_data \
--db-user postgres --db-name mydb -o backup.sql.gz
ptctools docker db backup -c container_id -v db_data \
--db-user postgres --db-name mydb -o s3://mybucket/backups/db.sql.gz
ptctools docker db restore -c container_id -v db_data \
--db-user postgres --db-name mydb -i backup.sql.gz
ptctools docker db restore -c container_id -v db_data \
--db-user postgres --db-name mydb -i s3://mybucket/backups/db.sql.gzNetdata collects host, container, and PostgreSQL metrics.
Netdata limits local metrics storage to 10 GiB in monitoring/configs/netdata.conf, which provides roughly 2-4 weeks of retention depending on metric volume.
Services can self-register for PostgreSQL monitoring by adding Docker labels:
deploy:
labels:
- netdata.postgres.name=my_database
- netdata.postgres.dsn=postgresql://user:pass@host:5432/dbname
networks:
- monitoringThe service must also join the shared monitoring network so the Netdata stack can reach it.