Hoo hoo! Welcome, brave platform engineer. I’ve been expecting you.
Your CTO has given you a critical mission: “Provide Kubernetes to the company. Don’t use the cloud. A certain administration across the pond keeps me up at night. Do it on-prem. Here’s the budget, go buy some servers.”
First, you talk to the teams. Your platform team wants shared staging and prod clusters (isolated from each other for security), plus a central one for observability and GitOps. Data science needs its own for their higher-privilege operators. That’s 4 clusters minimum.
You waddle over to the infra team. Datacenter hardware has settled on big virtualization hosts: smaller nodes waste power, cooling, and network ports. Best price/performance starts at 512 GiB of RAM upwards. They order 10 bare metal servers, 64-core beasts with 512 GiB of RAM each, and rack them.
In the cloud, you’d just spin up EKS or GKE clusters as needed. But on-prem, buying dedicated servers per cluster doesn’t scale: each box is way too big for a single cluster’s workers or control plane, and every new cluster means another hardware order (or expensive spares lying around). Cramming everything into one big shared cluster kills the isolation your teams need.
So, how do you split up the big beefy hosts into Kubernetes clusters? Reach for VMware, Proxmox, OpenStack so you can create VMs? Or is there a cloud-native way to get GKE-style Kubernetes on-prem?
Let’s enter the quest. By the end, those 10 servers will quietly power a multi-cluster Kubernetes platform that rivals the cloud, on your own metal. Your CTO sleeps easy. Not a single packet crosses the Atlantic.
Before You Begin
You’ll need:
- A laptop with at least 26 GiB free RAM (i.e. a 32 GiB laptop) and nested virtualization support:
- macOS: Apple Silicon M3 or later with macOS 15+ (M1/M2 and Intel Macs don’t support nested virtualization)
- Linux/Windows: most modern x86_64 CPUs (Intel VT-x or AMD-V)
- No suitable laptop? Use a cloud VM with nested virtualization instead. Launch a recent Ubuntu on Azure
Standard_D8ds_v5, AWSr8i.xlarge, or GCPn2-standard-8and follow the Linux (QEMU/libvirt) guide. On AWS and GCP, make sure to explicitly enable nested virtualization on the instance.
- A virtualization tool for your OS. We have step-by-step guides for each:
- macOS: tart (recommended)
- Linux: QEMU/libvirt
- Windows: Hyper-V
- Your owl USB stick containing the Nest installer ISO
- A stable internet connection: the Nest installation downloads around 10 GB of data. Don’t do it on a train.
- About 35 GiB free disk space on your laptop
- kubectl and helm installed on your machine
- About 40 minutes of your time
Copy the Nest installer .iso from the USB stick to your laptop. Pick the right architecture: nest-installer-v*-arm64.iso for Apple Silicon Macs, nest-installer-v*-amd64.iso for Intel/AMD Linux and Windows laptops.
Chapter 1: Hatch Your Nest
Estimated time: 15 minutes
Every great platform starts with a Nest: the management appliance that hosts your Kubernetes control planes and Foundry, the web UI. Think of it as the command center for your 10 servers. Without it, they’re just expensive space heaters.
The Nest takes the first of your 10 servers; the other 9 will join the platform cluster in Chapter 2.
Create and boot the Nest VM:
- Create a Nest VM by following your hypervisor guide (macOS, Linux, Windows):
- CPU: 4+ vCPUs
- RAM: 10 GiB
- Disk: 200 GiB (thin-allocated, won’t use more than 20 GiB)
- Boot: Attach the Nest installer
.iso
- Start the VM: the Nest TUI installer will appear.
- Follow the installer prompts:
On a local VM the installer is designed to be a next → next → finish click-through: confirm the defaults and suggested IPs/hostnames. If you get stuck on any screen, check the Install Nest reference for a detailed walkthrough.
Except Hyper-V users: in the Networking tab, select Static IPs instead of DHCP. Hyper-V’s Default Switch doesn’t persist DHCP leases across reboots. The suggested static IP settings work fine.
- Wait for the installation to complete (~10 GB download + cluster setup). This can take 10–15 minutes.
- Once finished, the installer shows you the Foundry URL (typically
https://app.192.168.x.y.d.meltcloud.io, wherex.yis your VM’s IP). Note it down!
Access Foundry:
- Open your browser and navigate to the Foundry URL.
Splendid! You’re looking at Foundry, your meltcloud command center. From here, you manage every cluster and every machine. Think Azure Portal or GCP Cloud Console, only the metal is yours.
Notice the sidebar: Clusters, Machines, Enrollment Images, Elastic Fleets. We’ll touch all of these.
First, create an API key: in Foundry, click API Keys in the sidebar, click Create API Key, give it a name and expiry date, then click Create. Copy the key (starts with eyJ).
Create a main.tf and configure the provider:
terraform {
required_providers {
meltcloud = {
source = "meltcloud/meltcloud"
}
time = {
source = "hashicorp/time"
version = "~> 0.11"
}
}
}
provider "meltcloud" {
# Your Foundry URL from Chapter 1
endpoint = "https://app.192.168.x.y.d.meltcloud.io"
# From the Foundry URL: app.192.../ui/orgs/<uuid>/...
organization = "your-organization-uuid"
# Either export MELTCLOUD_API_KEY env var, or:
# api_key = "eyJ"
# the local Nest installer uses self-signed certificates
skip_tls_verify = true
}
terraform init
Chapter 2: The Platform Cluster
Estimated time: 15 minutes
Now for the interesting part: your 9 worker servers don’t get divided up front and assigned to disparate clusters. Instead, they all join one Kubernetes cluster: the platform cluster. This is where you’ll run your platform tools (Argo CD, Prometheus, Grafana, your secrets operator).
Later, we’ll build the tenant clusters on top.
Create the platform cluster:
- Navigate to Clusters > Create Cluster
- Name it
platform - Select the latest Kubernetes version
- Under Networking, set the following (to avoid conflicts with tenant clusters that will run on top later):
- Pod CIDR:
10.0.0.0/16 - Service CIDR:
10.1.0.0/16 - DNS Service IP:
10.1.0.10
- Pod CIDR:
- Leave the rest (e.g. Addons) as default
- Click Roll out
Add to your main.tf:
resource "meltcloud_cluster" "platform" {
name = "platform"
version = "1.35"
pod_cidr = "10.0.0.0/16"
service_cidr = "10.1.0.0/16"
dns_service_ip = "10.1.0.10"
}
terraform apply
Notice what just happened: the Nest spun up a full Kubernetes control plane (kube-apiserver, etcd, scheduler, controller-manager) as containers inside the Nest. We call this a Hosted Control Plane (HCP): every cluster you create gets its own HCP, all running in the Nest. The control plane doesn’t eat a single CPU on your worker bare metal. The other 9 servers stay fully available for workloads. In production, you’d scale the Nest out to 3 instances across separate servers for HA.
Create a Machine Pool:
- In the
platformcluster, open the Machine Pools tab - Click Create Machine Pool, name it
bare-metal - Keep the default version, click Create
resource "meltcloud_machine_pool" "bare_metal" {
cluster_id = meltcloud_cluster.platform.id
name = "bare-metal"
version = "1.35"
}
terraform apply
A quick word on how machines join the platform: enrollment. You generate an Enrollment Image in Foundry (a bootable ISO), boot a bare metal server (or a VM) from it, and the image installs our immutable Linux distro and joins the machine to the Nest. You don’t need to install an OS, SSH bootstrap, or run Ansible.
From that moment, the machine is fully managed, just like in the cloud: patches arrive automatically, every config or version change rolls out as a controlled reboot, and there’s no drift. For the deep dive on bare metal workers, see From Metal to Kubernetes.
Create an Enrollment Image:
- Navigate to Enrollment Images > Create Enrollment Image
- Name it
quest-enrollment - Set the Install disk to match your VM type:
| OS | Virtualization Tool | Disk Path |
|---|---|---|
| macOS | tart (Apple Virtualization) | /dev/vda |
| Linux | libvirt (QEMU) | /dev/vda |
| Windows | Hyper-V | /dev/sda |
- Click Create and wait 1–2 minutes for the build
- In the
quest-enrollmentEnrollment Image, download the.isofile
resource "time_offset" "enrollment_expiry" {
offset_days = 30
}
resource "meltcloud_enrollment_image" "quest" {
name = "quest-enrollment"
expires_at = time_offset.enrollment_expiry.rfc3339
# Pick one:
# 1. /dev/vda for macOS (tart) and Linux (libvirt)
install_disk_device = "/dev/vda"
# or
# 2. /dev/sda for Windows (Hyper-V)
install_disk_device = "/dev/sda"
}
terraform apply
Once applied, download the enrollment .iso from the Enrollment Image in Foundry.
Enroll your bare metal (well, your VMs):
- Create 2 new VMs by following your hypervisor guide (macOS, Linux, Windows, choose tab Machine on Linux, tab Machine (hosting Elastic Node Pools or KubeVirt) on macOS/Windows). In production this would be your other 9 servers, but for the quest, two is plenty:
- CPU: 2+ vCPUs / RAM: 8 GiB / Disk: 30 GiB (thin-allocated, won’t use more than 10 GiB)
- Boot from the enrollment
.isoyou downloaded above (copy it per VM if your hypervisor locks the file) - Enable nested virtualization (in Chapter 4 we’ll run Elastic Node Pools (Virtual Workers) inside these VMs)
- Start the VMs. They enroll as described above; you can watch the TUI for progress.
- In Foundry, go to Machines: your VMs should appear with status
Ready (Unassigned)
Assign machines to the pool:
worker1, worker2), assign to the bare-metal pool in the platform cluster, click Preview, then Roll out.
The machines already exist after enrollment. To bring them into Terraform, find each machine’s ID (visible in the URL when you click on a machine in Foundry, e.g. /machines/42) and UUID (shown in the machine’s Info tab). Then use import blocks:
import {
to = meltcloud_machine.worker1
id = "machines/<worker1-id>" # most likely machines/1
}
resource "meltcloud_machine" "worker1" {
uuid = "<worker1-uuid>"
name = "worker1"
machine_pool_id = meltcloud_machine_pool.bare_metal.id
}
import {
to = meltcloud_machine.worker2
id = "machines/<worker2-id>" # most likely machines/2
}
resource "meltcloud_machine" "worker2" {
uuid = "<worker2-uuid>"
name = "worker2"
machine_pool_id = meltcloud_machine_pool.bare_metal.id
}
terraform apply
Install Cilium on the platform cluster:
- Download the Kubeconfig (Admin) for the
platformcluster (from the cluster’s Info tab) - Connect and install Cilium as the CNI:
# Linux / macOS / WSL:
export KUBECONFIG=/path/to/platform-admin.kubeconfig
# Windows (PowerShell):
$Env:KUBECONFIG="C:\Path\To\platform-admin.kubeconfig"
# All OS
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# worker1 NotReady <none> 3m v1.35.x
# worker2 NotReady <none> 3m v1.35.x
Both nodes show NotReady - expected, no CNI yet. Install Cilium:
helm install cilium oci://quay.io/cilium/charts/cilium --version 1.19.4 -n kube-system
A minute later:
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# worker1 Ready <none> 5m v1.35.x
# worker2 Ready <none> 5m v1.35.x
Hoo! Your platform cluster is alive. In a real deployment, this is where you’d install Argo CD or Flux to manage your platform GitOps, Prometheus & Grafana for cluster-wide observability, an external secrets operator, certificate management, whatever your platform team uses to operate the rest of the org’s infrastructure.
This single cluster is your platform. Every team’s workloads will eventually run on the same metal, but isolated in their own clusters. Let’s see how.
Chapter 3: The Tenancy Map
Estimated time: 3 minutes
Remember the teams you talked to earlier? Time to turn those conversations into a tenancy map.
Your platform team wants shared staging and prod clusters (isolated from each other for security), plus a central one for observability and GitOps. Data science needs its own for their higher-privilege operators. Here’s the map you end up with:
| Cluster | Purpose | Why a dedicated cluster |
|---|---|---|
platform |
Argo CD, Prometheus, Grafana, secrets, certificates | The home of the platform team’s own tools. Already built. |
shared-staging |
All teams’ staging workloads | Teams share one staging cluster: fewer clusters to operate, faster CI deploys, realistic prod-like behavior |
shared-prod |
All teams’ production workloads | Same model for prod. Workloads are isolated by namespace + network policy; the cluster is shared. |
data-science |
Kubeflow, Spark, GPU pipelines | The data team’s operators want cluster-admin, they may need a different CSI, and GPU nodes could land here later. Worth a dedicated cluster. |
The naive option: buy more hardware, one set of servers per cluster. Wasteful, and the dedicated boxes sit half-idle most of the time.
Instead of dedicating separate hardware to each cluster, we build them virtualized on top of the platform cluster’s metal. The meltcloud option is Elastic Fleets: turn your 9 platform workers into a set that all your other clusters can carve workers from, virtually. One set of metal underneath, properly isolated clusters on top. Let’s build it.
Chapter 4: Elastic Fleet to Slice the Metal
Estimated time: 8 minutes
Time to build the fleet. An Elastic Fleet turns your platform cluster’s 9 workers into a set of virtualization capacity: the same physical machines can now host virtual Kubernetes workers for any other cluster, on demand.
Under the hood: KubeVirt (the CNCF-graduated project for running VMs on Kubernetes), where each virtual worker runs our immutable Linux distro, just like the bare metal Machines.
Create the Elastic Fleet:
- Navigate to Elastic Fleets > Create Elastic Fleet
- Name it
platform-fleet - Select the
platformcluster as the providing cluster: this is where Foundry will install KubeVirt - Click Roll out
resource "meltcloud_elastic_fleet" "platform_fleet" {
cluster_id = meltcloud_cluster.platform.id
name = "platform-fleet"
}
terraform apply
This takes 1-2 minutes while the virtualization stack starts up. The Nest is rolling out KubeVirt and the virtualization stack across all workers of the platform cluster. Your bare metal nodes can now host VMs. Your platform tools (Argo, Prom, …) keep running side-by-side: containers and VMs share the same metal.
Define Quotas:
- Open the new
platform-fleet, go to Elastic Quotas > Create Elastic Quota - Name it
tenant-clusters(this is the slice of capacity you’re earmarking for tenant clusters) - Set the total budget: 6 vCPUs, 16384 MiB Memory, 100 GiB Disk (this matches the full RAM of your two VMs and overcommits cores and disk, which is fine for a quest workload)
- Click Create
resource "meltcloud_elastic_quota" "tenant_clusters" {
elastic_fleet_id = meltcloud_elastic_fleet.platform_fleet.id
consuming_organization_uuid = "your-organization-uuid"
name = "tenant-clusters"
vcpus = 6
memory_mib = 16384
disk_gib = 100
}
terraform apply
Notice this is a quota, not workers. You’ve reserved a chunk of fleet resources for clusters created later. In a true multi-tenant setup, you’d define quotas for other organizations (say, a dev or a data org) and their engineers would self-serve their own clusters from that quota.
For this quest, we’re keeping it simple: the platform team is the only org, and it defines a quota for itself.
Chapter 5: Cluster Magic Starts
Estimated time: 10 minutes
Now the magic. Each cluster on your tenancy map gets a real Kubernetes cluster (its own control plane, its own workers, its own kubeconfig, its own kubectl context, full isolation), but the workers are created as KubeVirt VMs on the Elastic Fleet you just built. All the benefits of virtualization, without the enterprise vendor lock-in (or the bill). The underlying hardware is used efficiently and can be extended with new workers as needed.
Let’s start with shared-staging. The other two are the same playbook.
Create shared-staging:
- Navigate to Clusters > Create Cluster
- Name it
shared-staging - Pick a Kubernetes version, click Roll out
resource "meltcloud_cluster" "shared_staging" {
name = "shared-staging"
version = "1.35"
}
terraform apply
This is identical to creating the platform cluster in Chapter 2: an HCP control plane spins up in the Nest. No worker assignment yet: that’s the next step.
Create an Elastic Node Pool (the cluster’s workers):
- In
shared-staging, open the Elastic Node Pools tab - Click Create Elastic Node Pool, name it
workers - Reference the
tenant-clustersquota fromplatform-fleet - Define the worker shape: 2 nodes, 1 vCPU, 1536 MiB RAM, 10 GiB disk
- Click Roll out
resource "meltcloud_elastic_node_pool" "staging_workers" {
cluster_id = meltcloud_cluster.shared_staging.id
elastic_quota_id = meltcloud_elastic_quota.tenant_clusters.id
name = "workers"
version = "1.35"
node_count = 2
node_config {
vcpus = 1
memory_mib = 1536
disk_gib = 10
}
}
terraform apply
Within minutes, virtual workers are provisioned on top of your bare metal pool. Download the admin kubeconfig for shared-staging and connect:
# Linux / macOS / WSL:
export KUBECONFIG=/path/to/shared-staging-admin.kubeconfig
# Windows (PowerShell):
$Env:KUBECONFIG="C:\Path\To\shared-staging-admin.kubeconfig"
# All OS
kubectl get nodes
# A brand-new virtual node, sized exactly to shared-staging's needs.
Optional: if you want to install a CNI (and potentially later some workloads) on the new cluster, you can install Cilium here:
# different VXLAN port to avoid conflict with the outer cluster's Cilium
# lower MTU to account for the outer encapsulation overhead
# Linux / macOS / WSL:
helm install cilium oci://quay.io/cilium/charts/cilium \
--version 1.19.4 \
-n kube-system \
--set tunnelPort=8473 \
--set MTU=1400
# Windows (PowerShell):
helm install cilium oci://quay.io/cilium/charts/cilium `
--version 1.19.4 `
-n kube-system `
--set tunnelPort=8473 `
--set MTU=1400
Now repeat for the other two planned clusters:
shared-prod: same flow asshared-staging. 2 nodes, 1 vCPU / 1536 MiB / 10 GiB disk (we’d give it more in a real deployment, but our 2 VMs don’t have more to spare).data-science: same flow. 1 node, 1 vCPU / 1536 MiB / 10 GiB disk. The data team will install their own Kubeflow/Spark operators once you hand them the kubeconfig.
resource "meltcloud_cluster" "shared_prod" {
name = "shared-prod"
version = "1.35"
}
resource "meltcloud_elastic_node_pool" "prod_workers" {
cluster_id = meltcloud_cluster.shared_prod.id
elastic_quota_id = meltcloud_elastic_quota.tenant_clusters.id
name = "workers"
version = "1.35"
node_count = 2
node_config {
vcpus = 1
memory_mib = 1536
disk_gib = 10
}
}
resource "meltcloud_cluster" "data_science" {
name = "data-science"
version = "1.35"
}
resource "meltcloud_elastic_node_pool" "data_science_workers" {
cluster_id = meltcloud_cluster.data_science.id
elastic_quota_id = meltcloud_elastic_quota.tenant_clusters.id
name = "workers"
version = "1.35"
node_count = 1
node_config {
vcpus = 1
memory_mib = 1536
disk_gib = 10
}
}
terraform apply
That’s it. Three new clusters in about 10 minutes total. Each fully isolated, each sized for its job, all sharing the same 9 bare metal servers underneath.
platform, shared-staging, shared-prod, data-science), each with its own HCP hosted in your Nest, one bare-metal worker pool underneath, three elastic node pools carved out of it.
Chapter 6: An Unexpected Request
Estimated time: 2 minutes
Just as you’re about to celebrate: your Slack pings. It’s the CTO:
“Loved the demo. New directive: every engineer in the company should learn Kubernetes hands-on. I want a sandbox cluster where teams can poke around, break things, try new operators, no risk to production. Can you have it ready by Friday?”
You smile. You’ve got a truly elastic multi-cluster platform now, so this is just one more cluster on the same metal.
Create the education cluster:
- Clusters > Create Cluster > name it
education, latest Kubernetes version - In
education, Create Elastic Node Pool: referencetenant-clusters, worker shape 1 node, 1 vCPU / 1536 MiB / 10 GiB disk (a tiny sandbox) - In real life, you’d hand out the kubeconfig with restrictive RBAC so engineers can play in their own namespaces but can’t escape
resource "meltcloud_cluster" "education" {
name = "education"
version = "1.35"
}
resource "meltcloud_elastic_node_pool" "education_workers" {
cluster_id = meltcloud_cluster.education.id
elastic_quota_id = meltcloud_elastic_quota.tenant_clusters.id
name = "workers"
version = "1.35"
node_count = 1
node_config {
vcpus = 1
memory_mib = 1536
disk_gib = 10
}
}
terraform apply
Hand out the kubeconfig with restrictive RBAC so engineers can play in their own namespaces but can’t escape.
That’s it. 30 seconds of clicks - or one terraform apply. Five clusters now share the same 9 worker servers.
terraform apply away.
Chapter 7: The Aftermath
Estimated time: 3 minutes
The good platform engineer you are knows the truth: when the CTO presentation ends, the work is done for everyone except you. Welcome to Day 2.
Patching, scaling, backups, capacity planning: you know, the boring work. All of it, automated by the platform. That’s what we meant back at the start when we said GKE-style Kubernetes on-prem that rivals the cloud.
Stays current, automatically:
- OS patches every month. Your immutable Linux distro gets a fresh image; machines reboot in a controlled rollout during the maintenance window you configure.
- Kubernetes patches every month, plus new minors as they ship. Both HCPs and workers stay on a supported version with a single click in Foundry or automatically in a chosen maintenance window.
Resilient by default:
- etcd backups for every HCP. Snapshots are scheduled and stored automatically; restore is a documented one-click operation.
- Control plane autoscaling. Each HCP scales with its cluster’s load without manual tuning.
Try it now:
- Enroll another machine. Boot a fresh VM (or a real bare metal box) from the same
quest-enrollmentISO and watch it join the platform Machine Pool within a minute. Your Elastic Fleet capacity grows automatically. - Scale an Elastic Node Pool. In Foundry, bump the worker count on any pool, or do it with
terraform applyif you prefer GitOps. The pool reconciles, a new KubeVirt worker boots in seconds, the cluster grows. - Set a maintenance window. Pick when the next OS or Kubernetes patch wave hits your fleet. The platform handles cordoning, draining, and rolling reboots.
Victory!
Hoo hoo hoo! Quest complete, platform engineer.
You took 10 bare metal servers and turned them into a multi-tenant Kubernetes platform that would make any cloud provider jealous. Your devs have shared staging and prod, your data scientists have their dedicated cluster, your CTO has an education sandbox, and your platform team has the home cluster where Argo and observability live: everything managed by the Nest, including the OS on those servers.
What you’ve accomplished:
- Stood up the Nest management appliance from a USB stick
- Built a platform cluster by enrolling bare metal servers with zero OS install
- Hosted 5 HCP control planes in the Nest: no metal wasted on control planes
- Created an Elastic Fleet that turned your metal into a virtualization pool
- Carved Elastic Node Pools for
shared-staging,shared-prod,data-science, and an ad-hoceducationcluster - Delivered self-service Kubernetes clusters to your entire company
Where to go from here:
- Platform Overview: the full architecture
- Elastic Node Pools concept: deeper on the fleet/quota/pool model
- Install on bare metal: the same workflow on real hardware
- Terraform Provider: manage everything as code
- Get in touch: we’d love to hear about your infrastructure!