Skip to main content

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

RoleWhat they need access toWhich steps
Platform / Kubernetes engineerkubectl and helm against the target cluster; permission to create namespaces, secrets, ingressesSteps 1, 3, 4, 5, 6, 7, 8, 9, 10
DNS adminAbility to create records on your domainStep 2
Security / secrets ownerYour secret store, and sign-off on where key material livesStep 5
Database administrator (if using an external database)Create a database, user, and grants; network path from the clusterStep 6
Identity / SSO adminRegister an OIDC application in Okta, Entra/ADFS, Keycloak, Google, or GitHubStep 11
Network / firewall adminEgress for image pulls, and for any third-party APIs your tools callStep 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-IP

    On OpenShift you can use Route instead of Ingress — 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.

DecisionOptionsNotes
DatabaseIn-cluster PostgreSQL, or your own external PostgreSQLCovered in Step 6. Moving later means a data migration
Log storagePostgreSQL (default), or your own ClickHouseClickHouse is easier to adopt at install time; PostgreSQL history is not backfilled if you switch later. See Bring your own ClickHouse
SSO providerOkta, Entra/ADFS, Keycloak, Google, GitHub — or none at firstCovered in Step 11. You can install without it and add it after
AI guardrailsOn (needs an LLM provider key) or offOnly guardrails require OPENAI_API_KEY; everything else works without it
HostnamesChart defaults, or your ownCovered in Step 1

The order of operations

PhaseWhat happensWhy it can't move earlier
1. Prepare your infrastructure (Steps 1–4)Hostnames, DNS, image access, TLSYour 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.yamlSecrets 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 --installNeeds Phase 2
4. Verify and sign in (Steps 10–11)Pods, ingress, TLS, first login, SSONeeds a running release

Checklist

Get a version you can tick off

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

  • kubectl pointed at the right cluster, helm v3+ 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 ClusterIssuer reporting READY: 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.yaml written and rendered clean with helm template (Step 7)

Phase 3 — Install

  • Chart repository added (Step 8)
  • Release installed (Step 9)

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:

ServiceDefault subdomainResulting hostnameWhat it's for
appwillow-adminwillow-admin.example.comAdministrative console
connectwillow-dashboardwillow-dashboard.example.comEnd-user dashboard and OAuth flows
runwillowwillow.example.comMCP 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

SymptomCauseFix
dig returns nothingRecord missing or not propagatedRe-check the record and wait out the TTL
EXTERNAL-IP stays <pending>No load balancer provisioned for the ingress controllerFix the ingress controller first — nothing downstream works without it
Resolves to a node IPPointed at a node instead of the controller's load balancerPoint at the LoadBalancer address
Resolves to CDN addresses you didn't configureA wildcard record (*.<YOUR_DOMAIN>) is answering, and it's proxiedCreate 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 reachesStale resolver or OS cache from before the record existedQuery 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 set global.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

SymptomCauseFix
ImagePullBackOff / unauthorizedNo registry credentials, or the secret exists but isn't referencedCreate the pull secret and list it in global.imagePullSecrets — see Custom Image Pull Secrets
no such host / timeoutEgress to quay.io blockedMirror 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 youingress.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 chart neither installs cert-manager nor creates an issuer

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

First, check which ingress controller you have — it decides how certificates work

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:

OptionUse whenNeeds public inbound to issue?
A certificate your organization already ownsYou have a wildcard for the domain, or a procurement processNo
cert-manager with a DNS-01 challengeYou want auto-renewal and can use your DNS provider's APINo
cert-manager with an HTTP-01 challengeYou want auto-renewal and the hostnames are publicly reachableYes
A certificate on your cloud load balancerYou already terminate TLS at an ALB, Application Gateway, or GCLBNo
HTTP-01 cannot issue a certificate for a hostname that isn't publicly reachable

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.

An internal CA is not enough for the run hostname

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 run hostname must not buffer or time out streaming (SSE)

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

Do this before your first install — not after

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:

