Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 141 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
strategy:
matrix:
scenario:
- default-values
- kind-values
- external-oidc-values
- external-inference-values
Expand Down Expand Up @@ -94,9 +95,147 @@ jobs:
--wait

- name: Run helm tests
run: helm test llm --namespace llm --timeout 5m

secret-stability:
name: Secret stability
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: azure/setup-helm@v4
with:
version: "v3.17.0"

- name: Create kind cluster
uses: helm/kind-action@v1.10.0
with:
cluster_name: secret-stability

- name: Add chart repos
run: |
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo add open-webui https://helm.openwebui.com/
helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo add ollama https://otwld.github.io/ollama-helm/
helm repo update

- name: Update chart dependencies
run: helm dependency update charts/llm-stack

- name: Install chart
run: |
helm install llm charts/llm-stack \
--namespace llm \
--create-namespace \
-f charts/llm-stack/ci/kind-values.yaml \
--timeout 10m \
--wait

- name: Capture initial secret hash
id: hash0
run: |
H=$(kubectl get secret -n llm llm-stack-secrets -o json \
| jq -cS '.data' | sha256sum | awk '{print $1}')
echo "hash=$H" >> "$GITHUB_OUTPUT"

- name: Upgrade (no-op)
run: |
helm upgrade llm charts/llm-stack \
--namespace llm \
-f charts/llm-stack/ci/kind-values.yaml \
--timeout 10m \
--wait

- name: Verify secret unchanged after first upgrade
run: |
H=$(kubectl get secret -n llm llm-stack-secrets -o json \
| jq -cS '.data' | sha256sum | awk '{print $1}')
if [ "$H" != "${{ steps.hash0.outputs.hash }}" ]; then
echo "Secret rotated on upgrade 1: $H != ${{ steps.hash0.outputs.hash }}"
exit 1
fi

- name: Upgrade (second pass)
run: |
helm upgrade llm charts/llm-stack \
--namespace llm \
-f charts/llm-stack/ci/kind-values.yaml \
--timeout 10m \
--wait

- name: Verify secret unchanged after second upgrade
run: |
H=$(kubectl get secret -n llm llm-stack-secrets -o json \
| jq -cS '.data' | sha256sum | awk '{print $1}')
if [ "$H" != "${{ steps.hash0.outputs.hash }}" ]; then
echo "Secret rotated on upgrade 2: $H != ${{ steps.hash0.outputs.hash }}"
exit 1
fi
echo "Secret stable across 2 upgrades: $H"

networkpolicy:
name: NetworkPolicy enforcement
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: azure/setup-helm@v4
with:
version: "v3.17.0"

- name: Create Calico cluster
run: bash hack/kind-with-calico.sh

- name: Add chart repos and dependencies
run: |
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo add open-webui https://helm.openwebui.com/
helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo add ollama https://otwld.github.io/ollama-helm/
helm repo update
helm dependency update charts/llm-stack

- name: Install with policies enabled
run: |
helm install llm-stack charts/llm-stack \
--kube-context kind-netpol \
--namespace llm --create-namespace \
-f charts/llm-stack/ci/netpol-values.yaml \
--timeout 15m --wait

- name: Egress must be blocked
run: |
if kubectl --context kind-netpol -n llm run egress-test \
--rm -i --restart=Never --image=curlimages/curl:8.8.0 \
-- curl -m 5 -sf https://example.com >/dev/null 2>&1; then
echo "FAIL: external egress succeeded, isolation is not enforced"
exit 1
fi
echo "OK: external egress blocked"

- name: Internal traffic must still work
run: |
helm test llm --namespace llm --timeout 5m \
--filter name=llm-llm-stack-test-webui-health
# Verify pod-to-pod connectivity within the llm namespace is not blocked.
# helm test is not used here because test-litellm-models requires loaded
# models, which are intentionally not pulled in the netpol scenario.
kubectl --context kind-netpol -n llm run internal-check \
--rm -i --restart=Never --image=curlimages/curl:8.8.0 \
-- sh -c '
set -e
curl -sf http://llm-stack-litellm.llm.svc.cluster.local:4000/health \
&& echo "OK: LiteLLM reachable"
curl -sf http://llm-stack-open-webui.llm.svc.cluster.local/health \
&& echo "OK: Open WebUI reachable"
curl -sf http://llm-stack-qdrant.llm.svc.cluster.local:6333/readyz \
&& echo "OK: Qdrant reachable"
'

schema-validate:
name: Schema validation
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Release

on:
push:
branches: [main]
paths:
- "charts/**"

permissions:
contents: write
pages: write

jobs:
release:
name: Release chart
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Configure git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"

- uses: azure/setup-helm@v4
with:
version: "v3.17.0"

- name: Add chart repos
run: |
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo add open-webui https://helm.openwebui.com/
helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo add ollama https://otwld.github.io/ollama-helm/
helm repo update

- uses: helm/chart-releaser-action@v1.6.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
charts_dir: charts
skip_existing: true
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Helm
# Helm — packaged subchart tarballs are ephemeral, but Chart.lock is committed
# for reproducible builds (pins digest of OCI charts like litellm-helm).
charts/llm-stack/charts/
charts/llm-stack/Chart.lock
*.tgz

# Secrets / local overrides
Expand Down
83 changes: 70 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,38 @@ A self-hosted LLM stack as a single Helm umbrella chart. Designed for teams that
- A default StorageClass (for persistent volumes)
- Optional: an Ingress controller (nginx) and cert-manager for TLS

