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 flag | What it deploys |
|---|---|
toolhiveOperator | The ToolHive Operator and its custom resource definitions (MCPServer, VirtualMCPServer, and others) |
enterpriseManager | Enterprise Manager, which serves configuration to Stacklok clients |
enterprise-manager.directory.enabled | The directory service: users, groups, connectors, secrets, and virtual keys. Needs enterpriseManager and a PostgreSQL database you provide |
cloudUi | Console (Next.js application) |
registryServer | Registry Server, backed by an external PostgreSQL database you provide |
global.stacklok.aiGateway.enabled | AI Gateway operator and its custom resource definitions |
global.stacklok.connectorGateway.enabled | Connector 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.
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.
--namespace you install intoPass the namespace used for installation. Otherwise, the signing-key check looks
for the Secret in the default namespace.
What it checks
| Check | Renders when | Outcome |
|---|---|---|
| Kubernetes version | Always | fail below 1.30 (the chart's floor) |
| Node capacity (CPU and memory) | Always | warn below 4 allocatable cores or 6Gi, evaluated on the smallest node |
| Distribution | Always | warn on OpenShift OCP |
| Egress reachability | preflight.checkEgress=true | warn only; off by default, always fails in air gap |
| Registry database reachability | registryServer.enabled and a run-time toolhive-registry-server.preflightDatabaseUri is supplied | fail if unreachable |
| OIDC issuer reachability | enterpriseManager.enabled=true and global.stacklok.primaryIdp.issuer is set | fail if unreachable or misconfigured (see the note below) |
| Signing-key Secret exists | enterpriseManager.enabled=true | fail if the Secret named in enterprise-manager.signingConfig.existingSecret is missing |
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.
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.iokubectl auth can-i create clusterroles.rbac.authorization.k8s.iokubectl auth can-i create clusterrolebindings.rbac.authorization.k8s.iokubectl auth can-i '*' '*' -n stacklok-systemEach must print
yes. If any printsno,helm installfails 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
customResourceDefinitionanalyzer can only express present as pass or absent as fail, not "already present is a problem." If you're reinstalling over a previous release, checkkubectl get crd | grep -E 'aigateway|toolhive'for version conflicts by hand. The pattern covers the AI Gateway CRDs as well, which install separately whenglobal.stacklok.aiGateway.enabledis 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.
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.
# 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'
global to a single top-level blockThe 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.
global.stacklok.primaryIdpThe 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.
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
CREATEROLEattribute, 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;CREATEROLEis a role attribute rather than a grant, so it is not implied by ownership or by anyGRANTon the schema. Without it the Enterprise Manager exits at startup withpermission denied to create role.
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'
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:
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:
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.
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>"
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 flag | How it uses the global default |
|---|---|
toolhiveOperator | Default session storage for MCPServer, MCPRemoteProxy, and VirtualMCPServer (vMCP) workloads that have no explicit spec.sessionStorage. The per-resource setting always overrides. |
global.stacklok.aiGateway.enabled | NER 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.
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.
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:
| Component | Service (port) | Reached by | Hostname to route |
|---|---|---|---|
| Console | <RELEASE_NAME>-toolhive-cloud-ui (80) | Browsers | betterAuth.url |
| Enterprise Manager | <RELEASE_NAME>-enterprise-manager (80) | Stacklok CLI clients | resourceURL |
| Registry Server | registry-api (8080) | Stacklok CLI clients | the 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
- Configure policies to control client behavior across your organization
- Verify the distribution to confirm the signatures, provenance, and SBOMs of the images you pulled
- Sign in to the console once the platform is running
Related information
- Distributed deployments to spread components across clusters or maintain separate registries per environment
- Install from a private registry (air-gapped) for clusters that can't reach Replicated at install time
- Configure platform identity - the identity provider configuration this deployment depends on as a prerequisite
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.