SecretChart valuePurpose
AUTH_SECRETglobal.authSecretSigns admin and dashboard sessions in app and connect. Rotating it logs everyone out — nothing worse
DB_AUTH_SECRETglobal.dbAuthSecretAuthenticates service-to-service calls into db-service. Reaches the services as their AUTH_SECRET
AUTO_AUTHENTICATE_TOKENglobal.autoAuthenticateTokenUsed by connect and db-service for the auto-authentication flow
ENCRYPTION_KEYdeployments.db-service.env.ENCRYPTION_KEYMaster 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 chart

For 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

SymptomCauseFix
db-service crashloops with a connection errorWrong credentials, or the database isn't reachable from the clusterRun the pgtest check above; check security groups and network policy
Both databases seem configuredwebrix-postgresql.enabled: true and externalDatabase.url setEnable only one
sslmode errorsServer requires or rejects TLSMatch 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"
Leave global.org alone on-prem

An 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.

All configuration values

Every available value, setting, and default is documented in the Helm chart repository: github.com/webrix-ai/webrix-helm

Configuration reference

ValuePurpose
global.domain.hostYour base domain. Used to build the ingress hostname for each service.
global.secretNameKubernetes secret shared by all services. Where the Step 5 secrets belong.
global.authSecretSession signing secret for app and connect. Chart default is a public placeholder.
global.dbAuthSecretService-to-service auth secret. Chart default is a public placeholder.
global.autoAuthenticateTokenAuto-authentication token. Chart default is a public placeholder.
global.OPENAI_API_KEYOptional. Required only if you use AI-powered guardrails.
global.imagePullSecretsRegistry 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_KEYMaster key for secrets at rest. Chart default is a public placeholder.
deployments.<svc>.ingress.subdomainFirst label of the service hostname. Defaults: willow-admin, willow-dashboard, willow.
deployments.<svc>.ingress.tlsTLS certificate for the service. Empty by default — you must set it.
webrix-postgresql.enabledDeploys 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
Prefer an OCI registry?

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

SymptomCauseFix
--wait times outOne or more pods never became readyMove to Step 10 — the pod-level checks identify which
cannot re-use a name that is still in useA release with that name already existshelm list -n <namespace>; upgrade it rather than installing a second one
Rendering errorsMalformed values.yamlRe-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 connectivitydb-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 this from where your users actually are

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.

This proves reachability, not streaming

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

SymptomCauseFix
Pods stuck in Pending, Insufficient cpuNot enough allocatable CPU — the four default services request ~1200m between them, before PostgreSQL, your ingress controller, and system podskubectl 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
ImagePullBackOffNo registry accessSee Step 3 and Custom Image Pull Secrets
db-service crashloopsDatabase unreachable or wrong credentialsSee Step 6; in-cluster, check kubectl logs webrix-postgresql-0 -n <namespace>
app or connect crashloopsConfiguration error in values.yamlkubectl logs <pod> -n <namespace>
Ingress returns 404 / no hostIngress controller missing, or className mismatchConfirm a controller is running and the className matches it
Ingress returns 503No Ready pod behind the serviceNo healthy backend — usually ImagePullBackOff, which also breaks HTTP-01 issuance
Certificate warningsingress.tls not set, or issuance failedSee 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 namespaceThe controller logs name the secret it expected — see Verifying certificates
Certificate reports Issuer not foundThe ClusterIssuer named in the annotation doesn't exist — the chart doesn't create itInstall cert-manager and apply the issuer — see Step 4
Certificate stuck at Ready: FalseHTTP-01 can't reach the hostname from the internetUse DNS-01 or your own certificate — see Which certificate
Certificate works in a browser, MCP clients reject itIssued by an internal CAThe run hostname needs a publicly trusted certificate — see Which certificate
Connections time out from outside, work from insideA firewall, security group, or load balancer ruleInbound network — per-cloud checks
Hostnames don't resolveDNS not pointing at the ingressSee Step 2
/mcp returns 401 immediatelyThis is a pass/mcp requires authenticationContinue; the live MCP client in check 6 is the real streaming test
/mcp hangs with no response at allA proxy in front of run is buffering the responseDisable response buffering on the run route — see Step 4
An MCP client connects, then drops at ~60sIdle timeout on a load balancer in front of the ingressRaise the LB idle timeout — see Step 4
ClickHouse migrate/start failuresMissing URL, grants, or networkSee 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:

  • Apphttps://willow-admin.<domain> — administrative console
  • Dashboardhttps://willow-dashboard.<domain> — end-user dashboard, analytics, and OAuth flows
  • Runhttps://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.

