Skip to main content

Deploy the platform

The Stacklok Enterprise platform runs in your Kubernetes cluster. You install it as a single umbrella Helm chart that deploys the ToolHive Operator, the Enterprise Manager, the console, and the Registry Server in one release, along with the custom resource definitions (CRDs) the operator needs.

For multi-cluster installations or separate registries per environment, see Distributed deployments. For clusters that must pull artifacts from an internal registry, see Install from a private registry (air-gapped).

Prerequisites

Before deploying, ensure you have:

  • A Kubernetes cluster (1.30 or later)
  • An ingress or gateway controller to publish the components, plus DNS records and TLS certificates for the hostnames you expose the console, Enterprise Manager, and Registry Server on. The chart creates Services but no ingress, DNS, or certificates; see Step 6.
  • An OIDC-compatible identity provider configured per Configure platform identity
  • A PostgreSQL database. The Registry Server stores the MCP and skills catalog the console reads in an external PostgreSQL instance you provide, and the Enterprise Manager's directory service needs one too. One instance can serve both, but each component needs its own database and its own credentials on it; see the Enterprise Manager database.
  • Your Stacklok Enterprise license, available from the Stacklok install portal at install.stacklok.com. The license grants access to the umbrella chart and the container images it references. Stacklok sends portal access instructions during onboarding.

What the chart includes

Enable each required component in values.yaml. All component flags default to false, and an install without values creates no platform workloads.

Enable flagWhat it deploys
toolhiveOperatorThe ToolHive Operator and its custom resource definitions (MCPServer, VirtualMCPServer, and others)
enterpriseManagerEnterprise Manager, which serves configuration to Stacklok clients
enterprise-manager.directory.enabledThe directory service: users, groups, connectors, secrets, and virtual keys. Needs enterpriseManager and a PostgreSQL database you provide
cloudUiConsole (Next.js application)
registryServerRegistry Server, backed by an external PostgreSQL database you provide
global.stacklok.aiGateway.enabledAI Gateway operator and its custom resource definitions
global.stacklok.connectorGateway.enabledConnector Gateway. Also requires global.stacklok.connectorGatewayId and global.stacklok.authServerIssuer

The gateway flags belong under global.stacklok. The console's chart identifiers retain their original names: the flag is cloudUi and its configuration key is toolhive-cloud-ui, so use those spellings in the values file.

The Connector Gateway requires its enable flag, gateway ID, and authorization server issuer. See Configure the Connector Gateway and Configure the AI Gateway.

The toolhiveOperator subchart deploys the enterprise build of the ToolHive Operator. Use Run MCP servers in Kubernetes for MCP servers and remote proxies, and the Virtual MCP Server guides for Virtual MCP Server (vMCP) gateways.

This guide uses the stacklok-system namespace. Adjust the commands if you use a different namespace. Run MCP server workloads in separate namespaces from the platform components.

Run preflight checks

Before installation, render the chart and pass its Preflight resource to the kubectl-preflight plugin. The checks cover Kubernetes version, capacity, identity configuration, database connectivity, and required Secrets.

Use the registry access, namespace, Secrets, and values.yaml prepared for the installation. Complete Step 1, Step 2, and Step 3 below first, then come back here before Step 4.

Install the CLI plugin

Install the pinned preflight release that Stacklok validates the spec against. Download it from replicatedhq/troubleshoot releases:

PREFLIGHT_VERSION=v0.131.1
os=$(uname -s | tr '[:upper:]' '[:lower:]')
if [ "$os" = "darwin" ]; then
asset="preflight_darwin_all.tar.gz"
else
arch=$(uname -m); [ "$arch" = "x86_64" ] && arch=amd64
asset="preflight_${os}_${arch}.tar.gz"
fi
curl -fsSL -o preflight.tar.gz \
"https://github.com/replicatedhq/troubleshoot/releases/download/${PREFLIGHT_VERSION}/${asset}"
tar -xzf preflight.tar.gz preflight
sudo install -m 0755 preflight /usr/local/bin/kubectl-preflight

Stacklok validates the preflight specification with the version above.

Disable automatic updates

The CLI enables automatic updates by default. Pass --auto-update=false on every invocation to retain the validated version:

kubectl preflight --auto-update=false -

kubectl preflight version reports the downloaded version even after an automatic update.

Run it against your real values

Use the complete values.yaml from Step 3. Checks for identity, signing keys, and the registry database render only when their components are configured.

helm template stacklok-enterprise \
oci://oci.stacklok.com/stacklok-enterprise/<CHANNEL>/stacklok-enterprise-platform \
--version <VERSION> \
--namespace stacklok-system \
--values values.yaml \
| kubectl preflight --auto-update=false -

<CHANNEL> and <VERSION> are the channel slug and chart version the install portal at install.stacklok.com generates for your release in its Existing cluster with Helm instructions. helm template uses the registry credentials from Step 1. Log in again if the command returns an authorization error.

Pass the same --namespace you install into

Pass the namespace used for installation. Otherwise, the signing-key check looks for the Secret in the default namespace.

What it checks

CheckRenders whenOutcome
Kubernetes versionAlwaysfail below 1.30 (the chart's floor)
Node capacity (CPU and memory)Alwayswarn below 4 allocatable cores or 6Gi, evaluated on the smallest node
DistributionAlwayswarn on OpenShift OCP
Egress reachabilitypreflight.checkEgress=truewarn only; off by default, always fails in air gap
Registry database reachabilityregistryServer.enabled and a run-time toolhive-registry-server.preflightDatabaseUri is suppliedfail if unreachable
OIDC issuer reachabilityenterpriseManager.enabled=true and global.stacklok.primaryIdp.issuer is setfail if unreachable or misconfigured (see the note below)
Signing-key Secret existsenterpriseManager.enabled=truefail if the Secret named in enterprise-manager.signingConfig.existingSecret is missing
A cluster-internal OIDC issuer can fail from your workstation

The OIDC check runs from the host executing kubectl preflight. For an issuer available only inside the cluster, run the check in-cluster through enforcement mode. Investigate a failure for a publicly reachable issuer.

Check registry database reachability

Pass a connection URI to enable the registry database check:

helm template stacklok-enterprise \
oci://oci.stacklok.com/stacklok-enterprise/<CHANNEL>/stacklok-enterprise-platform \
--version <VERSION> \
--namespace stacklok-system \
--values values.yaml \
--set registryServer.enabled=true \
--set 'toolhive-registry-server.preflightDatabaseUri=postgres://<USER>:<PASSWORD>@<HOST>:5432/<DATABASE>?sslmode=require' \
| kubectl preflight --auto-update=false -

This is the only check that reads a credential.

Pass the URI as a one-off flag

Supply preflightDatabaseUri with --set for the preflight command. A value in a persistent values file remains in the generated Kubernetes Secret. Do not commit or upload rendered output containing the URI.

Preflight is advisory by default

The CLI check does not block helm install. Resolve every fail result before installing the chart.

Optional: enforce preflight checks in-cluster

Set preflight.enforce: true to run the same checks in a pre-install and pre-upgrade Helm hook. A fail result blocks the Helm operation.

The Job uses a dedicated ServiceAccount with cluster-scoped read access to nodes, namespaces, storage classes, and CRDs. When Enterprise Manager is enabled, it also reads the signing-key Secret. The Replicated integration supplies global.replicated.dockerconfigjson so the Job can pull the licensed runner image. If the pull fails, copy this value from the install portal's Existing cluster with Helm instructions.

Pass toolhive-registry-server.preflightDatabaseUri as a one-time --set value to avoid retaining the password in the preflight Secret.

Enabling this on the air-gapped path takes extra setup, since the Job's image defaults to a Stacklok-hosted registry. See Run preflight checks against the mirrored chart.

If enforcement blocks an install, read the Job's log to see why:

kubectl logs job/stacklok-enterprise-preflight-check -n stacklok-system

Replace stacklok-enterprise when you use a different Helm release name.

To bypass a known false positive for one run, re-run the same helm command with --set preflight.enforce=false.

What preflight can't check

Two prerequisites need manual verification; troubleshoot.sh has no analyzer for either:

  • Installer RBAC rights. No SelfSubjectAccessReview-style analyzer exists. Verify against the same kubeconfig context you install with:

    kubectl auth can-i create customresourcedefinitions.apiextensions.k8s.io
    kubectl auth can-i create clusterroles.rbac.authorization.k8s.io
    kubectl auth can-i create clusterrolebindings.rbac.authorization.k8s.io
    kubectl auth can-i '*' '*' -n stacklok-system

    Each must print yes. If any prints no, helm install fails partway through with a CRD, RBAC, or namespaced-resource creation error instead of upfront.

  • CRD collisions with a prior install. No CRD analyzer ships, since troubleshoot's customResourceDefinition analyzer can only express present as pass or absent as fail, not "already present is a problem." If you're reinstalling over a previous release, check kubectl get crd | grep -E 'aigateway|toolhive' for version conflicts by hand. The pattern covers the AI Gateway CRDs as well, which install separately when global.stacklok.aiGateway.enabled is set.

Deploy with Helm

1. Authenticate to the Replicated registry

Stacklok distributes the platform through Replicated. Your license, the umbrella chart, and per-release install instructions all live in the install portal at install.stacklok.com. Log in with the credentials Stacklok provides during onboarding.

Authenticate to the OCI registry at oci.stacklok.com with your license email as the username and your License ID as the password:

helm registry login oci.stacklok.com \
--username <YOUR_EMAIL> \
--password <LICENSE_ID>

In the portal, the Existing cluster with Helm instructions generate the exact login and install commands for your release, including your channel slug and the current chart version. Note those values; you reference them when you install the chart.

Image pulls during install

The chart's Replicated integration creates the image pull credentials from your license. For an air-gapped installation, create the pull secret as described in Install from a private registry.

2. Prepare secrets

The chart expects a few Secrets to already exist. They live in the namespace the platform installs into, so create that namespace first:

kubectl create namespace stacklok-system

Then prepare the Secrets for the components you're enabling before you configure values.

Identity provider client secret. The console signs users in against your corporate provider using a confidential client, and the chart reads that client's secret from a Secret you create. There is no plaintext fallback. Use the client secret from the console's client registration in Configure platform identity:

kubectl create secret generic console-oidc-client \
--namespace stacklok-system \
--from-literal=client-secret='<CONSOLE_CLIENT_SECRET>'

The values file references it through global.stacklok.primaryIdp.clientSecretRef.

Enterprise Manager signing key. Generate the key and create the Secret as described in Generate a signing key. The values file references it by name through enterprise-manager.signingConfig.existingSecret.

Better Auth session secret. The console needs a secret of at least 32 characters to encrypt its sessions. Generate one:

openssl rand -base64 32

Use the output as toolhive-cloud-ui.betterAuth.secret in the values file.

Registry Server database passwords. Create a Secret for the database password (and a second one if you use a separate migration user), as described in Create the database credential Secrets. The values file references them through secretKeyRef, as the example below shows.

Enterprise Manager database passwords. The Enterprise Manager reads its own database passwords from Secrets, one per role, each under a fixed key. Create them with the passwords you assigned when you provisioned the roles described in the Enterprise Manager database:

kubectl create secret generic enterprise-manager-db \
--namespace stacklok-system \
--from-literal=postgres-password='<APP_PASSWORD>'

kubectl create secret generic enterprise-manager-db-migration \
--namespace stacklok-system \
--from-literal=postgres-migration-password='<MIGRATION_PASSWORD>'

The key names matter: the chart defaults to postgres-password and postgres-migration-password and looks them up by those names.

3. Configure values

Create a values.yaml file that enables the components you want and supplies their settings. Each component has an enable flag, and its configuration goes under that component's own key, as the example shows. Platform-wide settings, such as the identity provider every component trusts, go under global at the top level. The example below installs the operator, Enterprise Manager, the console, and Registry Server. Every other component, including the AI Gateway, stays off because each flag defaults to off.

Two optional additions later on this page, the Redis/Valkey and PostgreSQL defaults, also live under global. Merge those into the single global block below rather than appending a second one; see the warning after the example.

values.yaml
# Replicated SDK subchart. Required for the online install: it turns the license
# credentials injected at chart-pull time into the enterprise-pull-secret image
# pull secret the platform components reference.
replicated:
enabled: true

# Enable only the components you want.
toolhiveOperator:
enabled: true
enterpriseManager:
enabled: true
cloudUi:
enabled: true
registryServer:
enabled: true

# The corporate identity provider, configured once for the whole platform. Every
# component reads it from here. See "Configure platform identity" for the client
# registrations these values correspond to.
global:
stacklok:
primaryIdp:
issuer: 'https://idp.example.com'
clientId: '<CONSOLE_CLIENT_ID>'
clientSecretRef:
# Secret created in "Prepare secrets" above
name: 'console-oidc-client'
key: 'client-secret'
scopes: 'openid,profile,email'

# Enterprise Manager configuration. See the Enterprise Manager deployment page
# for the full reference of fields under this key.
enterprise-manager:
idpConfig:
audience: 'enterprise-manager'
requiredScope: 'toolhive:config:read'
idpType: 'generic'
signingConfig:
# Secret created in "Prepare secrets" above
existingSecret: 'enterprise-manager-signing-key'
resourceURL: 'https://config.example.com'
clientID: '<STACKLOK_CLI_CLIENT_ID>'

# Registry Server configuration. Serves the MCP and skills catalog the console
# reads. Requires an external PostgreSQL database; supply the password from
# a Secret, never inline. This user runs the schema migrations, so it needs the
# CREATEROLE attribute. See "Configure the Registry Server" for why, and for
# how to recover if the first migration fails partway.
toolhive-registry-server:
upstream:
config:
database:
host: 'postgres.example.com'
port: 5432
user: 'thv_user'
database: 'toolhive_registry'
sslMode: 'require'
extraEnv:
- name: THV_REGISTRY_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: registry-db-credentials
key: password

# Configuration for the console.
toolhive-cloud-ui:
# URL of the Registry Server. When it runs in this cluster, point at its
# in-cluster Service (named registry-api on port 8080).
apiBaseUrl: 'http://registry-api.stacklok-system.svc.cluster.local:8080'
# URL of the Enterprise Manager. Only the console's server side calls it, so
# point at its in-cluster Service, named <RELEASE_NAME>-enterprise-manager
# on port 80.
enterpriseManagerUrl: 'http://stacklok-enterprise-enterprise-manager.stacklok-system.svc.cluster.local'
betterAuth:
# Generated in "Prepare secrets" above (openssl rand -base64 32)
secret: '<BETTER_AUTH_SECRET>'
# Public URL of the console. The browser is redirected here during
# sign-in, so it must be externally reachable.
url: 'https://console.example.com'
Keep global to a single top-level block

The optional Redis/Valkey and PostgreSQL sections below are also rooted at global. YAML has no merge semantics for repeated top-level keys: a file with two global: blocks keeps only the last one, so appending either section verbatim discards the global.stacklok.primaryIdp settings above.

Nothing catches this. The render succeeds, preflight's OIDC analyzer stops rendering along with the issuer it was checking, and you get a platform with no identity provider configured and no error anywhere saying so. Add redis and postgres as siblings of stacklok inside the one global block instead.

Set the identity provider only under global.stacklok.primaryIdp

The identity provider is configured once for the whole platform, and every component reads it from global.stacklok.primaryIdp. The per-component keys toolhive-cloud-ui.oidc.* (issuerUrl, clientId, clientSecret, existingSecret, existingSecretKey, and scopes) and enterprise-manager.idpConfig.issuer are not read. Setting any of them fails the render with a message naming the global.stacklok.primaryIdp field to use instead.

The rest of enterprise-manager.idpConfig, including audience, requiredScope, and idpType, is component-scoped and belongs under enterprise-manager as the example shows. toolhive-cloud-ui.oidc.additionalProviders is a separate multi-provider feature and is unaffected.

See Configure platform identity.

For the full reference of fields each component accepts, see Configure the Enterprise Manager and Configure the Registry Server.

Enterprise Manager database

The example above runs the Enterprise Manager without a database, which is a complete configuration: it serves signed configuration envelopes to Stacklok clients, and its database-backed modules stay switched off.

The directory stores users, groups, connectors, managed secrets, and virtual API keys in PostgreSQL. Add the block below to enable these features.

Provision two roles on your PostgreSQL instance first, in a database of their own:

  • An application role for normal reads and writes. Make it a plain role: not a superuser, and without BYPASSRLS.

  • A migration role, which owns the schema and applies migrations at startup. It needs the CREATEROLE attribute, because the Enterprise Manager creates a module role of its own at startup, on the migration connection:

    CREATE ROLE em_migrator LOGIN PASSWORD '<MIGRATION_PASSWORD>' CREATEROLE;
    CREATE ROLE em_app LOGIN PASSWORD '<APP_PASSWORD>';
    CREATE DATABASE enterprise_manager OWNER em_migrator;

    CREATEROLE is a role attribute rather than a grant, so it is not implied by ownership or by any GRANT on the schema. Without it the Enterprise Manager exits at startup with permission denied to create role.

values.yaml (add alongside the other enterprise-manager keys)
enterprise-manager:
# Required whenever the directory service is on: the chart pins a
# secret-reader grant, and it refuses to render if that grant would land on
# the namespace's shared `default` ServiceAccount. automount is needed as
# well, because the module reads Secrets from the Kubernetes API at startup
# and cannot do so without a projected token.
serviceAccount:
create: true
automount: true
database:
host: 'postgres.example.com'
port: 5432
name: 'enterprise_manager'
user: 'em_app'
sslMode: 'require'
# Secret created in "Prepare secrets" above, key postgres-password.
existingSecret: 'enterprise-manager-db'
migration:
user: 'em_migrator'
# Secret created in "Prepare secrets" above, key
# postgres-migration-password.
existingSecret: 'enterprise-manager-db-migration'
directory:
enabled: true
# Externally reachable URL of the Enterprise Manager. SCIM clients read it
# in Location headers and member references.
scimExternalBaseURL: 'https://config.example.com'
Use two distinct roles

The directory keeps one user's data from reaching another with PostgreSQL row-level security, and that holds only while the application role is subject to the policy. Keep database.user and database.migration.user separate, and keep the application role unprivileged.

The Enterprise Manager exits at startup if the application role carries BYPASSRLS, with the error app DB user "..." has BYPASSRLS - RLS is voided.

Cross-user connector administration requires a separate database role. Set enterprise-manager.database.admin.user and its Secret with the key postgres-admin-password to enable it.

That role must carry BYPASSRLS, and nothing grants it for you. The Enterprise Manager only checks the attribute and refuses to start without it, on every PostgreSQL, whether self-hosted or managed. Grant it yourself before installing:

CREATE ROLE em_admin LOGIN PASSWORD '<ADMIN_PASSWORD>';
ALTER ROLE em_admin BYPASSRLS;

The startup error names a "database bootstrap Job" if you miss this. No such Job ships with the chart; the ALTER ROLE above is the whole remedy.

Encrypting stored credentials

The directory uses a key-encryption key (KEK) to encrypt managed secrets, connector credentials, and the catalog entry for your primary identity provider. Enable the KEK to use these features.

The key is read from the Kubernetes API, and the decrypted plaintext crosses the gRPC port. The chart validates the related settings at render time and identifies any missing value:

values.yaml (add alongside the other enterprise-manager keys)
enterprise-manager:
rbac:
create: true
directory:
kek:
enabled: true
# Have the chart mint a key on install. Use existingSecret instead for
# GitOps: chart generation is not render-stable under Argo CD or
# `helm template | kubectl apply`, which silently replaces the key and
# orphans every credential already stored.
generate: true
grpc:
tls:
enabled: true
# Bring your own certificate, or set issuerRef.name for cert-manager.
existingSecret: 'enterprise-manager-grpc-tls'
networkPolicy:
enabled: true
allowedClientSelector:
matchLabels:
app.kubernetes.io/name: connector-gateway

TLS is not optional here: the directory refuses a non-TLS peer at call time, so a key-encrypted install served over the cleartext port renders cleanly and then fails every credential resolution. Point callers at grpc.tls.port. If a service mesh or cluster-wide policy already restricts the gRPC ports, set directory.kek.externallyManagedNetworkPolicy=true to acknowledge that instead of defining a policy here; there is no equivalent escape for TLS, because a mesh terminates it in the sidecar and the peer still arrives without it.

SCIM identity providers

enterprise-manager.directory.issuers configures the identity providers the directory accepts SCIM provisioning from. An empty list is valid, and is the right starting point before any provider is onboarded, but SCIM stays off until you add an entry:

values.yaml (add alongside the other enterprise-manager keys)
enterprise-manager:
directory:
issuers:
- id: 'okta-prod'
issuer_url: 'https://company.okta.com'
audience: 'enterprise-manager'
# The JWT claim carrying the stable per-user identifier. Required.
binding_claim: 'uid'
scim_bearer_token_ref:
namespace: 'stacklok-system'
name: 'directory-scim-token-okta'
key: 'token'

Use the claim your provider uses as the stable user identifier. binding_claim has no default. The Enterprise Manager validates the claim and issuer at startup.

Global Redis/Valkey defaults

Several platform components can inherit an external Redis or Valkey instance from global.redis for distributed session storage. Valkey is a drop-in replacement for Redis. When global.redis.host is empty, each component uses its own configuration.

values.yaml (inside the existing global block)
global:
# The stacklok.primaryIdp block from the example above stays here unchanged.
# Add redis as its sibling; don't start a second top-level global block.
redis:
# When empty, the global default is inactive.
host: 'redis.example.com'
port: 6379
# Enable the Redis Cluster protocol when connecting to the host.
clusterMode: false
tls:
enabled: false

If your instance requires authentication, create a Secret holding the password and name it in values. Components read the name from configuration rather than looking for one by convention, so a Secret you create but never reference has no effect:

kubectl create secret generic redis-auth \
--namespace stacklok-system \
--from-literal=redis-password="<YOUR_REDIS_PASSWORD>"
values.yaml (inside the existing global block)
global:
redis:
host: 'redis.example.com'
existingSecret: 'redis-auth'
# Defaults to redis-password when omitted.
existingSecretKey: 'redis-password'

The ToolHive operator also accepts toolhive-operator.upstream.operator.defaultRedis.existingSecret and existingSecretKey for a component-specific credential, which take precedence over the global pair.

A passwordless instance needs no Secret at all: leave existingSecret unset and the components connect without credentials.

The following components inherit global.redis:

Enable flagHow it uses the global default
toolhiveOperatorDefault session storage for MCPServer, MCPRemoteProxy, and VirtualMCPServer (vMCP) workloads that have no explicit spec.sessionStorage. The per-resource setting always overrides.
global.stacklok.aiGateway.enabledNER scan-result caching when an AIGateway enables the cache

The embedded auth server's token storage is configured separately through MCPExternalAuthConfig. See Configure session storage in the operator guide.

note

The operator subchart also exposes toolhive-operator.upstream.operator.defaultRedis.addr for an operator-specific override. When set, it takes precedence over global.redis.host and global.redis.port; when empty, the operator falls back to the global block.

Global PostgreSQL defaults

Components that need PostgreSQL read its host, port, and SSL mode from global.postgres when their own database.host is empty. This is the same pattern as global.redis above, intended for umbrella deployments that share a PostgreSQL instance across several backing services. The block is optional: when global.postgres.host is empty, the default is inactive and each subchart uses its own database block.

Both the Registry Server and the Enterprise Manager inherit from it, and that second one is easy to miss.

values.yaml (inside the existing global block)
global:
# Another sibling of stacklok, in the same single global block.
postgres:
# When empty, the global default is inactive.
host: 'postgres.example.com'
port: 5432
sslMode: 'require'

host, port, and sslMode are inherited from the global block, and so is the password Secret: the Enterprise Manager falls back to global.postgres.existingSecret and existingSecretKey when its own database.existingSecret is empty. Database names and users have no global equivalent and always stay on the component, under toolhive-registry-server.upstream.config.database and enterprise-manager.database. A locally set value always wins over the global one.

:::danger[Setting global.postgres.host switches on the Enterprise Manager's database]

A host is all it takes. The Enterprise Manager inherits it, decides its database-backed modules are wanted, and then fails to start because enterprise-manager.database.name and .user have no global equivalent and are still empty:

create database pool: invalid configuration: user is required

The pod crashloops and helm install --wait times out. If you set global.postgres, configure the Enterprise Manager database in the same values file.

Note that the block earns you nothing if the Registry Server is the only component you meant it for, since the example in Step 3 already sets that component's own host, and a local host always wins. Setting enterprise-manager.database.host directly, and leaving global.postgres out, avoids the interaction entirely.

:::

4. Install the chart

Install the chart into the stacklok-system namespace you created earlier. Reference the chart by its oci:// URL, using the channel slug and version from Step 1:

helm install stacklok-enterprise \
oci://oci.stacklok.com/stacklok-enterprise/<CHANNEL>/stacklok-enterprise-platform \
--version <VERSION> \
--namespace stacklok-system \
--values values.yaml \
--wait --timeout 10m

--wait keeps Helm running until the workloads are ready and returns a non-zero exit status if they do not become ready within the timeout.

5. Verify the install

List the pods and check that each is Running and fully ready, with no restarts:

kubectl get pods -n stacklok-system

The exact pod count depends on which components you enabled, so read the READY column rather than counting rows: every pod should show all of its containers ready, such as 1/1, and a restart count that stays at zero. A pod stuck in Pending, ImagePullBackOff, CrashLoopBackOff, or cycling restarts is a failed install even though the objects exist.

For anything not ready, the events and logs name the cause:

kubectl describe pod <POD_NAME> -n stacklok-system
kubectl logs <POD_NAME> -n stacklok-system

Confirm the ToolHive CRDs registered:

kubectl get crd | grep toolhive.stacklok.dev

This prints one line per registered CRD, and prints nothing at all if the operator's CRD chart didn't install. An empty result with toolhiveOperator enabled means the install did not complete, whatever helm list reports.

6. Expose the platform endpoints

The chart creates ClusterIP Services for the components but no ingress. Publish these three through your ingress or gateway controller so browsers and clients outside the cluster can reach them at the hostnames you set in Step 3. List the Services to get their names (most are prefixed with your Helm release name):

kubectl get svc -n stacklok-system

Route each external hostname to its Service with the Ingress, HTTPRoute, or Gateway resources your controller uses:

ComponentService (port)Reached byHostname to route
Console<RELEASE_NAME>-toolhive-cloud-ui (80)BrowsersbetterAuth.url
Enterprise Manager<RELEASE_NAME>-enterprise-manager (80)Stacklok CLI clientsresourceURL
Registry Serverregistry-api (8080)Stacklok CLI clientsthe registry's public API URL

The Registry Server uses the fixed Service name registry-api.

Set betterAuth.url to the public console address that your ingress or gateway publishes.

The in-cluster URLs (apiBaseUrl, enterpriseManagerUrl) stay as Service DNS and need no routing. Once the routes resolve, confirm the Enterprise Manager answers at its external hostname:

curl -sf https://config.example.com/.well-known/toolhive-configuration | jq .

7. Prepare workload namespaces

If you run MCP server and vMCP workloads in a namespace other than stacklok-system (the recommended setup, see the Namespaces note above), copy the image pull secret into that namespace. The operator stamps the enterprise-pull-secret secret onto every workload pod it spawns, and the kubelet resolves it in the pod's own namespace. The Replicated integration creates it only in stacklok-system:

kubectl create namespace <WORKLOAD_NAMESPACE>

# Copy the pull secret from the platform namespace.
kubectl get secret enterprise-pull-secret -n stacklok-system \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d \
| kubectl create secret generic enterprise-pull-secret \
--namespace <WORKLOAD_NAMESPACE> \
--type kubernetes.io/dockerconfigjson \
--from-file=.dockerconfigjson=/dev/stdin

Repeat for any namespace that hosts operator-managed workloads. If you run everything in stacklok-system, skip this step.

Next steps

Troubleshooting

helm install times out

Inspect the pods as described in Step 5. The release remains installed so its events and logs are available. After resolving the cause, run helm upgrade with the same values and wait options.

Every pod reports ImagePullBackOff

Confirm that enterprise-pull-secret exists in the installation namespace and that replicated.enabled is true in your values.