Most teams hit Kubernetes after outgrowing a single VPS running Docker Compose. You've got three or four services, traffic is climbing, and you need better resource management, zero-downtime deployments, and the ability to scale horizontally without SSH-ing into boxes at 2 AM.
A production-ready cluster isn't just kubeadm init and hoping for the best. You need properly configured nodes, network policies that actually isolate workloads, and a deployment pipeline that won't leave you debugging YAML at midnight. I'll walk through the five checkpoints that matter: initializing the control plane, joining worker nodes, setting up namespace boundaries, deploying your first real workload, and verifying the cluster can survive a node failure.
Step 1: Initialize the control plane with kubeadm
The control plane runs the API server, scheduler, and controller manager. Pick a machine with at least 2 CPU cores and 2 GB RAM—underpowered control planes cause cryptic timeouts later.
Before running kubeadm init, disable swap on all nodes. Kubernetes refuses to start the kubelet if swap is enabled:
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
Install a container runtime. containerd is the standard choice now that Docker shim support was removed:
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo systemctl restart containerd
Then install kubeadm, kubelet, and kubectl. Pin the versions to avoid surprises during upgrades:
sudo apt-get install -y apt-transport-https ca-certificates curl
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.28/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.28/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
Now initialize the control plane. Specify a pod network CIDR that doesn't overlap with your existing VPC or office network:
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --apiserver-advertise-address=<CONTROL_PLANE_IP>
Save the kubeadm join command from the output. You'll need it for worker nodes. Copy the kubeconfig so kubectl works without sudo:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Verify the control plane components are running:
kubectl get pods -n kube-system
You'll see kube-apiserver, kube-controller-manager, kube-scheduler, and etcd pods. If any are stuck in Pending or CrashLoopBackOff, check /var/log/pods/ for errors.
Step 2: Deploy a CNI plugin for pod networking
Pods can't communicate until you install a Container Network Interface plugin. The control plane node will stay NotReady without it.
Flannel is straightforward for most setups. Apply the manifest:
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
Calico offers more features like network policies and works better in multi-region clusters, but it's heavier. For internal clusters behind a firewall, Flannel is enough.
Wait for the control plane node to show Ready:
kubectl get nodes
If it stays NotReady after three minutes, check the CNI pod logs:
kubectl logs -n kube-flannel -l app=flannel
Common issues: the pod network CIDR you passed to kubeadm init doesn't match the one in the Flannel config, or a firewall is blocking VXLAN traffic on UDP port 8472.
Step 3: Join worker nodes and verify cluster health
Worker nodes run your actual workloads. They need the same prep as the control plane: swap disabled, containerd installed, kubelet and kubeadm packages present.
On each worker, run the kubeadm join command you saved earlier:
sudo kubeadm join <CONTROL_PLANE_IP>:6443 --token <TOKEN> --discovery-token-ca-cert-hash sha256:<HASH>
If you lost the token, generate a new one on the control plane:
kubeadm token create --print-join-command
After joining, the worker appears in kubectl get nodes within 30 seconds. It takes another minute or two to pull the CNI images and transition to Ready.
Check that pods can schedule across nodes:
kubectl run test-nginx --image=nginx --replicas=3
kubectl get pods -o wide
You should see pods distributed across workers. If they all land on one node, the scheduler might be ignoring the others due to taints. Describe a node to check:
kubectl describe node <NODE_NAME> | grep Taints
Remove unwanted taints with:
kubectl taint nodes <NODE_NAME> <TAINT_KEY>-
For production, label nodes by role or hardware type so you can pin workloads later:
kubectl label nodes worker-1 workload=web
kubectl label nodes worker-2 workload=database
Step 4: Create namespaces and resource quotas
Namespaces prevent one team's runaway deployment from starving others. They also let you apply different network policies and RBAC rules per environment.
Create namespaces for staging and production:
apiVersion: v1
kind: Namespace
metadata:
name: production
---
apiVersion: v1
kind: Namespace
metadata:
name: staging
Apply it:
kubectl apply -f namespaces.yaml
Set resource quotas to cap CPU and memory per namespace. This stops a memory leak in staging from taking down production:
apiVersion: v1
kind: ResourceQuota
metadata:
name: production-quota
namespace: production
spec:
hard:
requests.cpu: "8"
requests.memory: 16Gi
limits.cpu: "16"
limits.memory: 32Gi
pods: "50"
Without these, a single pod can request more resources than your cluster has, leaving other pods stuck in Pending.
Also create a LimitRange to enforce default requests and limits on pods that don't specify them:
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: production
spec:
limits:
- max:
cpu: "2"
memory: 4Gi
min:
cpu: 50m
memory: 64Mi
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
type: Container
Apply both:
kubectl apply -f resource-quota.yaml
kubectl apply -f limit-range.yaml
Verify the quota is active:
kubectl describe quota production-quota -n production
Step 5: Deploy a workload with a Service and Ingress
Time to run something real. I'll use a simple web app with a LoadBalancer Service and an Ingress for external access.
Create a Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:stable
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
Expose it with a ClusterIP Service first:
apiVersion: v1
kind: Service
metadata:
name: web-service
namespace: production
spec:
selector:
app: web
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP
Apply both:
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
Check that pods are running and the service has endpoints:
kubectl get pods -n production
kubectl get endpoints web-service -n production
For external access, install an Ingress controller. NGINX Ingress is the standard:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
Wait for the ingress controller pod to be ready:
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
Create an Ingress resource:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: production
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80
Apply it and grab the external IP:
kubectl apply -f ingress.yaml
kubectl get ingress -n production
Point your DNS A record for app.example.com at that IP. In a minute or two, you should be able to curl the hostname and see the nginx welcome page.
Test failover by draining a node
Production readiness means surviving a node failure. Drain a worker to simulate maintenance:
kubectl drain worker-1 --ignore-daemonsets --delete-emptydir-data
Watch the pods migrate:
kubectl get pods -n production -o wide --watch
They should reschedule to other nodes within 30 seconds. If they stay Pending, check events:
kubectl get events -n production --sort-by='.lastTimestamp'
Common reasons: not enough CPU or memory available on remaining nodes, or a pod anti-affinity rule blocking placement.
Bring the node back:
kubectl uncordon worker-1
Pods won't automatically rebalance unless you delete and recreate them, or run a tool that does rolling restarts.
What to monitor after setup
Once your cluster is running, watch node CPU and memory with kubectl top nodes. If you don't have the metrics server installed, deploy it:
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
Set up alerts for pods in CrashLoopBackOff or Pending states for more than five minutes. Those are the two states that indicate real problems—everything else is usually transient.
Check the etcd database health on the control plane. A slow or full etcd will lock up the entire cluster:
sudo ETCDCTL_API=3 etcdctl --endpoints=127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
endpoint health
Back up etcd snapshots weekly. Losing etcd means rebuilding the cluster from scratch.
