Cluster Provisioning¶
Provision a Kubernetes cluster for the reference deployment, with DNS, TLS, ingress, storage, and registry access. The worked example uses K3s as its Kubernetes distribution; MCP Runtime can also run on the other distributions described in Deployment Options.
The node layout, packaged Traefik, ServiceLB labels, install commands, and container-runtime configuration below are specific to the K3s example. If you choose another distribution, use its provisioning instructions and the shared Cluster Requirements checks before installing Runtime.
The four-node layout is a demo or pilot reference design, not an inventory of the current public deployment. A five-node variant is covered below. The control plane is not highly available; a production K3s control plane needs its HA topology with three server nodes and separate datastore backups.
This guide provisions the Runtime cluster. The reference deployment's external Keycloak identity provider runs separately; its VM and Docker/Caddy lifecycle are covered in Public Reference Deployment.
Reference Topology¶
| Node | k3s role | Recommended size | Purpose |
|---|---|---|---|
mcp-cp-1 |
server | 4-8 vCPU, 8-16 GiB RAM | Kubernetes API, scheduler, controller manager, embedded datastore, light platform workloads |
mcp-ingress-1 |
agent | 2-4 vCPU, 4-8 GiB RAM | Public Traefik ServiceLB node for ports 80 and 443 |
mcp-worker-1 |
agent | 2-4 vCPU, 4-8 GiB RAM | Platform, operator, registry, and MCP server workloads |
mcp-worker-2 |
agent | 2-4 vCPU, 4-8 GiB RAM | Extra capacity and scheduling headroom |
For a five-node demo, add mcp-worker-3 as another general worker. If you need
control-plane high availability, use the k3s HA server topology; adding one
more server node does not provide HA.
This guide assumes all nodes use the same CPU architecture. Standard VPS and
most on-prem x86 servers are amd64, so setup builds linux/amd64 images. Do
not mix amd64 and arm64 nodes; MCP Runtime does not yet publish multi-arch
setup images.
Prerequisites¶
- Ubuntu 24.04 or another k3s-supported Linux distribution on every node.
- Root or passwordless sudo access on every node.
- Node-to-node network connectivity for k3s and the pod network.
- Public ports 80 and 443 open on the ingress node.
- Kubernetes API port 6443 reachable from worker nodes and your admin workstation. Restrict it to trusted IPs when the node has a public address.
- A default storage path. k3s installs
local-pathby default; use a real CSI, NFS, Longhorn, or another durable storage class for production. - DNS records for the platform hosts:
platform.example.com -> <ingress-public-ip>
registry.example.com -> <ingress-public-ip>
mcp.example.com -> <ingress-public-ip>
Use your own apex domain in place of example.com. MCP_PLATFORM_DOMAIN takes
the apex only, so MCP_PLATFORM_DOMAIN=example.com derives the three names
above.
Let's Encrypt HTTP-01 requires public DNS and public port 80. For private-only
on-prem DNS, use an enterprise cert-manager ClusterIssuer or pre-created TLS
secrets in place of --acme-email.
Choose the front door¶
Pick the public or internal traffic path before installing MCP Runtime. Every path uses the same three hostnames:
platform.example.comfor the dashboard, API, and Grafana.registry.example.comfor OCI registry push and pull flows.mcp.example.comfor MCP server routes such as/<server-name>/mcp.
Direct DNS to k3s Ingress¶
The simplest public demo shape:
client -> DNS A record -> mcp-ingress-1 public IP -> k3s ServiceLB -> Traefik
Use this when you can expose ports 80 and 443 directly on the ingress node or
on a small external load balancer. --acme-email works in this shape because
Let's Encrypt HTTP-01 can reach Traefik on port 80.
Cloudflare, WAF, or public reverse proxy¶
For internet-facing demos, put Cloudflare, an enterprise WAF, or another reverse proxy in front of the ingress node:
client -> Cloudflare/WAF/proxy -> origin ingress IP -> k3s ServiceLB -> Traefik
In this shape:
- Point the public DNS records at the proxy, not directly at the node, if the proxy is meant to hide or shield the origin.
- Configure the proxy origin to forward all three hosts to the ingress node or external load balancer.
- Preserve the original
Hostheader andX-Forwarded-Proto. When TLS terminates on this proxy and cluster Traefik is HTTP, Traefik overwritesX-Forwarded-Prototohttpon the inner hop. The UI defaultUI_REQUIRE_HTTPS=autothen answers308to the same HTTPS URL and the dashboard loops. SetUI_REQUIRE_HTTPS=falseandUI_FORCE_SECURE_COOKIE=truein the file passed tosetup --env-file. Setup propagates these values tomcp-uiand preserves them on reruns. Cookies staySecure. This is the production external-terminator case, not only a non-TLS dev install. - Do not cache or rewrite
/api,/v2,/<server-name>/mcp, or/.well-known/acme-challenge/*. - Allow long-lived and streaming HTTP responses for MCP traffic.
- Allow registry blob upload/download behavior, including large request bodies, range requests, and Docker/OCI auth headers.
- Restrict origin firewall access to the proxy source ranges when possible, and keep those ranges updated from the proxy provider.
--acme-email still uses HTTP-01. If the proxy is in front during issuance,
/.well-known/acme-challenge/* must pass through to Traefik without auth,
cache, forced HTTPS loops, or WAF blocks. Start with DNS-only/direct records
until cert-manager issues certificates, then enable the proxy after validation.
For private or always-proxied environments, use an enterprise cert-manager
ClusterIssuer, proxy-managed origin certificates, or pre-created TLS secrets
in place of public HTTP-01.
Test the registry path through the proxy before you finish the install:
curl -i https://registry.example.com/v2/
Unauthenticated 401 or 403 is healthy. A proxy-generated HTML error,
timeout, body-size error, or cached response means Docker/OCI clients may fail
even if the dashboard works.
Internal enterprise proxy or load balancer¶
For private on-prem installs, put an internal reverse proxy, F5/HAProxy/NGINX,
or a private load balancer in front of mcp-ingress-1:
internal client -> internal DNS/proxy/LB -> k3s ServiceLB -> Traefik
Keep the same hostnames, but resolve them in internal DNS. Use
--tls-cluster-issuer <issuer-name> or pre-created TLS secrets so certificates
chain to your enterprise trust store. Public Let's Encrypt ACME works only when
the names and HTTP-01 challenge path are publicly reachable.
Install k3s¶
Install the first node as the single k3s server. Keep the packaged k3s Traefik
in kube-system: this guide reuses it as the ingress controller and installs
MCP Runtime with --ingress none, so the cluster never runs two ingress
stacks.
Run on mcp-cp-1:
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
--node-name mcp-cp-1 \
--write-kubeconfig-mode 0644 \
--node-ip <cp-node-ip> \
--node-external-ip <cp-node-public-ip> \
--tls-san <cp-node-public-ip> \
--tls-san platform.example.com \
--tls-san registry.example.com \
--tls-san mcp.example.com" sh -
To have MCP Runtime own ingress, add
--disable traefik to the server install command. Then run setup without
--ingress none so it installs the repo-managed Traefik into the traefik
namespace, and leave PLATFORM_TRAEFIK_NAMESPACE unset (it defaults to
traefik). Every Traefik and ServiceLB check below then uses the traefik
namespace in place of kube-system.
If nodes have more than one network interface, add --flannel-iface <iface> to
the server and every agent install command so pod networking uses the intended
interface.
Get the node token:
sudo cat /var/lib/rancher/k3s/server/node-token
Treat the node token as a secret; it lets other machines join the cluster.
Join each worker. Run this once per agent node, changing the node name and IPs:
curl -sfL https://get.k3s.io | K3S_URL=https://<cp-node-ip>:6443 \
K3S_TOKEN=<node-token> \
sh -s - agent \
--node-name mcp-ingress-1 \
--node-ip <worker-node-ip> \
--node-external-ip <worker-node-public-ip>
Repeat for mcp-worker-1, mcp-worker-2, and optionally mcp-worker-3.
Configure kubectl¶
From your workstation, copy the kubeconfig from the server node, replace
127.0.0.1 with the reachable control-plane address, and keep the file
private:
install -d -m 700 "$HOME/.kube"
PROD_KUBECONFIG="$HOME/.kube/prod-mcp-runtime-config"
scp root@<cp-node-ip>:/etc/rancher/k3s/k3s.yaml "$PROD_KUBECONFIG"
sed -i.bak 's/127.0.0.1/<cp-node-ip>/g' "$PROD_KUBECONFIG"
chmod 0600 "$PROD_KUBECONFIG"
kubectl --kubeconfig "$PROD_KUBECONFIG" get nodes -o wide
For macOS, the sed -i.bak form works with the default BSD sed.
Treat the kubeconfig as a cluster-admin credential and do not commit it.
Keep production out of the default kubeconfig and do not export it as the
ambient KUBECONFIG; pass the production file explicitly on each command.
Pin ServiceLB to the ingress node¶
k3s ServiceLB schedules load-balancer pods on eligible nodes. For a public demo, keep ports 80 and 443 on one known public ingress node.
Label the ingress node:
kubectl --kubeconfig "$PROD_KUBECONFIG" label node mcp-ingress-1 \
svccontroller.k3s.cattle.io/enablelb=true \
ingress.mcpruntime.org/public=true \
node-role.mcpruntime.org/public-ingress=true
If another node was labeled for ServiceLB during earlier testing, remove the ServiceLB label from it:
kubectl --kubeconfig "$PROD_KUBECONFIG" label node <node-name> svccontroller.k3s.cattle.io/enablelb- --overwrite
Verify the svclb-traefik pods land only on the ingress node:
kubectl --kubeconfig "$PROD_KUBECONFIG" -n kube-system get pods -o wide \
-l svccontroller.k3s.cattle.io/svcname=traefik
If one node also serves non-Kubernetes docs or a website with Docker/nginx, keep that node out of Kubernetes scheduling:
kubectl --kubeconfig "$PROD_KUBECONFIG" cordon <docs-node-name>
A cordoned node stays in the cluster, but no new pods are scheduled on it.
Preflight Checks¶
Before installing MCP Runtime, verify the cluster shape:
kubectl --kubeconfig "$PROD_KUBECONFIG" get nodes -o wide
kubectl --kubeconfig "$PROD_KUBECONFIG" get storageclass
kubectl --kubeconfig "$PROD_KUBECONFIG" -n kube-system get pods
kubectl --kubeconfig "$PROD_KUBECONFIG" get ingressclass
Check DNS from your workstation and from inside the cluster:
dig +short platform.example.com
dig +short registry.example.com
dig +short mcp.example.com
kubectl --kubeconfig "$PROD_KUBECONFIG" run dns-check --rm -i --restart=Never --image=busybox:1.36 -- \
nslookup platform.example.com
All three public names must resolve to the ingress node before using Let's Encrypt HTTP-01. Port 80 must reach Traefik for certificate issuance.
Install MCP Runtime¶
The cluster is now ready for platform installation. Follow Platform Installation for CLI installation, registry and TLS choices, platform setup, and the first server. For the public reference's saved configuration, installation scripts, and subsequent updates, use the Deployment Guide.
When using the K3s-provided Traefik from this example:
- Pass
--ingress noneto setup to reuse the existing ingress controller. - Set
PLATFORM_TRAEFIK_NAMESPACE=kube-systemandPLATFORM_TEAM_TRAEFIK_WATCH=disabledin the deployment environment. - Pass
--kubeconfig "$PROD_KUBECONFIG"explicitly to setup and other commands targeting this cluster.
If you disabled K3s Traefik during provisioning, let setup install the repo-managed ingress controller instead, as described above.
Enterprise-supplied certificate installation and renewal are documented in Platform Installation. These instructions also apply to other Kubernetes distributions.
Pod egress to an internal network¶
Team namespaces start with a default-deny NetworkPolicy. DNS, the registry, same-namespace pods, and the ingress controller are allowed. Other destinations are refused, including HTTPS APIs on the company network.
Flannel already rewrites pod sources to the node address. Opening those destinations is a cluster-admin NetworkPolicy choice, not a per-server address. Set both values when you run setup:
mcp-runtime setup \
--pod-egress-cidrs 10.0.0.0/8 \
--pod-egress-except-cidrs 10.42.0.0/16,10.43.0.0/16
--pod-egress-cidrs is the destination list. Prefixes must be /8 or longer
for IPv4 and /32 or longer for IPv6, and 0.0.0.0/0 is rejected. The allow
is TCP 443 only. --pod-egress-except-cidrs must include the cluster pod
CIDR and service CIDR whenever a destination is private (10.0.0.0/8,
172.16.0.0/12, or 192.168.0.0/16), so pods cannot use this rule to reach
other pods or Service IPs. Use the ranges from your Kubernetes install. The
same values can be supplied as MCP_POD_EGRESS_CIDRS and
MCP_POD_EGRESS_EXCEPT_CIDRS. An empty destination list installs nothing.
Hostnames stay in the MCP server configuration. Do not put a destination address or a company range in server metadata.
Validate¶
Run the platform checks:
./bin/mcp-runtime status
./bin/mcp-runtime cluster diagnostics
kubectl --kubeconfig "$PROD_KUBECONFIG" get pods -A
kubectl --kubeconfig "$PROD_KUBECONFIG" get ingress -A
kubectl --kubeconfig "$PROD_KUBECONFIG" get certificate -A
Check the public routes:
curl -I https://platform.example.com/
curl -i https://registry.example.com/v2/
The platform route should return 200. The registry route should return
401 or 403 without credentials, which means the public registry route is up
and guarded.
Before deploying MCP servers, https://mcp.example.com/<server>/mcp can return
404 because no server route exists yet. Follow
Publish an MCP Server to build, push, deploy, and
verify a real server.
Five-Node Variant¶
For a five-node demo, keep the same control-plane and ingress roles and add one more general worker:
| Node | Role |
|---|---|
mcp-cp-1 |
k3s server |
mcp-ingress-1 |
ServiceLB / Traefik public ingress |
mcp-worker-1 |
general workloads |
mcp-worker-2 |
general workloads |
mcp-worker-3 |
general workloads, observability, or larger MCP servers |
Do not label the extra worker with
svccontroller.k3s.cattle.io/enablelb=true unless you want ports 80 and 443
spread across more than one public node. Keep DNS pointed at the
node or load balancer that actually receives HTTP and HTTPS traffic.
Migration Notes¶
Fresh first-time installs do not need certificate backup. Use fresh ACME, your enterprise issuer, or pre-created TLS secrets.
Reinstalling on the same public domain (app-namespace wipe, setup rerun):
Let's Encrypt limits duplicate certificates to five per domain set per seven days.
Use Reference Deployment - Step 0
or hack/deploy/reference/reset.sh --yes to back up platform-runtime TLS
before delete. Review an explicit recovery snapshot, then run restore.sh --snapshot PATH --yes separately; setup never restores automatically.
Back up cert-manager Certificate, Issuer or ClusterIssuer, and TLS
Secret objects when migrating an existing public install that already
owns valid certificates or issuer state. Keep those backups encrypted and out
of git because TLS secrets contain private keys.
Troubleshooting¶
| Symptom | Check |
|---|---|
exec format error in a setup-built pod |
The image architecture does not match the node. Use a homogeneous cluster and set MCP_IMAGE_PLATFORM=linux/amd64 or linux/arm64. |
| cert-manager reports NXDOMAIN or HTTP-01 failure | platform, registry, and mcp DNS records must point to the ingress IP, and port 80 must reach Traefik. |
| Setup rejects public mode because login is missing | Set GOOGLE_CLIENT_ID / MCP_GOOGLE_CLIENT_ID, or set OIDC_ISSUER, OIDC_AUDIENCE, and OIDC_JWKS_URL. |
| Setup rejects production admin config | Set MCP_ADMIN_USERS for OIDC admins, or both password-admin variables. Do not confuse this with --acme-email, which is only the certificate contact. |
| Dashboard has no password login | Set both MCP_PLATFORM_ADMIN_EMAIL and MCP_PLATFORM_ADMIN_PASSWORD before setup. An incomplete pair is rejected; reruns preserve an existing complete pair when neither is provided. |
Dashboard or docs return 308 to the same URL |
TLS terminates outside the cluster and Traefik is HTTP. Pass UI_REQUIRE_HTTPS=false and UI_FORCE_SECURE_COOKIE=true through setup --env-file. |
| Registry setup fails shrinking storage | --registry-storage is smaller than the bound PVC. Kubernetes cannot shrink it. Rerun with a size at least as large as the claim. |
| Setup image build says buildx is missing | Install the Docker buildx plugin, or set DOCKER_BUILDKIT=0 and rerun setup. |
server push rejects a redirect |
Set the final HTTPS origin with auth login --api-url. Uploads reject changes to method, scheme, or origin. |
server push fails on a small request body |
The proxy in front of /api/ is still at the default body limit. Allow a large body on that location. /v2/ is a different path. |
Tenant server push says the caller has no team |
The admin API key is not a team member. Create a team, add a user, and push with that user's token. --scope org works in both modes; --scope public requires public mode. |
Team MCP server gets ECONNREFUSED to an internal HTTPS API the node can open |
The team default-deny policy is refusing the pod. Set --pod-egress-cidrs and --pod-egress-except-cidrs. See Pod egress to an internal network. |
| Registry image pulls fail | Confirm MCP_REGISTRY_ENDPOINT is the exact host nodes pull, DNS resolves from every node, and the certificate chain is trusted by the node runtime. |
| Setup dies in operator webhook wait with connection refused | The Kubernetes API was down, often right after a k3s restart to load registries.yaml. Rerun setup after the API answers. Confirm registry ingress auth was restored. |
| Traefik 404 for the dashboard | Confirm MCP_PLATFORM_DOMAIN=example.com, kubectl get ingress -A, and DNS for platform.example.com points to the ingress node. |
| ServiceLB lands on the wrong node | Check kubectl get pods -A -o wide | grep svclb and fix svccontroller.k3s.cattle.io/enablelb labels. |