## Quick start (kind)
## Installation

### From Helm repository (recommended)

```bash
helm repo add llm-stack https://ufukdev.github.io/llm-stack
helm repo update

helm install llm-stack llm-stack/llm-stack \
--namespace llm --create-namespace \
--set global.domain=example.com
```

> **Note:** GitHub Pages must be enabled on the `gh-pages` branch for
> `helm repo add` to work. The release workflow publishes there automatically
> on every merge to `main` that touches `charts/`.

### From source (development)

```bash
# Create a local cluster
kind create cluster --name llm-stack

# Add required chart repositories
helm repo add open-webui https://helm.openwebui.com/
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo add ollama https://otwld.github.io/ollama-helm/
helm repo update
helm dependency update charts/llm-stack

# Install with the local CI values (no Ingress, no SSO, Keycloak disabled)
helm install llm charts/llm-stack \
helm install llm-stack charts/llm-stack \
--kube-context kind-llm-stack \
-n llm --create-namespace \
--namespace llm --create-namespace \
-f charts/llm-stack/ci/kind-values.yaml

# Access Open WebUI
kubectl -n llm port-forward svc/llm-open-webui 8080:80
kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80
# Open http://localhost:8080
```

Expand All @@ -65,7 +76,7 @@ All top-level keys are defined in [`charts/llm-stack/values.yaml`](charts/llm-st
| `ollama.enabled` | `true` | Deploy Ollama for local inference |
| `qdrant.enabled` | `true` | Deploy Qdrant vector store |
| `keycloak.enabled` | `true` | Deploy Keycloak for SSO |
| `litellm.enabled` | `false` | Deploy LiteLLM gateway |
| `litellm.enabled` | `true` | Deploy LiteLLM gateway |
| `networkPolicy.enabled` | `false` | Deploy NetworkPolicies (requires Calico/Cilium) |
| `ingress.enabled` | `false` | Deploy Ingress resources |

Expand Down Expand Up @@ -140,8 +151,12 @@ oidc:
external:
enabled: true
issuerUrl: "https://sso.example.com/auth/realms/my-realm"
clientId: "open-webui"
clientSecret: "..." # use --set, never commit

open-webui:
sso:
oidc:
clientId: "open-webui" # must match the client registered in your IdP
```

See [`examples/values-external-idp.yaml`](charts/llm-stack/examples/values-external-idp.yaml) and [`ci/external-oidc-values.yaml`](charts/llm-stack/ci/external-oidc-values.yaml).
Expand All @@ -150,6 +165,48 @@ See [`examples/values-external-idp.yaml`](charts/llm-stack/examples/values-exter

When `keycloak.enabled=true`, a post-install Job imports the `llm-stack` realm automatically. The realm includes an `open-webui` OIDC client, `llm-user`/`llm-admin` roles, and matching groups.

### SSO with a real domain (production)

When `global.domain` is set the chart automatically configures `KC_HOSTNAME_URL` so Keycloak always advertises its public address (`auth.<domain>`) in the OIDC discovery document — regardless of which internal address Open WebUI uses to fetch it. Both the browser redirect and the server-side token exchange therefore reach Keycloak through the Ingress.

```yaml
global:
domain: "mycompany.com"

ingress:
enabled: true
className: nginx
tls:
issuer: letsencrypt # cert-manager ClusterIssuer
```

With this config SSO works end-to-end:

```
Browser → https://chat.mycompany.com
→ "Sign in with Keycloak"
→ https://auth.mycompany.com/auth/realms/llm-stack/... (Keycloak login page)
→ back to Open WebUI with a valid session
```

### SSO with port-forward (local dev)

Port-forward cannot provide SSO because the browser cannot resolve the internal Kubernetes service address (`llm-stack-keycloakx-http.llm.svc.cluster.local`) that Keycloak embeds in redirect URLs when no public domain is configured.

**Workaround — skip SSO and register with email/password:**

```bash
# Start port-forward
kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80

# Create the first (admin) account via API
curl -X POST http://localhost:8080/api/v1/auths/signup \
-H "Content-Type: application/json" \
-d '{"name":"Admin","email":"admin@local.test","password":"changeme"}'
```

Then open `http://localhost:8080` and sign in with email/password. The first account is automatically granted the `admin` role.

See [`docs/sso.md`](docs/sso.md) for full details.

## NetworkPolicy
Expand Down
18 changes: 18 additions & 0 deletions charts/llm-stack/Chart.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
dependencies:
- name: open-webui
repository: https://helm.openwebui.com/
version: 16.1.0
- name: keycloakx
repository: https://codecentric.github.io/helm-charts
version: 7.3.0
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.19.0
- name: ollama
repository: https://otwld.github.io/ollama-helm/
version: 1.78.0
- name: litellm-helm
repository: oci://ghcr.io/berriai
version: 1.98.0
digest: sha256:649e1221775e6c2237585b9952c6f849ae0a0d36758153541c0f681b0486e303
generated: "2026-08-27T10:15:13.735238754+03:00"
2 changes: 1 addition & 1 deletion charts/llm-stack/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: v2
name: llm-stack
description: |
Self-hosted LLM stack with SSO, RAG and observability.
Self-hosted LLM stack with SSO and RAG.
Designed for teams that cannot send data outside and must
integrate with their existing identity provider.
type: application
Expand Down
Loading
Loading