Learn Kubernetes Part 1: Development with kind Using Docker Registry#

This blog walks through deploying and scaling a small NGINX-based application in Kubernetes using kind. It covers Pod replication, Services, load balancing, Ingress routing, autoscaling, and a few debugging scenarios including how to identify which Pod actually served a given request.

Kind is a tool for running local Kubernetes clusters using Docker containers as “Nodes.” It was originally designed for testing Kubernetes itself, but it works just as well for local development or CI/CD. This post shows you how to set up a kind environment for local development that closely mimics a production Kubernetes environment.

Table of Contents#

The Setup#

You can run this setup with or without a local registry. A registry is basically the same idea as Docker Hub a place to push and pull images except it’s your own. It’s a good practice to run one locally (or Selfhosted, if you prefer), so that’s what I’ve done here.

My setup is based on local registry and also without registry can be run without much of hassle, you can run that with few modifcation in kind-config.yaml also you can find it my Github repo.

A fully functioning environment built with kind needs a handful of components. Here’s everything we’ll install:

  1. Docker
  2. kubectl
  3. kind (Kubernetes IN Docker)
  4. k9s
  5. Registry
  6. Ingress controller
  7. Metrics server
  8. Helm

Docker#

apt install docker.io

kubectl#

curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client

kind#

curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
kind --version

Local Docker registry#

docker run -d --restart=always --name kind-registry -p 5000:5000 registry:latest

k9s#

curl -LO https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.tar.gz
tar -xzf k9s_Linux_amd64.tar.gz
sudo mv k9s /usr/local/bin/
k9s version

Helm#

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version

Verify all tools#

docker version
kubectl version --client
kind --version
k9s version
helm version

File structure#

/kubernetes/
├── Dockerfile
├── index.html
├── kind-config.yaml
└── manifests/
    ├── 00-namespace.yaml
    ├── 10-deployment.yaml
    ├── 15-service.yaml
    ├── 20-autoscaler.yaml
    └── 25-ingress.yaml

Dockerfile#

FROM nginx:alpine
RUN apk add --no-cache gettext
COPY index.html /tmp/index.html
CMD ["sh", "-c", "envsubst < /tmp/index.html > /usr/share/nginx/html/index.html && exec nginx -g 'daemon off;'"]

index.html#

<html>
  <body>
    <h1>NGINX</h1>
    <p>serving request from pod: <b>${HOSTNAME}</b></p>
  </body>
</html>

${HOSTNAME} gets replaced at container startup with the actual pod name. Since each pod shows a different name, this is an easy visual proof that load balancing is actually working.

00-namespace.yaml#

apiVersion: v1
kind: Namespace
metadata:
  name: panipuri-namespace

Creates isolation room panipuri-namespace. Everything else goes inside it. Applied first hence 00- prefix.

10-deployment.yaml#

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ng-deployment
  namespace: panipuri-namespace
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ng-pod   
  template:
    metadata:
      labels:
        app: ng-pod 
    spec:
      containers:
        - name: ng-pod1 #container 1
          image: localhost:5000/nginx:learn #docker image name
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"            
          readinessProbe:     
              httpGet:
                path: /    
                port: 80
              initialDelaySeconds: 2
              periodSeconds: 3
          livenessProbe: 
              httpGet:
                path: /    
                port: 80
              initialDelaySeconds: 3
              periodSeconds: 4

Runs 3 copies of nginx. Self heals if a pod death. Limits resources per pod.

15-service.yaml#

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  namespace: panipuri-namespace
spec:
  type: NodePort
  selector:
    app:  ng-pod  #must match with deployment labels 
  ports:
    - port: 80   #public face port
      targetPort: 80  #pod internal port
      #nodePort: 30080 #for testing

A stable internal endpoint that routes traffic to every pod labeled app: ng-pod. The default Service type is ClusterIP.

20-autoscaler.yaml#

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
    name: nginx-hpa
    namespace: panipuri-namespace
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ng-deployment
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50

Auto-scales pods 2-10 based on CPU usage. Scales out when CPU above 50%.

25-ingress.yaml#

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ng-ingress
  namespace: panipuri-namespace
spec:
  rules:
    - host: nginx.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-service  
                port:
                  number: 80

Routes external HTTP traffic for the hostname nginx.local through the Service and down to the Pods.

