k3s On-Prem Cluster¶
This guide creates a small public or on-prem k3s cluster that can run MCP Runtime with real DNS, TLS, ingress, registry pulls, and multi-node scheduling. It is the production-style version of the lab path in Deployment Targets: still small enough for a demo or pilot, but close enough to a real customer environment to test the platform honestly.
The reference layout is four nodes because that is the smallest shape that separates the control plane, public ingress, and general workloads. A fifth node is an easy extension and is covered below.
This is a demo or pilot topology, not a high-availability control plane. For a production control plane, use the k3s HA topology with three server nodes and plan datastore backups separately.
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 | Sentinel, 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 instead of just
adding one more server node; that is a different operational shape.
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 until MCP Runtime publishes 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 serious 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 instead of --acme-email.
Choose the Front Door¶
Pick the public or internal traffic path before installing MCP Runtime. The platform expects the same three hostnames either way:
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¶
This is 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, it is usually better to 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. - 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. A practical rollout is to start with
DNS-only/direct records until cert-manager issues certificates, then enable the
proxy after validation. For private or always-proxied environments, prefer an
enterprise cert-manager ClusterIssuer, proxy-managed origin certificates, or
pre-created TLS secrets instead of public HTTP-01.
Test the registry path through the proxy before calling the install done:
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 is not the
right fit unless the names and HTTP-01 challenge path are publicly reachable.
Install k3s¶
Install the first node as the single k3s server. Disable the packaged k3s Traefik so MCP Runtime can install and own the repo-managed Traefik manifests.
Run on mcp-cp-1:
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
--node-name mcp-cp-1 \
--disable traefik \
--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 -
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:
scp root@<cp-node-ip>:/etc/rancher/k3s/k3s.yaml ./mcp-k3s.yaml
sed -i.bak 's/127.0.0.1/<cp-node-ip>/g' ./mcp-k3s.yaml
chmod 0600 ./mcp-k3s.yaml
export KUBECONFIG=$PWD/mcp-k3s.yaml
kubectl 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.
Pin ServiceLB to the Ingress Node¶
k3s ServiceLB will schedule 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 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 label node <node-name> svccontroller.k3s.cattle.io/enablelb- --overwrite
After setup installs Traefik, verify the svclb-traefik pods land only on the
ingress node:
kubectl -n traefik get pods -o wide
If one node also serves non-Kubernetes docs or a website with Docker/nginx, keep that node out of Kubernetes scheduling:
kubectl cordon <docs-node-name>
Cordoning a node does not remove it from the cluster; it just prevents new pods from being scheduled there.
Preflight Checks¶
Before installing MCP Runtime, verify the cluster shape:
kubectl get nodes -o wide
kubectl get storageclass
kubectl -n kube-system get pods
kubectl 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 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¶
Build the CLI from the repo root:
make deps
make build
For a public demo using the bundled HTTPS registry, set the public platform domain and make platform image pulls use the public registry host. The registry host must resolve from every node.
export MCP_PLATFORM_DOMAIN=example.com
export MCP_REGISTRY_ENDPOINT=registry.example.com
export MCP_PLATFORM_ADMIN_EMAIL=admin@example.com
export GOOGLE_CLIENT_ID=<google-client-id>.apps.googleusercontent.com
export MCP_IMAGE_PLATFORM=linux/amd64
MCP_IMAGE_PLATFORM is optional when all Kubernetes nodes report the same
architecture, but setting it explicitly is useful when building from an ARM
laptop for amd64 servers. Use linux/arm64 only for a homogeneous ARM cluster.
Run setup:
./bin/mcp-runtime bootstrap --provider k3s
# k3s ships Traefik in kube-system — use --ingress none to avoid a second stack.
# Pass --kubeconfig explicitly when multiple kubeconfigs exist on the workstation.
MCP_SETUP_WAIT_TIMEOUT=1200 ./bin/mcp-runtime setup \
--kubeconfig "$KUBECONFIG" \
--platform-mode public \
--registry-mode bundled-https \
--storage-mode dynamic \
--with-tls \
--acme-email ops@example.com \
--ingress none \
--strict-prod \
--parallel-builds
Set PLATFORM_TRAEFIK_NAMESPACE=kube-system and
PLATFORM_TEAM_TRAEFIK_WATCH=disabled in the deployment env (see
config/deployments/mcpruntime-org.env.example) so team create does not patch
repo-managed Traefik when k3s Traefik is already active.
For reruns, clean+restore, rollout-only updates, and the full environment variable reference, use k3s Deployment Runbook.
Use --platform-mode tenant for private team-isolated installs, or
--platform-mode org for a shared internal catalog. public exposes the
catalog anonymously and requires browser login configuration for publishing.
If your organization already owns cert-manager and a ClusterIssuer, replace
--acme-email with:
MCP_SETUP_WAIT_TIMEOUT=1200 ./bin/mcp-runtime setup \
--platform-mode public \
--registry-mode bundled-https \
--storage-mode dynamic \
--with-tls \
--tls-cluster-issuer <issuer-name> \
--skip-cert-manager-install \
--strict-prod \
--parallel-builds
If your organization already owns a registry, prefer the external registry path:
MCP_SETUP_WAIT_TIMEOUT=1200 ./bin/mcp-runtime setup \
--platform-mode public \
--registry-mode external \
--external-registry-url registry.example.com/mcp-runtime \
--with-tls \
--acme-email ops@example.com \
--strict-prod \
--parallel-builds
Pass --external-registry-username and PROVISIONED_REGISTRY_PASSWORD when the
registry needs credentials.
Validate¶
Run the platform checks:
./bin/mcp-runtime status
./bin/mcp-runtime cluster doctor
kubectl get pods -A
kubectl get ingress -A
kubectl 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; that 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 intentionally 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 k3s Deployment Runbook - Step 0
or hack/deploy/mcpruntime-org/clean.sh --yes to back up platform-runtime TLS
before delete, then hack/deploy/mcpruntime-org/setup.sh to restore after setup.
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_PLATFORM_ADMIN_EMAIL or MCP_ADMIN_USERS. Do not confuse this with --acme-email, which is only the certificate contact. |
| 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. |
| 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. |