Treat the admin hostname as privileged until SSO is configured

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:

ProviderRequired variables
OktaAUTH_PROVIDER, AUTH_OKTA_ID, AUTH_OKTA_SECRET, AUTH_OKTA_ISSUER
ADFS / EntraAUTH_PROVIDER, AUTH_ADFS_ID, AUTH_ADFS_SECRET, AUTH_ADFS_ISSUER
KeycloakAUTH_PROVIDER, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, AUTH_KEYCLOAK_ISSUER
GoogleAUTH_PROVIDER, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
GitHubAUTH_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

SymptomCauseFix
Provider button doesn't appearAUTH_<PROVIDER>_ID not set, so the provider isn't registeredConfirm the ID variable reached the pod: kubectl exec deploy/app -n <namespace> -- env | grep AUTH_
redirect_uri_mismatchCallback URL not registered, or hostname mismatchRegister https://willow-admin.<domain>/api/auth/callback/<provider> exactly
Login loops back to the sign-in pageSession cookie rejected — usually a hostname or TLS mismatchConfirm you're browsing the same hostname the chart configured, over HTTPS
Sessions drop after a redeployAUTH_SECRET changedExpected: rotating the session secret invalidates existing sessions
Locked out entirelySSO misconfigured and bootstrap login unavailableTemporarily 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 NameHostnameIngress Controller
runwillow.<domain>nginx-internal (VPN)
run-externalwillow-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 targetMemoryUtilizationPercentage if you understand the trade-off.
  • db-service connection limits. Each db-service pod opens up to ~10 PostgreSQL connections. Keep its maxReplicas low enough that peak connections (maxReplicas × 10) stay well under your database's max_connections. Raise both together, not just maxReplicas.
  • Advanced tuning. You can set a custom scaling behavior block 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
Creating the secret is not enough — you must also reference it

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.

Zero downtime

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:

ResultMeaning
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 yetNo 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.

Do not destroy old key material until this reports zero pending and zero unreadable

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.

SymptomGo to
Pods stuck in PendingStep 10
ImagePullBackOffStep 3
Pod crashloops on startupStep 10
Database connection failuresStep 6
Cannot reach any serviceStep 2
Hostname resolves but connections time outInbound network — per-cloud checks for AKS, EKS, and GKE
Works from inside the cluster, not from a user's machineTest it correctly
Ingress returns 404 / no hostStep 10
Ingress returns 503No healthy backend
TLS certificate errorsStep 4
Ingress created but never programmed; hostnames 404Match your ingress controllerclassName doesn't match any IngressClass
Using the AKS application routing add-onMatch 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 readyVerifying certificates
cert-manager Certificate reports Issuer not foundStep 4 — the chart doesn't install cert-manager or create the ClusterIssuer
ImagePullBackOff after creating the pull secretCustom Image Pull Secretsglobal.imagePullSecrets is empty by default, so the secret is ignored until you list it
Hostnames resolve to CDN addresses you didn't configureStep 2 — a proxied wildcard record, or a stale resolver cache
curl https://willow.<domain>/mcp returns 401Step 10 check 5 — expected; /mcp requires authentication
MCP client hangs on a cold connection, then times outStep 4 — SSE buffering or idle timeout
Can't sign in, or SSO button missingStep 11
Everyone logged out after a redeployStep 11AUTH_SECRET changed
Installed with the chart's placeholder secretsStep 5, then Rotating the Encryption Key
Stored credentials fail to decryptRotating the Encryption Key
ClickHouse migrate/start failuresBring 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