kind-config.yaml#

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
  [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5000"]
    endpoint = ["http://kind-registry:5000"]
nodes:
  - role: control-plane
    kubeadmConfigPatches:
    - |
      kind: InitConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "ingress-ready=true"
    extraPortMappings:
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP

Maps ports 80/443 to your laptop. Registers the local registry. Labels node for ingress.


Step by Step — Running Kubernetes with kind by using a local registry#

Step 1 — Verify tools#

docker version
kubectl version --client
kind --version
k9s version

Step 2 — Build Docker image#

cd ~/k8s-learning
docker build -t localhost:5000/nginx-learn:1.0 . #Build and rebuild command
docker images
docker inspect nginx-learn:1.0
docker run -p 8080:80 nginx-learn:1.0    # optional local test, Ctrl+C to stop

Step 3 — Create kind cluster#

kind get clusters
kind create cluster --name panipuri --config kind-config.yaml

The new development loop#

docker build -t localhost:5000/nginx-learn:1.0 .
docker push localhost:5000/nginx-learn:1.0
kubectl rollout restart deployment ng-deployment -n panipuri-namespace  #rolling restart to pull new image
kubectl get pods -n panipuri-namespace -w #watches for changes of pods continuously 

The advantage of using a registry here is simple: push once, and kind automatically pulls the latest image. Without a registry, you’d need to reload the image manually on every rebuild — and it’s easy to forget, which leaves you debugging against a stale image.

Step 4 - Deploy app#

Apply all manifests and verify eveything is running

Check existing namespaces first. You should only see default, kube-system, kube-public, kube-node-lease. The panipuri-namespace doesn’t exist yet.

kubectl get ns

Step 5 - Apply all manifests#

Applies all 5 YAML files in alphabetical order: namespace -> deployment -> service -> hpa -> ingress. The namespace gets created first, everything else goes inside it.

kubectl apply -f manifests/

Step 6 - Verify pods are running#

You should see 3 pods in Running state. If they show Pending or ImagePullBackOff, the image likely wasn’t loaded into kind correctly.

kubectl get pods -n panipuri-namespace
kubectl get svc -n panipuri-namespace

Step 7 - Access the app#

Port-forward to reach the app from your browser. This tunnels your laptop’s port 8080 directly to the Service.

kubectl port-forward svc/nginx-service 8080:80 -n panipuri-namespace

Open localhost:8080in your browser. Press ctrl+c when done.

Step 8 - Install the Ingress controller#

The Ingress object in your YAML needs a controller to actually process it. This installs the nginx ingress controller built for kind. Wait until it’s ready before proceeding.

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

 kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller  --timeout=90s

Step 9 - Re-apply manifest and add /etc/host entry#

kubectl apply -f manifests/

echo "127.0.0.5 nginx.local" | sudo tee -a /etc/hosts

curl http://nginx.local

Step 10 - Self healing demo#

Open a second terminal and run this. It shows pods updating in real time as you delete them.

Run this in a separate terminal window

kubectl get pods -n panipuri-namespace -w

Step 11 - Delete all pods by label#

Deletes all 3 pods at once, watch your other terminal. Kubernetes immediately create 3 new pods to replace them this is Self healing

kubectl get deploy ng-deployment -n panipuri-namespace
kubectl delete pod -l app=ng-pod -n panipuri-namespace

Step 12 - AutoScaling (HPA) Demo#

Install metrics-server. HPA needs metrics-server to read CPU usage. The patch disables TLS verification which is required in kind clusters.

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

 kubectl patch deployment metrics-server -n kube-system \
  --type=json \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'

kubectl rollout status deployment metrics-server -n kube-system

Step 13 - Confirm metrics are working#

Wait for 1 minute after installing metricsserver, then check. You should see CPU and memory numbers for your pods.

kubectl top pods -n panipuri-namespace

Step 14 - Generate load to trigger HPA#

Runs a busybox pod inside the cluster that hammers your service with requests. In a separate terminal, watch the HPA respond by adding pods.

Watch terminal run in separate Terminal

kubectl get pods -n panipuri-namespace -w
kubectl get hpa -n panipuri-namespace

Load generator (run in another terminal)

kubectl run loadgen -n panipuri-namespace --rm -it --image=busybox -- sh

Service DNS follows the format <service-name>.<namespace>.svc.cluster.local The inside the busybox shell, paste this:

while true; do
wget -qO- http://nginx-service.panipuri-namespace.svc.cluster.local > /dev/null &
wget -qO- http://nginx-service.panipuri-namespace.svc.cluster.local > /dev/null &
wget -qO- http://nginx-service.panipuri-namespace.svc.cluster.local > /dev/null &
done

Step 15 - Restart deployment (rolling update)#

Triggers a zero downtime rolling restart. Kubernetes replaces pods one by one. Watch in the -w terminal.

kubectl rollout restart deployment ng-deployment -n panipuri-namespace

Step 16 - Monitoring with Helm + Grafana#

Install prometheus and grafana dashboards. Install Prometheus + Grafana via Helm. Helm is a package manager for Kubernetes. This installs the full monitoring stack in one command into a separate monitoring namespace.

Run helm version first to confirm Helm is installed. If not, install it:

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
helm repo add prometheus-community https://prometheus-$ community.github.io/helm-charts
helm repo update
helm install monitoring prometheus-community/kube-prometheus-stack --namespace monitoring --create-namespace

Step 17 - Kubernetes dashboard#

Browse the UI to visualize the cluster

Install the dashbaord#

kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml

Step 18 - Create admin service account and get token#

The dashboard requires authentication. This creates an admin account and prints a login token you paste into the dashboard UI.

kubectl create serviceaccount dashboard-admin -n kubernetes-dashboard
kubectl create clusterrolebinding dashboard-admin \
  --clusterrole=cluster-admin \
  --serviceaccount=kubernetes-dashboard:dashboard-admin
kubectl -n kubernetes-dashboard create token dashboard-admin

Step 19 - Open the dashboard in browser#

kubectl proxy creates a secure tunnel to the cluster. Open the URL below, choose “Token” login, and paste the token from step 18. Keep the proxy terminal running while you use the dashboard.

Then open this URL in your browser:

http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/

When done - clean up#

Deletes the entire cluster and frees all resources. Your Docker image stays on your laptop.

kind delete cluster --name panipuri

GitHub repo for this project

:wq for now until later.