Bare metal Kubernetes: from physical servers to a validated cluster
A Kubernetes installation can finish successfully while the cluster still cannot serve a useful workload. On physical servers, the gaps often sit outside the installer: network paths, storage behavior, hardware access, and recovery ownership.
The useful milestone for datacenter and neocloud operators is a cluster with clear operating boundaries and evidence that traffic and data take the intended paths. We start with those decisions, build a three-server reference deployment, and define the acceptance gates between a working installation and a production handover.
What bare metal Kubernetes changes
Bare metal Kubernetes runs the node operating system and container runtime directly on physical servers, without a hypervisor hosting the Kubernetes nodes. The container runtime starts containers; Kubernetes places and manages their workloads across the cluster.
This gives the operator direct control over server configuration, network interfaces, and local storage. It also puts physical provisioning, repairs, and capacity expansion in the operator's hands. Removing a virtualization layer does not establish a performance or cost advantage for every workload. Hardware utilization, staffing, networking, and recovery requirements still determine the result.
We own the cluster layer through fully managed clusters covering architecture, deployment, and end-to-end operations. Our Kubernetes-based clusterdOS engine is the open-source engine inside each cluster. Aranya OS, our proprietary multicluster operating system, builds on clusterdOS to operate within and across clusters, federating them into one fleet. The clusterdOS stack explains that inside-cluster foundation. The reference deployment here is independent of that stack and does not imply support for this exact configuration.
Define the reference deployment's boundaries
Use three fresh, dedicated physical servers on a private network. This is a single-control-plane validation cluster, with two workers. It establishes a repeatable baseline; it has neither a redundant control plane nor replicated application storage.
The control plane holds the Kubernetes API and etcd, the database containing cluster state. Workers run the sample workload. We use kubeadm to initialize the cluster, kubelet as each node's agent, and kubectl as the administration command-line tool. Keep the default control-plane scheduling restriction so that ordinary workloads stay on the workers.
| Component | Reference assumption |
|---|---|
| Servers | Three x86-64 machines, each with at least 4 CPU cores, 8 GiB RAM, and a 100 GiB SSD |
| Operating system | Ubuntu Server 24.04 LTS, stock kernel, systemd-networkd for host network configuration, and cgroup v2 for resource accounting |
| Runtime | containerd 2.3.6, package 2.3.6-1~ubuntu.24.04~noble |
| Kubernetes | kubeadm, kubelet, and kubectl 1.36.5, package 1.36.5-1.1 |
| Pod networking | Calico 3.33.0, IPv4 overlay networking, and a 1,450-byte pod packet limit |
| Service address translation | kube-proxy with iptables NAT rules; a private node port for workload access |
| Storage | One manually declared local disk volume on worker1; no automatic volume creation |
| Workload | A CPU-only HTTP file server and a client on a different worker |
These server sizes are example allocations for the exercise, not AI workload sizing. containerd's Kubernetes compatibility matrix includes its 2.3 series for Kubernetes 1.36. Calico's system requirements include Kubernetes 1.36 and Ubuntu 24.04.
Reserve these addresses in the node subnet:
| Hostname or name | Address | Purpose |
|---|---|---|
cp1 |
10.20.0.11 |
Control plane |
worker1 |
10.20.0.21 |
File server and local volume |
worker2 |
10.20.0.22 |
Cross-node client |
k8s-api.internal |
10.20.0.11 |
Stable API name, initially pointing to cp1 |
| Pod address range | 10.244.0.0/16 |
Addresses assigned to pods |
| Service address range | 10.96.0.0/12 |
Virtual addresses for Kubernetes Services |
All commands assume those addresses. Substitute a site-approved plan consistently if they overlap with existing networks, including connected VPNs. Node, pod, and Service ranges must remain distinct.
1. Prepare the hosts and network
Install Ubuntu, assign each hostname and static address, and establish sudo access before starting. Each server needs a unique hardware identity, working time synchronization, and a private default route using its node-network interface. Keep the baseboard management controller, the server's remote power and console interface, on a separate management network.
Provide DNS records or add these entries to /etc/hosts on all three hosts:
10.20.0.11 cp1 k8s-api.internal
10.20.0.21 worker1
10.20.0.22 worker2
Use a 1,500-byte maximum transmission unit, or MTU, throughout the node path. The VXLAN overlay reserves 50 bytes for its IPv4 packet wrapper, leaving a 1,450-byte pod MTU. VXLAN carries pod traffic inside packets addressed between nodes, avoiding a requirement for the switches to route pod subnets. It does not encrypt that traffic.
Protect this reference network with upstream access controls. It assumes no UFW, firewalld, or other host firewall manager competing with Calico's rules. The required private paths include:
- TCP 6443 from nodes and the administration network to
cp1. - TCP 10250 from the control plane to nodes for kubelet operations.
- UDP 4789 between all nodes for VXLAN, plus TCP 5473 to nodes hosting Calico Typha, its networking-state distribution component. Operator installations always deploy Typha and scale its replicas with cluster size, so the TCP 5473 rule also applies to this three-node cluster.
- TCP 30080 from the administration network to the workers for the sample endpoint.
- DNS and time-service access, plus outbound HTTPS for package and image downloads, including registry redirects.
The Kubernetes port reference identifies component ports. Keep etcd's TCP 2379–2380 isolated from workers and public clients. This topology needs no cross-host etcd access.
On every host, disable swap for this configuration and enable forwarding:
sudo swapoff -a
sudo sed -i.bak '/^[^#].*[[:space:]]swap[[:space:]]/s/^/#/' /etc/fstab
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<'EOF' | sudo tee /etc/modules-load.d/kubernetes.conf
overlay
br_netfilter
EOF
cat <<'EOF' | sudo tee /etc/sysctl.d/99-kubernetes.conf
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
EOF
sudo sysctl --system
The expected host baseline is an empty swapon --show, synchronized time in timedatectl status, and net.ipv4.ip_forward = 1 from sysctl net.ipv4.ip_forward. This assumes the fresh installation has no additional swap generator.
2. Install the runtime and Kubernetes tools
Run the following on every host. The Docker-maintained repository supplies the containerd.io runtime package, including runc, the low-level container launcher. We do not install Docker Engine. Fresh hosts must have no conflicting containerd, runc, or Docker packages already installed.
sudo apt-get update
sudo apt-get install -y ca-certificates curl gpg
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
cat <<'EOF' | sudo tee /etc/apt/sources.list.d/docker.sources
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
EOF
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes.gpg
cat <<'EOF' | sudo tee /etc/apt/sources.list.d/kubernetes.list
deb [signed-by=/etc/apt/keyrings/kubernetes.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /
EOF
sudo apt-get update
sudo apt-get install -y \
containerd.io=2.3.6-1~ubuntu.24.04~noble \
kubelet=1.36.5-1.1 kubeadm=1.36.5-1.1 kubectl=1.36.5-1.1
sudo apt-mark hold containerd.io kubelet kubeadm kubectl
sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' \
/etc/containerd/config.toml
sudo systemctl enable --now containerd kubelet
sudo systemctl restart containerd
The runtime and kubelet use the systemd cgroup driver to agree on resource management. Its containerd 2.x configuration differs from 1.x; the Kubernetes runtime configuration guidance shows both layouts. The generated default configuration keeps the Container Runtime Interface, or CRI, enabled so kubelet can control containerd.
The packages are deliberately pinned. Holding them prevents an ordinary package upgrade from changing the cluster version; a separate upgrade procedure must keep security patches moving. Kubernetes uses minor-specific package repositories.
A restarting kubelet before bootstrap is expected because its cluster configuration does not yet exist.
3. Bootstrap the control plane and join workers
On cp1, save this as cluster.yaml. kubeadm bootstraps the control plane; kube-proxy installs iptables rules for destination NAT (network address translation), replacing a Service IP and port with a backend pod's IP and port.
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.20.0.11
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.5
controlPlaneEndpoint: k8s-api.internal:6443
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
dnsDomain: cluster.local
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
---
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
mode: iptables
Initialize it, then configure kubectl for your regular administration account:
sudo kubeadm init --config cluster.yaml
mkdir -p "$HOME/.kube"
sudo install -m 0600 -o "$(id -u)" -g "$(id -g)" \
/etc/kubernetes/admin.conf "$HOME/.kube/config"
kubectl get nodes
The API name gives a future redundant endpoint a stable address in cluster configuration. It provides no redundancy while it resolves to cp1. Kubernetes describes this shared endpoint requirement.
Keep admin.conf private. It grants cluster-administrator access. Keep the join token private, too. Run the kubeadm join command printed by initialization with sudo on each worker, adding --cri-socket unix:///run/containerd/containerd.sock. If the token expires, generate a replacement on cp1:
sudo kubeadm token create --print-join-command
The nodes can remain NotReady until pod networking exists.
4. Establish pod networking
On cp1, install the versioned Calico definitions and operator. An operator is a controller that maintains a component's declared configuration. These commands follow the upstream on-premises installation path.
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/v3_projectcalico_org.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/tigera-operator.yaml
kubectl wait --for=create crd/installations.operator.tigera.io --timeout=180s
kubectl wait --for=create crd/apiservers.operator.tigera.io --timeout=180s
kubectl wait --for=condition=Established \
crd/installations.operator.tigera.io \
crd/apiservers.operator.tigera.io --timeout=60s
Save this as network.yaml, then run kubectl apply -f network.yaml:
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
bgp: Disabled
linuxDataplane: Iptables
mtu: 1450
nodeAddressAutodetectionV4:
cidrs:
- 10.20.0.0/24
ipPools:
- cidr: 10.244.0.0/16
blockSize: 26
encapsulation: VXLAN
natOutgoing: Enabled
nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
This explicitly selects the private node subnet and tunnels all cross-node pod traffic. BGP, the routing protocol used to exchange network routes, stays disabled. With VXLAN-only pod networking, the reference needs no BGP peering with switches.
The APIServer resource completes Calico's native-v3 installation path. With these native resource definitions, the operator deploys admission webhooks, which handle validation and defaulting for Calico resources, instead of an aggregation API server. This component is separate from the Kubernetes API endpoint on cp1.
The API endpoint and workload endpoint serve different purposes. Administrators send cluster commands to TCP 6443. Application clients reach TCP 30080 on a worker, where iptables NAT rules installed by kube-proxy translate the destination to the file-server pod's IP and port. A request arriving on worker2 must cross the node network to reach the server on worker1.

Run the first cluster acceptance gate:
kubectl wait --for=condition=Ready nodes --all --timeout=300s
kubectl -n kube-system rollout status deployment/coredns --timeout=180s
kubectl -n calico-system rollout status daemonset/calico-node --timeout=180s
kubectl wait --for=create tigerastatus/apiserver --timeout=180s
kubectl wait --for=condition=Available tigerastatus/apiserver --timeout=300s
kubectl get nodes -o wide
kubectl get pods -A
kubectl get --raw='/readyz?verbose'
Expected: all three nodes are Ready, their internal addresses match the plan, CoreDNS and Calico complete rollout, Calico's apiserver component reports Available=True, and API readiness ends with readyz check passed. CoreDNS provides name resolution inside the cluster. These are installation-health gates; they do not establish workload availability.
5. Add storage with an explicit failure boundary
A PersistentVolume, or PV, represents storage available to Kubernetes. A PersistentVolumeClaim, or PVC, is a workload's request for that storage.
For the reference, create a small local volume on worker1:
sudo install -d -m 0755 /srv/kubernetes/reference
printf '%s\n' 'bare-metal-storage-ok' \
| sudo tee /srv/kubernetes/reference/index.html >/dev/null
This uses the existing filesystem, so no disk formatting is needed. Its advertised 1 GiB capacity is a scheduling declaration, not a filesystem quota. It is suitable for this small acceptance fixture.
Save the following as storage.yaml on cp1, then run kubectl apply -f storage.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: acceptance
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: reference-local
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: reference-local-pv
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: reference-local
local:
path: /srv/kubernetes/reference
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [worker1]
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reference-data
namespace: acceptance
spec:
accessModes: [ReadWriteOnce]
storageClassName: reference-local
resources:
requests:
storage: 1Gi
The PVC can remain Pending until its consumer exists. WaitForFirstConsumer lets Kubernetes consider storage placement together with pod placement. Local volumes require volume node affinity, which ties this workload to worker1.
Deleting a pod leaves this file in place. Losing worker1 makes the volume unavailable; Kubernetes cannot move its disk contents to worker2.
For production, choose storage by required failure behavior. Rebuildable model caches can use node-local disks if cold-start recovery is acceptable. Data that must survive a server failure needs shared or replicated storage with a compatible Container Storage Interface, or CSI, driver connecting it to Kubernetes. Replication still needs a separate backup and restore plan.
6. Serve a workload across the physical network
Save this as workload.yaml on cp1, then run kubectl apply -f workload.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: file-server
namespace: acceptance
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: file-server
template:
metadata:
labels:
app: file-server
spec:
containers:
- name: http
image: busybox:1.37.0
command: [httpd, -f, -p, "8080", -h, /data]
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 32Mi
limits:
cpu: 500m
memory: 64Mi
readinessProbe:
httpGet:
path: /
port: 8080
volumeMounts:
- name: data
mountPath: /data
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: reference-data
---
apiVersion: v1
kind: Service
metadata:
name: file-server
namespace: acceptance
spec:
type: NodePort
externalTrafficPolicy: Cluster
selector:
app: file-server
ports:
- port: 80
targetPort: 8080
nodePort: 30080
---
apiVersion: v1
kind: Pod
metadata:
name: cross-node-client
namespace: acceptance
spec:
nodeSelector:
kubernetes.io/hostname: worker2
containers:
- name: client
image: busybox:1.37.0
command: [sleep, "86400"]
resources:
requests:
cpu: 50m
memory: 16Mi
The local volume places the server on worker1; the client explicitly runs on worker2. This makes a same-node success insufficient to pass acceptance.
kubectl -n acceptance rollout status deployment/file-server --timeout=180s
kubectl -n acceptance wait --for=condition=Ready \
pod/cross-node-client --timeout=180s
kubectl -n acceptance get pods -o wide
kubectl -n acceptance get pvc
kubectl -n acceptance exec cross-node-client -- \
nslookup file-server.acceptance.svc.cluster.local
kubectl -n acceptance exec cross-node-client -- \
wget -T 5 -qO- http://file-server.acceptance.svc.cluster.local
curl --fail --max-time 5 http://10.20.0.22:30080/
Expected: the PVC is Bound, the pods occupy their intended workers, name resolution returns the Service address, and both HTTP requests return bare-metal-storage-ok. Run the final curl from the administration network to exercise its access path.
NodePort exposes a port on the node addresses. It is sufficient for private acceptance, and Kubernetes documents its NodePort behavior. Public service delivery additionally needs an owned address-allocation or load-balancing mechanism, an HTTP routing layer where appropriate, TLS certificates, and DNS. An API load balancer and an application load balancer solve separate problems.
Finally, replace the server pod and repeat the cross-node request:
kubectl -n acceptance rollout restart deployment/file-server
kubectl -n acceptance rollout status deployment/file-server --timeout=180s
kubectl -n acceptance exec cross-node-client -- \
wget -T 5 -qO- http://file-server.acceptance.svc.cluster.local
The expected response remains unchanged. This establishes persistence across pod replacement on the same worker. It makes no claim about server-failure recovery, GPU readiness, or inference performance.
Diagnose failures at the layer that failed
| Symptom | Likely boundary | Useful evidence |
|---|---|---|
| kubeadm initialization or worker join fails | Runtime, API reachability, or token discovery | journalctl -u containerd -u kubelet; kubeadm error output |
Nodes remain NotReady after networking installation |
Calico deployment or node-network selection | kubectl describe node worker1; Calico pod logs |
| Service name does not resolve | CoreDNS or pod-to-DNS path | Client nslookup; CoreDNS logs and Service endpoints |
| Name resolves, but cross-node HTTP fails | VXLAN path, policy, or Service NAT | Pod addresses, UDP 4789 access, and kube-proxy logs |
| Small requests work, larger transfers stall | MTU mismatch along the path | Host and pod interface MTUs; packet captures |
PVC or file-server pod stays Pending |
Volume binding or placement | kubectl -n acceptance describe pvc reference-data; pod events |
| Pod replacement loses the file | Wrong mount or ephemeral storage | PV path, pod volume mounts, and the file on worker1 |
A successful kubectl port-forward bypasses the normal client-to-Service route. It cannot replace the cross-node and NodePort acceptance gates.
Complete the production handover
Before customer workloads enter the cluster, assign an owner and acceptance evidence to each operational boundary:
| Boundary | Production handover requirement |
|---|---|
| Control-plane continuity | A redundant API endpoint and an appropriate etcd topology; evidence of surviving the intended node or rack failure |
| State recovery | Protected etcd snapshots, separate workload-data backups, and a completed restore exercise with recovery time and data-loss targets |
| Hardware lifecycle | Server inventory, remote-console access, firmware baseline, replacement procedure, and available spare capacity |
| Network and storage | Approved address ranges, MTU, service exposure, volume failure behavior, and recovery procedures |
| Security | Individual identities, role-based permissions, workload admission controls, network isolation, secret handling, and audit-log retention |
| Operations | Alerts with named responders, patch and certificate-renewal procedures, capacity limits, and maintenance windows |
| Workload performance | Acceptance under the actual concurrency, payload size, storage load, and failure conditions the service must support |
For a common stacked-etcd design, Kubernetes specifies three control-plane nodes. Spreading them across the relevant failure domains matters as much as their count. Three servers in one rack do not establish rack-level continuity.
Retain the manifests, host package versions, network plan, image digests, and acceptance outputs with the handover. The sample uses a versioned image tag for readability; production release records should retain immutable image digests. GPU drivers, device exposure, and allocation policy are additional deployment gates, outside this CPU-only baseline. Our inference reliability guidance explains why a healthy orchestrator alone cannot establish a healthy inference service.
When the goal is usable compute without building the cluster-operations function in-house, we turn already-racked and ready GPU, CPU, and storage hardware into custom, workload-tuned production clusters in 48 hours. Hardware procurement and physical installation sit outside that timeline. Talk to an engineer about your hardware, workload, and operating requirements.