On-Prem Deployment
Run the entire Willow platform inside your own Kubernetes cluster. The runtime, admin console, connect service, and database all run on your infrastructure — fully isolated from Willow SaaS, with no call-home requirement.
Why On-Prem?
- Complete data isolation — tool execution, authentication, audit logs, and configuration never leave your network
- No external dependencies at runtime — suitable for air-gapped environments and networks with strict egress controls
- Compliance — satisfies the strictest data residency and sovereignty requirements (HIPAA, FedRAMP, financial services, internal security policy)
- Full administrative control — you own the deployment lifecycle: upgrades, scaling, backups, and configuration
How It Works
All Willow microservices are deployed into your Kubernetes cluster with a single Helm chart:
┌──────────────────────────────────────────────────────────┐
│ Your Kubernetes cluster │
│ │
│ ┌──────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ app │──▶│ db-service │◀──│ connect │ │
│ │ (admin) │ └──────┬──────┘ │ (dashboard, │ │
│ └──────────┘ │ │ OAuth) │ │
│ ▼ └─────────────────┘ │
│ ┌────────────┐ ▲ │
│ │ PostgreSQL │ │ │
│ └────────────┘ ┌──────┴──────┐ │
│ MCP clients ─────────────────────▶│ run │ │
│ (Claude, Cursor) │ (tool exec) │ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────┘
Managed entirely by you
The Helm chart deploys:
- app — the administrative console for managing your Willow instance
- connect — the dashboard UI and OAuth / external tool authentication service
- run — the MCP runtime that executes tool calls inside your network
- db-service — the database access layer
- PostgreSQL (optional) — in-cluster database when
webrix-postgresql.enabled: true - ClickHouse (optional, not deployed by the chart) — your own instance for logs, OTLP, and analytics; see Bring your own ClickHouse
- Ingress, Service Accounts & RBAC — routing and the permissions required to operate
Your users connect their AI assistants (Claude, Cursor, or any MCP-compatible client) directly to the on-prem run endpoint. Authentication is handled by the on-prem connect service, which integrates with your existing SSO provider.
Before You Start
Read this whole section before running any commands. Two things in particular are much cheaper to get right up front than to fix later: the secrets in Step 5 (the chart ships publicly-known defaults) and the database choice in Step 6.
Who you need
| Role | What they need access to | Which steps |
|---|---|---|
| Platform / Kubernetes engineer | kubectl and helm against the target cluster; permission to create namespaces, secrets, ingresses | Steps 1, 3, 4, 5, 6, 7, 8, 9, 10 |
| DNS admin | Ability to create records on your domain | Step 2 |
| Security / secrets owner | Your secret store, and sign-off on where key material lives | Step 5 |
| Database administrator (if using an external database) | Create a database, user, and grants; network path from the cluster | Step 6 |
| Identity / SSO admin | Register an OIDC application in Okta, Entra/ADFS, Keycloak, Google, or GitHub | Step 11 |
| Network / firewall admin | Egress for image pulls, and for any third-party APIs your tools call | Step 3 |
What must already exist
-
Kubernetes cluster — EKS, GKE, AKS, OpenShift, or any conformant distribution (v1.23+ recommended)
-
kubectl — configured and pointed at the right cluster. Confirm it now:
kubectl config current-context -
Helm — v3+
-
An ingress controller running in the cluster, with an external address. Everything in Phase 1 depends on it:
kubectl get svc -n ingress-nginx # adjust the namespace for your controller# Look for TYPE=LoadBalancer and a populated EXTERNAL-IPOn OpenShift you can use
Routeinstead ofIngress— the chart supports both. -
A domain name you can create DNS records on
-
Access to the container images — either egress to
quay.io/webrix, or a mirror in your own registry for air-gapped clusters
Decisions to make before you configure anything
Each of these changes what you write in Step 7. Settle them first.
| Decision | Options | Notes |
|---|---|---|
| Database | In-cluster PostgreSQL, or your own external PostgreSQL | Covered in Step 6. Moving later means a data migration |
| Log storage | PostgreSQL (default), or your own ClickHouse | ClickHouse is easier to adopt at install time; PostgreSQL history is not backfilled if you switch later. See Bring your own ClickHouse |
| SSO provider | Okta, Entra/ADFS, Keycloak, Google, GitHub — or none at first | Covered in Step 11. You can install without it and add it after |
| AI guardrails | On (needs an LLM provider key) or off | Only guardrails require OPENAI_API_KEY; everything else works without it |
| Hostnames | Chart defaults, or your own | Covered in Step 1 |
The order of operations
| Phase | What happens | Why it can't move earlier |
|---|---|---|
| 1. Prepare your infrastructure (Steps 1–4) | Hostnames, DNS, image access, TLS | Your ingress controller already has an address, so DNS and certificates can be ready before Willow exists |
| 2. Configure the release (Steps 5–7) | Generate secrets, pick a database, write values.yaml | Secrets and the database choice must be settled before first install — changing them afterwards is a migration, not an edit |
| 3. Install (Steps 8–9) | Add the chart, helm upgrade --install | Needs Phase 2 |
| 4. Verify and sign in (Steps 10–11) | Pods, ingress, TLS, first login, SSO | Needs a running release |
Checklist
The boxes below are read-only on this page. Use Copy Page at the top to copy this guide as Markdown into your ticket tracker or notes — the checklist pastes in as a working task list you can mark done as you go. The Copy for Agent option in the same menu hands the whole guide to a coding agent that will walk you through it.
Phase 1 — Prepare your infrastructure
-
kubectlpointed at the right cluster,helmv3+ available (Prerequisites) - Ingress controller confirmed to have an external address (Prerequisites)
- Hostnames chosen for app, connect, and run (Step 1)
- DNS records created and resolving (Step 2)
- Image pull access confirmed, or images mirrored (Step 3)
- Certificate option chosen, and issuable given your network posture (Which certificate)
- If using cert-manager: cert-manager installed and the
ClusterIssuerreportingREADY: True— neither is created by the Willow chart (Step 4) - TLS certificates ready for all three hostnames (Step 4)
Phase 2 — Configure the release
- All four chart default secrets replaced with your own (Step 5)
- Secrets stored in Kubernetes secrets, not plaintext
values.yaml(Step 5) - Database chosen and, if external, created and reachable (Step 6)
-
values.yamlwritten and rendered clean withhelm template(Step 7)
Phase 3 — Install
Phase 4 — Verify and sign in
- All pods
Running(Step 10) - Ingress resolves and serves valid TLS on all three hostnames (Step 10)
- Admin console reachable and you can sign in (Step 11)
- SSO provider configured, or the admin hostname deliberately restricted (Step 11)
- An MCP client can list and call a tool through
run(Step 10)
Phase 1 — Prepare your infrastructure
Step 1 — Choose your hostnames
Owner: Platform engineer · Needs: nothing · Produces: three hostnames used everywhere below
The chart builds each service's public hostname as <subdomain>.<global.domain.host>. With global.domain.host: example.com and the chart's default subdomains you get:
| Service | Default subdomain | Resulting hostname | What it's for |
|---|---|---|---|
| app | willow-admin | willow-admin.example.com | Administrative console |
| connect | willow-dashboard | willow-dashboard.example.com | End-user dashboard and OAuth flows |
| run | willow | willow.example.com | MCP endpoint your AI assistants connect to |
db-service has no ingress — it is reached only in-cluster, which is why it isn't in the table.
To use different subdomains, override deployments.<service>.ingress.subdomain. To use hostnames that aren't <something>.<one shared domain>, set deployments.<service>.ingress.hosts instead, which bypasses the subdomain/domain concatenation entirely.
Verify: you have three hostnames written down, all on domains you can create records for.
Step 2 — Point DNS at your ingress
Owner: DNS admin · Needs: Step 1 · Produces: three resolving hostnames
Find your ingress controller's external address:
kubectl get svc -n ingress-nginx # adjust the namespace for your controller
Create an A record (or CNAME, if your ingress exposes a hostname) for each:
willow-admin.<domain> → <ingress-external-address>
willow-dashboard.<domain> → <ingress-external-address>
willow.<domain> → <ingress-external-address>
Do this now rather than after installing. The ingress controller is a prerequisite that already has an address, and the certificates in Step 4 are typically validated against these records.
Verify:
for h in willow-admin willow-dashboard willow; do
echo -n "$h: "; dig +short $h.<YOUR_DOMAIN>
done
All three should return your ingress address.
If it fails
| Symptom | Cause | Fix |
|---|---|---|
dig returns nothing | Record missing or not propagated | Re-check the record and wait out the TTL |
EXTERNAL-IP stays <pending> | No load balancer provisioned for the ingress controller | Fix the ingress controller first — nothing downstream works without it |
| Resolves to a node IP | Pointed at a node instead of the controller's load balancer | Point at the LoadBalancer address |
| Resolves to CDN addresses you didn't configure | A wildcard record (*.<YOUR_DOMAIN>) is answering, and it's proxied | Create explicit, unproxied records for all three hostnames. Wildcards are shadowed by an exact match, so a lingering CDN answer usually means cache — re-check with dig +short <host> @1.1.1.1 |
dig disagrees with what a browser reaches | Stale resolver or OS cache from before the record existed | Query an authoritative resolver directly rather than trusting the local cache |
Step 3 — Confirm image and network access
Owner: Platform engineer + network admin · Needs: nothing · Produces: a cluster that can pull the images
On-Prem has no call-home requirement — once deployed it operates entirely within your network boundary. The only traffic you need to allow is:
- During install — access to the image registry (
quay.io/webrix). For air-gapped clusters, mirror the images into your internal registry and setglobal.imagePullSecrets; see Custom Image Pull Secrets. - At runtime — only to the third-party APIs your tools actually call (GitHub, Slack, Jira, etc.). If you use AI-powered guardrails, also to your configured LLM provider.
Verify that the cluster can actually pull, before you install the whole stack:
kubectl run imagetest --rm -it --restart=Never \
--image=quay.io/webrix/mcp-s-run:latest \
--namespace <namespace> --command -- true
If that needs credentials, create the pull secret now — see Custom Image Pull Secrets.
If it fails
| Symptom | Cause | Fix |
|---|---|---|
ImagePullBackOff / unauthorized | No registry credentials, or the secret exists but isn't referenced | Create the pull secret and list it in global.imagePullSecrets — see Custom Image Pull Secrets |
no such host / timeout | Egress to quay.io blocked | Mirror the images internally and point the chart at your registry |
Step 4 — Prepare TLS certificates
Owner: Platform engineer · Needs: Step 2 · Produces: certificates for all three hostnames
The chart does not configure TLS for you — ingress.tls is empty by default for every service. Decide how certificates are issued and add the tls block to the values.yaml you write in Step 7:
deployments:
app:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow-admin.<YOUR_DOMAIN>"]
secretName: "willow-admin-tls"
connect:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow-dashboard.<YOUR_DOMAIN>"]
secretName: "willow-dashboard-tls"
run:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow.<YOUR_DOMAIN>"]
secretName: "willow-run-tls"
Helm merges annotation keys with the chart's defaults, so the run service keeps the streaming annotations it ships with (below).
The cert-manager.io/cluster-issuer annotation above only names a ClusterIssuer that must already exist. If it doesn't, no certificate is issued, every hostname keeps serving your controller's default certificate, and the only clue is an Issuer not found event on the Certificate objects — nothing in the Willow release reports it.
helm repo add jetstack https://charts.jetstack.io && helm repo update
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true
Then create the issuer. On-prem hostnames are often not publicly reachable, so DNS-01 is usually the right solver — the HTTP-01 form below only works if Let's Encrypt can reach the hostname on port 80 from the internet.
# clusterissuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod # must match the annotations in values.yaml
spec:
acme:
email: <YOUR_EMAIL>
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01: # replace with your DNS provider's dns01 solver if not public
ingress:
class: nginx # must match your IngressClass
kubectl apply -f clusterissuer.yaml
kubectl get clusterissuer letsencrypt-prod # READY must be True before Step 9
If you're supplying certificates you already own, skip all of this — and leave the cert-manager.io/cluster-issuer annotation out of values.yaml entirely.
Which certificate, and how to install it
The chart defaults every service's ingress.className to "nginx". That must match an IngressClass that exists in your cluster, or the ingress objects are created and then ignored by every controller — nothing is programmed, and the hostnames return 404 with no error in the Willow release to explain it.
kubectl get ingressclass
If you're on AKS using the application routing add-on, the class is webapprouting.kubernetes.azure.com, certificates come from Azure Key Vault instead of cert-manager, and each TLS secretName must be keyvault- plus the service name — keyvault-app, keyvault-connect, keyvault-run. See Match your ingress controller for the values, and skip the cert-manager options below.
You have four ways to get a certificate. Which one is viable depends on whether these hostnames are reachable from the public internet, which on-prem they often deliberately are not:
| Option | Use when | Needs public inbound to issue? |
|---|---|---|
| A certificate your organization already owns | You have a wildcard for the domain, or a procurement process | No |
| cert-manager with a DNS-01 challenge | You want auto-renewal and can use your DNS provider's API | No |
| cert-manager with an HTTP-01 challenge | You want auto-renewal and the hostnames are publicly reachable | Yes |
| A certificate on your cloud load balancer | You already terminate TLS at an ALB, Application Gateway, or GCLB | No |
Let's Encrypt validates an HTTP-01 challenge by connecting to your hostname on port 80 from the internet. On an on-prem deployment that is frequently blocked by design — in which case the Certificate sits at Ready: False forever with no useful error, and it looks like cert-manager is broken. Use DNS-01 or a certificate you already own instead.
To install a certificate you already have, put the full chain and key into a secret whose name matches the secretName in your values.yaml, in the same namespace as the Willow release:
kubectl create secret tls willow-run-tls \
--namespace <namespace> \
--cert=fullchain.pem --key=privkey.pem
Repeat for each hostname's secret. Don't add a cert-manager.io/cluster-issuer annotation when supplying your own certificate, or cert-manager will try to take over the secret. Note that a wildcard for *.example.com covers willow.example.com but not willow.internal.example.com — wildcards match a single label.
app and connect are browser-facing, so an internal CA your managed laptops already trust can work for those. The run hostname is different: MCP clients such as Claude and Cursor reject any certificate that isn't publicly trusted, and offer no way to skip verification. If you use an internal CA there, tool calls fail for every user.
For verifying what's actually being served and debugging a stuck cert-manager Certificate, see TLS and Inbound Reachability.
Alternatives: terminate TLS on a cloud load balancer with an ACM/GCP certificate, or use pre-existing certificate secrets by referencing them in secretName without a cert-manager annotation.
The MCP protocol keeps a long-lived GET /mcp Server-Sent Events stream open. If a proxy in front of run buffers the response or drops the idle connection, MCP clients hang on a cold connection and fail — while a plain curl tools/list still looks fast, because it doesn't hold the stream open.
The chart already sets proxy-read-timeout: 300, proxy-send-timeout: 300, and proxy-buffering: "off" on the run Ingress, which covers a plain ingress-nginx setup. Watch out for the layers it can't control: raise the idle timeout on any cloud load balancer in front (the AWS ALB default is 60s, Azure Load Balancer defaults to 4 minutes, and Azure Application Gateway enforces its own backend request timeout), don't enable an Envoy response buffer filter on the run route, and don't put a caching CDN in front of the run hostname. On Cloudflare that means the run record must be DNS-only (grey cloud), and watch for a proxied wildcard quietly covering it.
Verify: after Step 9, curl -sSI https://willow-admin.<YOUR_DOMAIN> should return a valid certificate. Come back to this once the release is running.
Phase 2 — Configure the release
Step 5 — Generate your own secrets
Owner: Platform engineer + security owner · Needs: nothing · Produces: the key material your release runs on
The Helm chart ships working placeholder values for four secrets so that a throwaway evaluation install comes up without configuration. They are published in a public chart, so they are not secret. A production install must replace all four.
ENCRYPTION_KEY is the one that really has to be right the first time: it is the master key db-service uses to encrypt every OAuth token, API key, and webhook secret at rest. Changing it after data exists is not an edit — it requires the full key rotation procedure, including a rewrap job. Setting it correctly now costs one command.
Generate four independent values:
for n in AUTH_SECRET DB_AUTH_SECRET AUTO_AUTHENTICATE_TOKEN ENCRYPTION_KEY; do
echo "$n=$(openssl rand -base64 32)"
done
What each one does, and which chart value carries it:
| Secret | Chart value | Purpose |
|---|---|---|
AUTH_SECRET | global.authSecret | Signs admin and dashboard sessions in app and connect. Rotating it logs everyone out — nothing worse |
DB_AUTH_SECRET | global.dbAuthSecret | Authenticates service-to-service calls into db-service. Reaches the services as their AUTH_SECRET |
AUTO_AUTHENTICATE_TOKEN | global.autoAuthenticateToken | Used by connect and db-service for the auto-authentication flow |
ENCRYPTION_KEY | deployments.db-service.env.ENCRYPTION_KEY | Master key for all secrets at rest, via envelope encryption. Treat as the most sensitive value in the deployment |
AUTH_SECRET means two different things in this chartFor app and connect it is the session signing secret (global.authSecret). For run and db-service the same variable name carries the service-to-service secret (global.dbAuthSecret). The chart wires each service to the right one — you only set the two global.* values.
Store them in a Kubernetes secret rather than in values.yaml, which the chart would otherwise render into a ConfigMap in plaintext:
kubectl create secret generic willow-platform-secrets \
--namespace <namespace> \
--from-literal=AUTH_SECRET='<generated>' \
--from-literal=DB_AUTH_SECRET='<generated>' \
--from-literal=AUTO_AUTHENTICATE_TOKEN='<generated>'
kubectl create secret generic willow-db-service-secrets \
--namespace <namespace> \
--from-literal=ENCRYPTION_KEY='<generated>'
Reference them from values.yaml (global.secretName applies to every service; a per-service secretName applies to one). Secrets are loaded after the ConfigMap, so their values win. sealedSecrets and externalSecrets are supported too — see Working with Custom Secrets.
Verify that no secret leaked into a ConfigMap:
kubectl get configmap -n <namespace> -o yaml | grep -iE "ENCRYPTION_KEY|AUTH_SECRET"
# Ideally returns nothing. If it echoes your real values, move them into a secret.
Back them up now. Losing ENCRYPTION_KEY means losing every stored credential — it cannot be recovered from the database.
Step 6 — Choose your database
Owner: Platform engineer, plus a DBA for the external option · Needs: nothing · Produces: a database Willow can reach
Pick exactly one. Enabling both is a configuration error.
Option A — In-cluster PostgreSQL (simplest). The chart deploys the webrix-postgres subchart and wires db-service to it automatically:
webrix-postgresql:
enabled: true
Note that webrix-postgresql.enabled is false in the chart defaults, so you must set it explicitly. Backups, upgrades, and storage sizing are yours to operate.
Option B — Your own PostgreSQL (recommended for production). Create the database and user first, confirm the cluster can reach it, then supply the URL as a secret:
kubectl create secret generic willow-db-url \
--namespace <namespace> \
--from-literal=DATABASE_URL="postgres://user:password@db-host:5432/willow?sslmode=require"
externalDatabase:
url:
secretName: "willow-db-url"
See External Database for the full options.
Verify the external option before installing:
kubectl run pgtest --rm -it --restart=Never --image=postgres:17-alpine \
--namespace <namespace> -- \
psql "postgres://user:password@db-host:5432/willow?sslmode=require" -c 'select 1'
If it fails
| Symptom | Cause | Fix |
|---|---|---|
db-service crashloops with a connection error | Wrong credentials, or the database isn't reachable from the cluster | Run the pgtest check above; check security groups and network policy |
| Both databases seem configured | webrix-postgresql.enabled: true and externalDatabase.url set | Enable only one |
sslmode errors | Server requires or rejects TLS | Match sslmode to your server's configuration |
Step 7 — Create values.yaml
Owner: Platform engineer · Needs: Steps 1, 4, 5, 6 · Produces: the config Helm installs
This is where the decisions above come together. A minimal production-shaped file:
global:
# Base domain from Step 1. Default subdomains:
# app → willow-admin.<host>
# connect → willow-dashboard.<host>
# run → willow.<host>
domain:
host: "<YOUR_DOMAIN>" # e.g. example.com
# Your secrets from Step 5, supplied via a Kubernetes secret rather than
# inline. Keys: AUTH_SECRET, DB_AUTH_SECRET, AUTO_AUTHENTICATE_TOKEN.
secretName: "willow-platform-secrets"
# Optional — required only if you use AI-powered guardrails
OPENAI_API_KEY: ""
# Step 6, Option A: in-cluster database. Off by default, so set it explicitly.
webrix-postgresql:
enabled: true
deployments:
db-service:
# ENCRYPTION_KEY from Step 5.
secretName: "willow-db-service-secrets"
# TLS from Step 4, for each exposed service.
app:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow-admin.<YOUR_DOMAIN>"]
secretName: "willow-admin-tls"
connect:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow-dashboard.<YOUR_DOMAIN>"]
secretName: "willow-dashboard-tls"
run:
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts: ["willow.<YOUR_DOMAIN>"]
secretName: "willow-run-tls"
global.org alone on-premAn on-prem install seeds an organization with the slug on-prem-org, which is also the chart's default for global.org. The bootstrap admin sign-in in Step 11 resolves that exact slug, so overriding global.org will break your first login. Only change it if Willow support tells you to.
Every available value, setting, and default is documented in the Helm chart repository: github.com/webrix-ai/webrix-helm
Configuration reference
| Value | Purpose |
|---|---|
global.domain.host | Your base domain. Used to build the ingress hostname for each service. |
global.secretName | Kubernetes secret shared by all services. Where the Step 5 secrets belong. |
global.authSecret | Session signing secret for app and connect. Chart default is a public placeholder. |
global.dbAuthSecret | Service-to-service auth secret. Chart default is a public placeholder. |
global.autoAuthenticateToken | Auto-authentication token. Chart default is a public placeholder. |
global.OPENAI_API_KEY | Optional. Required only if you use AI-powered guardrails. |
global.imagePullSecrets | Registry credentials for pulling the images. Empty by default — a pull secret you created is ignored until you list it here. |
deployments.db-service.env.ENCRYPTION_KEY | Master key for secrets at rest. Chart default is a public placeholder. |
deployments.<svc>.ingress.subdomain | First label of the service hostname. Defaults: willow-admin, willow-dashboard, willow. |
deployments.<svc>.ingress.tls | TLS certificate for the service. Empty by default — you must set it. |
webrix-postgresql.enabled | Deploys the in-cluster webrix-postgres database. false by default. |
externalDatabase.url.* | Bring your own database. See External Database. |
Verify before installing anything:
helm template willow willow/webrix-helm -f values.yaml > /tmp/rendered.yaml
# Are all four placeholder secrets gone?
grep -E "aUi2V5iCVqUrbrdjKty1zH4HCqszXArV9BLVU2giqhY=|ZI39UlR2sN6HFZauuze0iNEZnNkF1wiOuGklm3bC8dk=|iL7wUJhqRMPFY0asXqLAn1JjW9kv0OqC4NHtqKHt5VY=|balQgAqCpKW979XoibTYSbfCjvj7uSJK\+90m0iQG5" /tmp/rendered.yaml
# Are the hostnames what you expect?
grep -E "^\s+- host:" /tmp/rendered.yaml
The first grep should return nothing. If it prints matches, a placeholder secret is still in play — go back to Step 5.
Phase 3 — Install
Step 8 — Add the Helm repository
Owner: Platform engineer
helm repo add willow https://webrix-ai.github.io/webrix-helm
helm repo update
Verify:
helm search repo willow
The chart is also published as an OCI artifact to oci://ghcr.io/webrix-ai/charts. If your organization standardizes on OCI registries, you can skip helm repo add entirely and reference the chart directly by its oci:// URL in Step 9 — it's the exact same chart, just distributed as an OCI artifact.
# No `helm repo add` needed — the oci:// URL is self-contained
helm show chart oci://ghcr.io/webrix-ai/charts/webrix-helm
Step 9 — Install
Owner: Platform engineer · Needs: Step 7
Replace <namespace> with your desired namespace (e.g. willow):
helm upgrade --install willow willow/webrix-helm \
--namespace <namespace> \
--create-namespace \
-f values.yaml \
--wait
Or, using the OCI registry instead of helm repo add:
helm upgrade --install willow oci://ghcr.io/webrix-ai/charts/webrix-helm \
--namespace <namespace> \
--create-namespace \
-f values.yaml \
--wait
Installation typically takes 2–5 minutes depending on your cluster.
Verify:
helm status willow -n <namespace> # STATUS: deployed
If it fails
| Symptom | Cause | Fix |
|---|---|---|
--wait times out | One or more pods never became ready | Move to Step 10 — the pod-level checks identify which |
cannot re-use a name that is still in use | A release with that name already exists | helm list -n <namespace>; upgrade it rather than installing a second one |
| Rendering errors | Malformed values.yaml | Re-run the helm template check from Step 7 |
Phase 4 — Verify and sign in
Step 10 — Verify the deployment
Owner: Platform engineer · Needs: Step 9
Run these in order; the first failure tells you where to look.
1. Pod health
kubectl get pods -n <namespace>
Expected:
NAME READY STATUS RESTARTS AGE
app-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
connect-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
run-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
db-service-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
webrix-postgresql-0 1/1 Running 0 2m
The PostgreSQL pod appears only with the in-cluster option from Step 6.
2. Services and ingress
kubectl get services -n <namespace>
kubectl get ingress -n <namespace>
Confirm the hostnames match what you chose in Step 1.
3. Database connectivity — db-service reaching Running and staying there is the signal. If it restarts, check its logs:
kubectl logs -n <namespace> deployment/db-service --tail=100
4. TLS and reachability on each hostname:
curl -sS -o /dev/null -w 'http=%{http_code} redirect=%{redirect_url}\n' --max-time 5 --max-redirs 0 \
https://willow-admin.<YOUR_DOMAIN>/
curl -sS -o /dev/null -w 'http=%{http_code} redirect=%{redirect_url}\n' --max-time 5 --max-redirs 0 \
https://willow-dashboard.<YOUR_DOMAIN>/
curl -sS -o /dev/null -w 'http=%{http_code} redirect=%{redirect_url}\n' --max-time 5 --max-redirs 0 \
https://willow.<YOUR_DOMAIN>/healthz
Each should complete without a certificate warning. Don't add -k — it suppresses exactly the certificate problem you're checking for.
Run these from a machine on the same network as your MCP clients, not from inside the cluster or from a jump box. A shell in the cluster reaches these hostnames by a path your users don't have, so a pass there proves nothing about whether anyone can connect. If the run hostname is meant to be publicly reachable, test it from outside your network entirely — a hostname resolving in public DNS does not mean the port is open, because the record and the firewall are independent of each other.
What the result tells you: a timeout means packets are being silently dropped by a firewall or a load balancer with no rule, a connection refused means the address is reachable but nothing is listening, a certificate error means the network is fine and TLS is not, a 404 means the ingress has no rule for that hostname, and a 503 means the ingress is up with no healthy pod behind it. TLS and Inbound Reachability maps each to a fix, including the per-cloud network checks for AKS, EKS, and GKE.
5. Streaming on the run endpoint
curl -sS -i -N -H "Accept: text/event-stream" https://willow.<YOUR_DOMAIN>/mcp
Expect a 401 with a JSON body within a second or two, plus a www-authenticate: Bearer ... header. That is the pass: /mcp requires authentication, so an anonymous request is rejected before any stream opens.
An unauthenticated request never produces an SSE stream, so there is nothing to hold open past 60s. A prompt 401 rules out a proxy that stalls headers, but it cannot detect buffering or a low idle timeout — only an authenticated, long-lived stream can. Verify the timeouts from your infrastructure's configuration, and treat check 6 below (a real MCP client on a cold connection) as the actual streaming test. If /mcp hangs instead of answering promptly, something in front of run is buffering — that is a failure.
6. An MCP client — after Step 11, connect Claude or Cursor to https://willow.<YOUR_DOMAIN> and call a tool. This is the only check that exercises the full user path.
If it fails
| Symptom | Cause | Fix |
|---|---|---|
Pods stuck in Pending, Insufficient cpu | Not enough allocatable CPU — the four default services request ~1200m between them, before PostgreSQL, your ingress controller, and system pods | kubectl describe node <node> and read the Allocated resources section. Size the node pool for the sum of the requests you enable, remembering each pod's request must fit on a single node |
ImagePullBackOff | No registry access | See Step 3 and Custom Image Pull Secrets |
db-service crashloops | Database unreachable or wrong credentials | See Step 6; in-cluster, check kubectl logs webrix-postgresql-0 -n <namespace> |
app or connect crashloops | Configuration error in values.yaml | kubectl logs <pod> -n <namespace> |
| Ingress returns 404 / no host | Ingress controller missing, or className mismatch | Confirm a controller is running and the className matches it |
| Ingress returns 503 | No Ready pod behind the service | No healthy backend — usually ImagePullBackOff, which also breaks HTTP-01 issuance |
| Certificate warnings | ingress.tls not set, or issuance failed | See Step 4; check kubectl get certificate -n <namespace> |
| Ingress serves a "fake certificate" | The TLS secret doesn't exist, is misnamed, or is in the wrong namespace | The controller logs name the secret it expected — see Verifying certificates |
Certificate reports Issuer not found | The ClusterIssuer named in the annotation doesn't exist — the chart doesn't create it | Install cert-manager and apply the issuer — see Step 4 |
Certificate stuck at Ready: False | HTTP-01 can't reach the hostname from the internet | Use DNS-01 or your own certificate — see Which certificate |
| Certificate works in a browser, MCP clients reject it | Issued by an internal CA | The run hostname needs a publicly trusted certificate — see Which certificate |
| Connections time out from outside, work from inside | A firewall, security group, or load balancer rule | Inbound network — per-cloud checks |
| Hostnames don't resolve | DNS not pointing at the ingress | See Step 2 |
/mcp returns 401 immediately | This is a pass — /mcp requires authentication | Continue; the live MCP client in check 6 is the real streaming test |
/mcp hangs with no response at all | A proxy in front of run is buffering the response | Disable response buffering on the run route — see Step 4 |
| An MCP client connects, then drops at ~60s | Idle timeout on a load balancer in front of the ingress | Raise the LB idle timeout — see Step 4 |
| ClickHouse migrate/start failures | Missing URL, grants, or network | See Bring your own ClickHouse |
Step 11 — First sign-in and SSO
Owner: Platform engineer for the first sign-in, identity admin for SSO · Needs: Step 10
Your services are now available at:
- App —
https://willow-admin.<domain>— administrative console - Dashboard —
https://willow-dashboard.<domain>— end-user dashboard, analytics, and OAuth flows - Run —
https://willow.<domain>— the MCP endpoint your AI assistants connect to
First sign-in. An on-prem install seeds an organization (on-prem-org) with an owner admin. When no SSO provider is configured, the admin app offers a built-in On-Prem Admin sign-in for that seeded account, so you can get in and configure the platform before your identity provider is wired up.
The bootstrap sign-in exists so a fresh install isn't locked out — it does not verify an external identity. Until you complete the SSO configuration below, keep willow-admin.<domain> off the public internet: put it behind an internal-only ingress, an IP allowlist, or your VPN. Configuring SSO before you widen access is the safest order.
Configure SSO. Set AUTH_PROVIDER plus the client credentials for your provider on the app deployment. The chart supports Okta, Entra/ADFS, Keycloak, Google, and GitHub, following the AUTH_<PROVIDER>_* convention:
| Provider | Required variables |
|---|---|
| Okta | AUTH_PROVIDER, AUTH_OKTA_ID, AUTH_OKTA_SECRET, AUTH_OKTA_ISSUER |
| ADFS / Entra | AUTH_PROVIDER, AUTH_ADFS_ID, AUTH_ADFS_SECRET, AUTH_ADFS_ISSUER |
| Keycloak | AUTH_PROVIDER, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, AUTH_KEYCLOAK_ISSUER |
AUTH_PROVIDER, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET | |
| GitHub | AUTH_PROVIDER, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET |
The client secret belongs in a Kubernetes secret, not values.yaml — see Working with Custom Secrets:
kubectl create secret generic app-api-keys \
--namespace <namespace> \
--from-literal=AUTH_OKTA_SECRET=xxxxxxxxxxxxx
deployments:
app:
secretName: "app-api-keys"
env:
AUTH_PROVIDER: "okta"
AUTH_OKTA_ID: "<client-id>"
AUTH_OKTA_ISSUER: "https://<your-org>.okta.com"
Register https://willow-admin.<domain>/api/auth/callback/<provider> as the redirect URI in your identity provider. Then redeploy with the same helm upgrade --install from Step 9.
Verify: sign out, reload https://willow-admin.<domain>, and confirm your provider's sign-in button appears and a real account can log in.
If it fails
| Symptom | Cause | Fix |
|---|---|---|
| Provider button doesn't appear | AUTH_<PROVIDER>_ID not set, so the provider isn't registered | Confirm the ID variable reached the pod: kubectl exec deploy/app -n <namespace> -- env | grep AUTH_ |
redirect_uri_mismatch | Callback URL not registered, or hostname mismatch | Register https://willow-admin.<domain>/api/auth/callback/<provider> exactly |
| Login loops back to the sign-in page | Session cookie rejected — usually a hostname or TLS mismatch | Confirm you're browsing the same hostname the chart configured, over HTTPS |
| Sessions drop after a redeploy | AUTH_SECRET changed | Expected: rotating the session secret invalidates existing sessions |
| Locked out entirely | SSO misconfigured and bootstrap login unavailable | Temporarily unset AUTH_PROVIDER and redeploy to restore the bootstrap sign-in |
Advanced
External Database
To use your own PostgreSQL, use externalDatabase.url:
externalDatabase:
url:
# Provide the DATABASE_URL via one of the following:
secretName: "willow-db-url" # existing Secret with a DATABASE_URL key (recommended)
# clearText: "" # plain-text URL, stored in a ConfigMap (not recommended)
Prefer secretName (or sealedSecret) over clearText so credentials never land in plaintext values or a ConfigMap. Create the secret first:
kubectl create secret generic willow-db-url \
--namespace <namespace> \
--from-literal=DATABASE_URL="postgres://user:password@db-host:5432/willow?sslmode=require"
Bring your own ClickHouse
The chart does not install ClickHouse. To store logs, OpenTelemetry, and analytics in a ClickHouse cluster you already run, point db-service at it. Willow creates its own database and tables on startup — it does not attach to your existing schemas, and PostgreSQL history is not backfilled.
See Bring your own ClickHouse for user grants, Helm env vars, the dual-write rollout, and what is (and is not) automatic.
Working with Custom Secrets
You'll typically mount custom Kubernetes secrets to provide:
- Platform secrets — the four values from Step 5
- External database credentials — see External Database
- Third-party API keys & SSO secrets — your SSO client secret,
OPENAI_API_KEY, and similar
Step 1 — Create the secret:
kubectl create secret generic app-api-keys \
--namespace <namespace> \
--from-literal=AUTH_OKTA_SECRET=xxxxxxxxxxxxx \
--from-literal=OPENAI_API_KEY=sk-xxxxxxxxxxxxx
Verify it was created:
kubectl get secret app-api-keys -n <namespace>
Step 2 — Reference it in values.yaml:
deployments:
app:
secretName: "app-api-keys"
All key-value pairs from the secret are mounted as environment variables in the app deployment, and override any same-named key from the ConfigMap. Use global.secretName to share a secret across all services, or sealedSecrets / externalSecrets for managed secret delivery.
Step 3 — Redeploy:
helm upgrade willow willow/webrix-helm \
--namespace <namespace> \
-f values.yaml \
--wait
Multiple Gateways (Internal + External Access)
Some organizations need two separate access points to run — for example, one for VPN users (internal network) and one for non-VPN users (public internet). This is achieved with additional Ingress resources that use a different ingress controller. Both route to the same pods — no resource duplication.
Prerequisites: two ingress controllers in your cluster, each with a different ingressClassName (e.g. nginx-internal and nginx-external).
deployments:
run:
ingress:
enabled: true
className: "nginx-internal" # VPN users
subdomain: "willow"
path: "/"
pathType: "ImplementationSpecific"
ingresses:
external:
enabled: true
className: "nginx-external" # Non-VPN users
subdomain: "willow-ext"
path: "/"
pathType: "ImplementationSpecific"
This creates two Ingress resources for run:
| Ingress Name | Hostname | Ingress Controller |
|---|---|---|
run | willow.<domain> | nginx-internal (VPN) |
run-external | willow-ext.<domain> | nginx-external (Public) |
Point each hostname to its respective controller's IP:
willow.<domain> → <internal-ingress-ip>
willow-ext.<domain> → <external-ingress-ip>
Each ingress needs its own tls block — adding a second hostname does not extend the first certificate.
Autoscaling (HPA)
Each service can scale automatically with a HorizontalPodAutoscaler. Autoscaling is off by default (services run at their fixed replicas count) — enable it per service under deployments.<service>.autoscaling:
deployments:
run:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
Requirements: the metrics-server must be installed in your cluster, and each autoscaled service must define CPU resource requests (the chart's defaults already do).
Notes:
- CPU-based by default. Memory-based autoscaling is intentionally disabled — Node/V8 and model workloads hold memory high regardless of load, so a memory target tends to scale up and never scale back down. Opt in per service with
targetMemoryUtilizationPercentageif you understand the trade-off. db-serviceconnection limits. Eachdb-servicepod opens up to ~10 PostgreSQL connections. Keep itsmaxReplicaslow enough that peak connections (maxReplicas × 10) stay well under your database'smax_connections. Raise both together, not justmaxReplicas.- Advanced tuning. You can set a custom scaling
behaviorblock per service to control scale-up/scale-down rates.
Egress Allowlist and Host Discovery
Everything runs in your cluster, so your firewall decides which upstreams a tool call can reach. Willow exports the list of hosts to allow: Integrations → the ⋮ menu → Export Egress Allowlist. Nothing to configure — it reads your stored integration config.
Stdio MCP servers are the gap. They store a package to run (npx -y example-mcp), not a host to reach, so their upstreams live inside the package and the export can only mark them unknown. To close that, let run record the DNS lookups each sandbox makes:
deployments:
run:
env:
# Your in-cluster resolver — what sandboxes already resolve through.
# kubectl get svc -n kube-system kube-dns -o jsonpath='{.spec.clusterIP}'
SANDBOX_DNS_UPSTREAM: "10.96.0.10"
Requires chart 1.0.59 or later, which grants run the NET_BIND_SERVICE capability it needs to listen on port 53. If you filter the sandbox network's egress, you also need a UDP/53 rule to that network's own gateway — the RFC1918 drop otherwise covers it.
Each sandbox gets the listener as its first resolver and yours as its second, so DNS keeps working if the listener stops answering. If it cannot bind at all, run logs the reason, adds no --dns flag, and sandbox DNS is unchanged. Removing SANDBOX_DNS_UPSTREAM is the off switch.
Observed hostnames are stored by your own db-service and never leave the cluster, so this stays compatible with an air-gapped install.
Full reference — how to read the Provenance column, what is deliberately not recorded, the metrics to watch, and troubleshooting: Egress Allowlist.
Custom Image Pull Secrets
If your cluster doesn't already have access to the quay.io/webrix registry, create an image pull secret:
kubectl create secret docker-registry willow-registry \
--namespace <namespace> \
--docker-server=quay.io \
--docker-username=<robot-username> \
--docker-password=<robot-token> \
--docker-email=unused@withwillow.ai
global.imagePullSecrets is [] in the chart defaults, so no pull secret is attached to any pod until you list one. Creating willow-registry and stopping there leaves every pod in ImagePullBackOff with no indication that the credentials are being ignored. Add it to your values.yaml:
global:
imagePullSecrets:
- name: willow-registry # any name you like — it just has to match the secret
A per-service deployments.<svc>.imagePullSecrets overrides the global list for that service.
If you don't have quay.io/webrix credentials yet, ask your Willow contact — they are issued per customer, not self-served.
Rotating the Encryption Key
ENCRYPTION_KEY is the master key db-service uses to encrypt all secrets at rest (OAuth tokens, API keys, webhook secrets, etc.) via envelope encryption: each secret gets its own random data key (DEK), and only that small DEK is wrapped by the master key. Rotating the master key means re-wrapping those DEKs — the secrets themselves are never re-encrypted or even decrypted in bulk.
This is also the procedure to run if you installed with the chart's placeholder key and need to move to your own — which is why Step 5 recommends setting it before your first install instead.
New and old keys are both accepted for decryption during the rotation window, so this can be done with a normal rolling deploy — no maintenance window required.
Step 1 — Generate a new key
openssl rand -base64 32
Step 2 — Deploy with both keys configured
Set ENCRYPTION_KEY to the new key and ENCRYPTION_KEY_PREVIOUS to the old one, then redeploy (helm upgrade, as in Step 9). From this point, all newly-encrypted data uses the new key; existing data is still readable via the old one.
deployments:
db-service:
env:
ENCRYPTION_KEY: "<new-key>"
ENCRYPTION_KEY_PREVIOUS: "<old-key>"
Use a Kubernetes secret rather than plaintext values.yaml for both values — see Working with Custom Secrets. ENCRYPTION_KEY_PREVIOUS accepts a comma-separated list if more than one retired key needs to stay readable.
Step 3 — Run the rewrap job
In the admin app, go to On-Prem → Encryption Key and click Rotate now. The job runs in the background on the server, so you can navigate away while it works; the card reports progress and the final result. Only one rotation runs at a time, across every db-service replica — they take a cluster-wide lock, so extra clicks can't start a second pass.
The button is only enabled once ENCRYPTION_KEY_PREVIOUS is configured (Step 2) — without a retired key there is nothing to rotate from.
This walks every table known to store encrypted data and re-wraps any row still on the old key. It's idempotent and safe to re-run — rows already on the new key are skipped. If it encounters a row it cannot verifiably re-wrap, it stops and reports the row rather than risk corrupting it (see "If the status card reports unreadable payloads" below).
Step 4 — Verify and clean up
Click Check status on the same card. This is a read-only pass that classifies every stored secret by the key that wrapped it, and reports:
| Result | Meaning |
|---|---|
All on current key (Pending rewrap: 0) | Rotation is complete — safe to proceed |
Rotation incomplete (Pending rewrap: > 0) | Some data is still on a retired key — run Rotate now again |
Action required (Unreadable: > 0) | Some data is wrapped by a key that is not configured — see below |
| Not checked yet | No scan has run against the keys currently configured |
A scan result only ever describes the keys that were configured when it ran, so changing ENCRYPTION_KEY or ENCRYPTION_KEY_PREVIOUS resets the card to Not checked yet. Always re-run Check status after a redeploy — a green result from a previous rotation says nothing about the current one.
ENCRYPTION_KEY_PREVIOUS is the only thing keeping un-rewrapped data readable. Removing it (or destroying the old key) while anything is still pending makes that data permanently unrecoverable.
Once it reports All on current key, remove ENCRYPTION_KEY_PREVIOUS from your values.yaml/secret, redeploy once more, and destroy the old key material.
If the status card reports unreadable payloads
This means data was encrypted with a key that is no longer configured — usually because ENCRYPTION_KEY was replaced without setting ENCRYPTION_KEY_PREVIOUS. The card lists the missing key id(s) and an example location. Add the missing key(s) to ENCRYPTION_KEY_PREVIOUS (comma-separated), redeploy, and check again. Rotation deliberately refuses to touch these rows, so the data is still recoverable as long as you have the old key.
TLS / Custom CA
If your network uses TLS inspection with a private certificate authority, add the CA certificate so the services trust internal endpoints:
global:
caCertificate: |
-----BEGIN CERTIFICATE-----
MIIDxTCCAq2gAwIBAgI...
-----END CERTIFICATE-----
The chart mounts the certificate and sets NODE_EXTRA_CA_CERTS automatically.
This governs what the services trust on outbound connections. It is separate from the certificates you serve on your ingress hostnames, which are configured in Step 4.
Troubleshooting index
Each step above has its own troubleshooting table — start there, since it tells you what should already be verified. This index maps a symptom to the step that owns it.
| Symptom | Go to |
|---|---|
Pods stuck in Pending | Step 10 |
ImagePullBackOff | Step 3 |
| Pod crashloops on startup | Step 10 |
| Database connection failures | Step 6 |
| Cannot reach any service | Step 2 |
| Hostname resolves but connections time out | Inbound network — per-cloud checks for AKS, EKS, and GKE |
| Works from inside the cluster, not from a user's machine | Test it correctly |
| Ingress returns 404 / no host | Step 10 |
| Ingress returns 503 | No healthy backend |
| TLS certificate errors | Step 4 |
| Ingress created but never programmed; hostnames 404 | Match your ingress controller — className doesn't match any IngressClass |
| Using the AKS application routing add-on | Match your ingress controller — different class, and certificates come from Key Vault |
| Which certificate do I need, and how do I install it? | Which certificate |
cert-manager Certificate never becomes ready | Verifying certificates |
cert-manager Certificate reports Issuer not found | Step 4 — the chart doesn't install cert-manager or create the ClusterIssuer |
ImagePullBackOff after creating the pull secret | Custom Image Pull Secrets — global.imagePullSecrets is empty by default, so the secret is ignored until you list it |
| Hostnames resolve to CDN addresses you didn't configure | Step 2 — a proxied wildcard record, or a stale resolver cache |
curl https://willow.<domain>/mcp returns 401 | Step 10 check 5 — expected; /mcp requires authentication |
| MCP client hangs on a cold connection, then times out | Step 4 — SSE buffering or idle timeout |
| Can't sign in, or SSO button missing | Step 11 |
| Everyone logged out after a redeploy | Step 11 — AUTH_SECRET changed |
| Installed with the chart's placeholder secrets | Step 5, then Rotating the Encryption Key |
| Stored credentials fail to decrypt | Rotating the Encryption Key |
| ClickHouse migrate/start failures | Bring your own ClickHouse |
Inspecting Deployments
kubectl get pods -n <namespace>
kubectl describe pod <pod-name> -n <namespace>
# Component logs
kubectl logs -n <namespace> deployment/app --tail=100 -f
kubectl logs -n <namespace> deployment/connect --tail=100 -f
kubectl logs -n <namespace> deployment/run --tail=100 -f
kubectl logs -n <namespace> deployment/db-service --tail=100 -f
Helm Operations
# List releases
helm list -n <namespace>
# Release history
helm history willow -n <namespace>
# Roll back to a previous revision
helm rollback willow <revision> -n <namespace>
Getting Help
If you continue to experience issues, contact Willow support with:
- Kubernetes version:
kubectl version - Helm version:
helm version - Pod status and logs
- Your
values.yaml(redact sensitive information) - Which numbered check in Step 10 first failed