Skip to content
Back to Blog
Linux & Server11 min read

Kubernetes Cluster Setup: 5 Steps to Production Pods

Configure nodes, isolate namespaces, and deploy workloads for teams migrating from single-server architectures to containerized infrastructure.

Written by Abdul AbrorTechnical Hosting Support Engineer
Kubernetes Cluster Setup: 5 Steps to Production Pods
On this page

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.

FAQ

What if kubeadm init fails with "port 6443 already in use"?

Another process is bound to that port, or a previous kubeadm run left the API server running. Stop it with sudo kubeadm reset, then run kubeadm init again.

Do I need a load balancer for the control plane in production?

Yes, if you're running multiple control plane nodes for high availability. Put them behind a TCP load balancer on port 6443 so workers can join through a single endpoint. A single control plane is fine for internal dev clusters.

Can I change the pod network CIDR after initialization?

Not easily. You'd need to tear down the cluster and reinitialize with the new CIDR. Plan your IP ranges before running kubeadm init.

Why are my pods stuck in ImagePullBackOff?

Either the image name is wrong, the registry requires authentication, or the node can't reach the internet. Check the pod events with kubectl describe pod <POD_NAME> for the exact error.