Deployment in Kubernetes
The Registry server can be deployed in various environments, from local development to production Kubernetes clusters.
Kubernetes deployment
The Registry server is designed to run as an independent deployment, possibly alongside the ToolHive Operator.
ToolHive Operator
ToolHive Operator supports deploying the Registry server. See Deploy the Registry server in Kubernetes for a complete guide.
Manual Deployment
Below is an example Kubernetes Deployment configuring the ToolHive Registry Server to expose a single static registry based on a Git repository.
This example assumes that a Postgres database is available at db.example.com
and the necessary users for migration and application execution are configured
and able to connect to a registry database. It also assumes that you have a
keycloak instance configured to act as identity provider.
All resources are created in the toolhive-system namespace. This namespace
must exist before applying the deployment.
For further details about user grants read the Migration user privileges and Application user privileges sections.
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry-api
namespace: toolhive-system
spec:
replicas: 1
selector:
matchLabels:
app: registry-api
template:
metadata:
labels:
app: registry-api
spec:
initContainers:
- name: pgpass-fixer
image: alpine:3
command:
- /bin/sh
- -c
- cp /cfg/* /thv/ && chmod 0600 /thv/pgpass && chown 65532:65532 /thv/pgpass
volumeMounts:
- name: thv
mountPath: /thv
- name: config
mountPath: /cfg/config.yaml
subPath: config.yaml
- name: pgpass
mountPath: /cfg/pgpass
subPath: pgpass
containers:
- name: registry-api
image: ghcr.io/stacklok/thv-registry-api:latest
args:
- serve
- --config=/thv/config.yaml
env:
- name: PGPASSFILE
value: /thv/pgpass
ports:
- containerPort: 8080
name: http
volumeMounts:
- name: thv
mountPath: /thv
readOnly: true
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: thv
emptyDir: {}
- name: config
configMap:
name: registry-api-config
items:
- key: config.yaml
path: config.yaml
- name: pgpass
secret:
secretName: registry-api-pgpass
items:
- key: pgpass
path: pgpass
---
apiVersion: v1
kind: ConfigMap
metadata:
name: registry-api-config
namespace: toolhive-system
data:
config.yaml: |
registryName: my-registry
registries:
- name: git-registry
format: toolhive
git:
repository: https://github.com/stacklok/toolhive.git
branch: main
path: pkg/registry/data/registry.json
syncPolicy:
interval: "15m"
auth:
mode: oauth
oauth:
resourceUrl: https://registry.example.com
providers:
- name: keycloak
issuerUrl: https://keycloak.example.com/realms/mcp
audience: registry-api
database:
host: db.example.com
port: 5432
user: db_app
migrationUser: db_migrator
database: registry
sslMode: verify-full
---
apiVersion: v1
kind: Secret
metadata:
name: registry-api-pgpass
namespace: toolhive-system
type: Opaque
stringData:
pgpass: |
db.example.com:5432:registry:db_app:app_password
db.example.com:5432:registry:db_migrator:migrator_password
---
apiVersion: v1
kind: Service
metadata:
name: registry-api
namespace: toolhive-system
spec:
selector:
app: registry-api
ports:
- port: 80
targetPort: 8080
protocol: TCP
type: ClusterIP
Apply the deployment:
kubectl apply -f deployment.yaml
Workload discovery
Kubernetes workload discovery works by looking for annotations in a specific set
of workloads. The types being watched are
MCPServer,
MCPRemoteProxy, and
VirtualMCPServer.
By default, the Registry server discovers resources in all namespaces
(cluster-wide). You can restrict discovery to specific namespaces by setting the
THV_REGISTRY_WATCH_NAMESPACE environment variable to a comma-separated list of
namespace names in your deployment:
env:
- name: THV_REGISTRY_WATCH_NAMESPACE
value: toolhive-system,production
When THV_REGISTRY_WATCH_NAMESPACE is set, only resources in the specified
namespaces are discovered. When unset, the server watches all namespaces.
Both RBAC options below use the same ClusterRole for workload discovery and a separate namespace-scoped Role for leader election. The difference is how the ClusterRole is bound.
Cluster-wide discovery (default)
For cluster-wide discovery, apply the following resources:
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api
namespace: toolhive-system
---
# Manager role for workload discovery (ToolHive CRDs + services)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api-manager
rules:
- apiGroups:
- toolhive.stacklok.dev
resources:
- mcpservers
- mcpremoteproxies
- virtualmcpservers
verbs:
- get
- list
- watch
- apiGroups:
- ''
resources:
- services
verbs:
- get
- list
- watch
---
# Leader election role (namespace-scoped, always required)
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api-leader-election
namespace: toolhive-system
rules:
- apiGroups:
- ''
resources:
- configmaps
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- ''
resources:
- events
verbs:
- create
- patch
---
# Leader election binding (always namespace-scoped)
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api-leader-election
namespace: toolhive-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: registry-api-leader-election
subjects:
- kind: ServiceAccount
name: registry-api
namespace: toolhive-system
---
# Cluster-wide binding for the manager role
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: registry-api-manager
subjects:
- kind: ServiceAccount
name: registry-api
namespace: toolhive-system
Namespace-scoped discovery
When THV_REGISTRY_WATCH_NAMESPACE is set, use the same ClusterRole but bind it
with a RoleBinding in each watched namespace instead of a ClusterRoleBinding.
Create one RoleBinding per namespace:
# Use the same ServiceAccount, ClusterRole, and leader election
# Role/RoleBinding from the cluster-wide example above.
# Replace the ClusterRoleBinding with one RoleBinding per namespace:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
toolhive.stacklok.io/registry-name: registry-api
name: registry-api-manager
namespace: toolhive-system # repeat for each watched namespace
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: registry-api-manager
subjects:
- kind: ServiceAccount
name: registry-api
namespace: toolhive-system
Applying the service account
Apply the service account to the registry server deployment in the
spec.template.spec section:
spec:
template:
spec:
serviceAccountName: registry-api
If you run multiple Registry Server instances in the same namespace, set the
THV_REGISTRY_LEADER_ELECTION_ID environment variable to a unique value for
each instance to avoid leader election lease conflicts. The Helm chart handles
this automatically.