How to Deploy Your First App to Kubernetes with kind and kubectl (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

This guide is for anyone who has used Docker but never deployed to Kubernetes. By the end you will have a three-node Kubernetes 1.37 cluster running on your laptop, an app deployed with a Deployment and a Service, and hands-on experience of port-forwarding, scaling, a rolling update, a rollback, logs, exec and cleanup. Everything here was checked against kind v0.33.0 (released 26 August 2026, default node image Kubernetes v1.37.0) and kubectl v1.37.2, the current stable release. You need Docker running (Docker Desktop on macOS or Windows, Docker Engine on Linux; kind also supports Podman and nerdctl) and a terminal. If you want the concepts first, read the companion explainer, Kubernetes explained: Pods, Deployments and Services, then come back.

Step 1: Install kind and kubectl (pinned versions)

kind ("Kubernetes in Docker") runs each Kubernetes node as a Docker container. It is the tool the Kubernetes project itself uses for testing, and a cluster costs nothing to throw away. The quick start gives these pinned downloads:

# Linux x86_64: kind v0.33.0
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
# Linux arm64
[ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind

# macOS Apple Silicon (use kind-darwin-amd64 on Intel Macs)
[ $(uname -m) = arm64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-arm64
chmod +x ./kind && mv ./kind /usr/local/bin/kind

# Or, with Go installed
go install sigs.k8s.io/kind@v0.33.0

brew install kind also works but installs whatever is newest, not a pinned version.

kubectl is the command-line client. It is supported within one minor version of the API server, so for a 1.37 cluster use 1.36, 1.37 or 1.38. Install v1.37.2 and verify the checksum:

# Linux amd64 (swap amd64 for arm64 as needed)
curl -LO "https://dl.k8s.io/release/v1.37.2/bin/linux/amd64/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.2/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check     # expect: kubectl: OK
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

# macOS Apple Silicon
curl -LO "https://dl.k8s.io/release/v1.37.2/bin/darwin/arm64/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.2/bin/darwin/arm64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | shasum -a 256 --check
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl

kind version              # expect: kind v0.33.0 ...
kubectl version --client  # expect: Client Version: v1.37.2

I checked my own machine before writing this: it had kubectl v1.32.3 on the path, five minors behind. That is the most common source of odd warnings later, so check yours.

Step 2: Create a kind cluster with a pinned node image

The kind release notes say you must use the @sha256 digest to guarantee a node image built for that release. Put it in a config file so the cluster is reproducible:

# kind-config.yaml: one control plane, two workers, Kubernetes v1.37.0
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    image: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
  - role: worker
    image: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
  - role: worker
    image: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
kind create cluster --name first-app --config kind-config.yaml --wait 2m
kubectl cluster-info --context kind-first-app
kubectl get nodes

kind names the kubectl context kind-<cluster-name> and switches to it. Expected output, give or take ages:

NAME                      STATUS   ROLES           AGE   VERSION
first-app-control-plane   Ready    control-plane   60s   v1.37.0
first-app-worker          Ready    <none>          40s   v1.37.0
first-app-worker2         Ready    <none>          40s   v1.37.0

Run docker ps and you will see three containers with those names. Each is a "node" running kubelet and containerd inside it. kubectl get pods -n kube-system shows the control plane components (kube-apiserver, etcd, kube-scheduler, kube-controller-manager) running as Pods, plus CoreDNS, kube-proxy and kindnet, kind's network plugin.

Step 3: Create a namespace to work in

Working in your own namespace keeps experiments separate from system Pods and makes cleanup a single command.

kubectl create namespace demo
kubectl config set-context --current --namespace=demo   # default namespace for this context
kubectl config view --minify | grep namespace           # expect: namespace: demo

Step 4: Write and apply a Deployment

The app is podinfo, a small Go web service built for Kubernetes demos. It listens on port 9898, exposes /healthz and /readyz, and returns its version and hostname as JSON, which makes rolling updates visible. Releases 6.16.0 and 6.17.0 both shipped in October 2026, so we start on the older one and upgrade later.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels: { app: web }
spec:
  replicas: 3
  revisionHistoryLimit: 5           # old ReplicaSets kept for rollback (default 10)
  selector:
    matchLabels: { app: web }       # must match the template labels below
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                   # at most one extra Pod during an update
      maxUnavailable: 0             # never drop below 3 ready Pods
  template:
    metadata:
      labels: { app: web }
    spec:
      containers:
        - name: podinfo
          image: ghcr.io/stefanprodan/podinfo:6.16.0
          ports:
            - name: http
              containerPort: 9898
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 128Mi }
          readinessProbe:
            httpGet: { path: /readyz, port: http }
            initialDelaySeconds: 2
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            initialDelaySeconds: 5
kubectl apply -f deployment.yaml
kubectl rollout status deployment/web     # waits until all 3 are ready
kubectl get pods -o wide                  # see which worker each Pod landed on

Expected output (illustrative, not captured from a run):

deployment.apps/web created
Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...
deployment "web" successfully rolled out

kubectl get replicasets shows the ReplicaSet the Deployment created for you, named web-<hash>. Try kubectl delete pod <one-of-the-pods> and run kubectl get pods again: a replacement appears within seconds. That is the reconciliation loop doing its job. The maxSurge and maxUnavailable values above override the defaults of 25 percent each; with three replicas, zero unavailable is easier to reason about.

Step 5: Expose the Deployment with a Service

A Service gives the Pods one stable name and virtual IP. ClusterIP, the default type, is reachable only inside the cluster, which is what you want for now.

# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector: { app: web }      # routes to Pods with this label
  ports:
    - name: http
      port: 80                # port clients use
      targetPort: http        # named container port (9898)
kubectl apply -f service.yaml
kubectl get service web
kubectl get endpointslices -l kubernetes.io/service-name=web   # should list 3 Pod IPs

If the EndpointSlice shows no addresses, the selector does not match the Pod labels or the Pods are not ready. Fix that before going further; it is the cause of most "my Service doesn't work" questions.

Step 6: Reach the app with kubectl port-forward

kubectl port-forward service/web 8080:80
# in a second terminal:
curl -s localhost:8080
{
  "hostname": "web-6c9f7d8b5d-x2k4q",
  "version": "6.16.0",
  "message": "greetings from podinfo v6.16.0"
}

That output is illustrative and trimmed; podinfo also returns fields such as revision, goos and num_cpu. Your hostname will differ. Note that port-forward to a Service picks one backing Pod and sticks to it, so repeated curls will show the same hostname. To see load balancing across Pods, call the Service from inside the cluster:

kubectl run curl --rm -it --restart=Never --image=curlimages/curl:8.23.0 -- \
  sh -c 'for i in 1 2 3 4 5 6; do curl -s web/ | grep hostname; done'

Step 7: Scale the Deployment

kubectl scale deployment/web --replicas=5
kubectl get pods -l app=web
kubectl get endpointslices -l kubernetes.io/service-name=web   # now 5 addresses

Scaling changes spec.replicas only, so it does not create a new revision. In a real cluster you would leave this to a HorizontalPodAutoscaler and keep the replica count out of the manifest you apply. Scale back with --replicas=3 before continuing.

Step 8: Do a rolling update

Change the image, record why, and watch Kubernetes replace Pods one at a time:

kubectl set image deployment/web podinfo=ghcr.io/stefanprodan/podinfo:6.17.0
kubectl annotate deployment/web kubernetes.io/change-cause="podinfo 6.16.0 to 6.17.0"
kubectl rollout status deployment/web
kubectl get replicasets            # old RS scaled to 0, new RS at 3
kubectl rollout history deployment/web

The history should look like this (illustrative):

deployment.apps/web
REVISION  CHANGE-CAUSE
1         <none>
2         podinfo 6.16.0 to 6.17.0

The old --record flag is deprecated, which is why the annotation is set explicitly. Re-run the curl from Step 6 (restart the port-forward first, because its Pod is gone) and the version reads 6.17.0. In a team you would change the tag in deployment.yaml, commit it and kubectl apply, so Git stays the source of truth rather than your shell history.

Step 9: Roll back a bad release

Simulate a broken release with a tag that does not exist:

kubectl set image deployment/web podinfo=ghcr.io/stefanprodan/podinfo:9.9.9
kubectl rollout status deployment/web --timeout=60s   # times out
kubectl get pods -l app=web                           # one Pod in ErrImagePull / ImagePullBackOff

Because maxUnavailable is 0, the three good Pods keep serving while the new one fails to pull. Roll back:

kubectl rollout undo deployment/web                   # back to the previous revision
# Alternative to the line above, not in addition: after an undo, the restored
# revision is renumbered, so pick a number from `kubectl rollout history` first.
# kubectl rollout undo deployment/web --to-revision=<n>
kubectl rollout status deployment/web

If you left nothing to stop it, a failed rollout is marked ProgressDeadlineExceeded after progressDeadlineSeconds, 600 by default. Kubernetes does not roll back on its own; that is your job or your deploy tool's. The ImagePullBackOff guide covers the real-world causes.

Step 10: Read logs and exec into a Pod

kubectl logs deployment/web                     # logs from one Pod of the Deployment
kubectl logs -l app=web --prefix --tail=20      # last 20 lines from every matching Pod
kubectl logs <pod-name> --previous              # the crashed container's last run
kubectl exec -it deployment/web -- sh           # shell inside one Pod (podinfo is Alpine-based)
#   inside: wget -qO- localhost:9898/healthz ; exit
kubectl describe pod <pod-name>                 # events: scheduling, pulls, probe failures
kubectl get events --sort-by=.lastTimestamp

describe and get events are the two commands I reach for first. Most failures explain themselves in the Events section at the bottom.

Step 11 (optional): Run your own image with kind load

kind nodes cannot see images in your local Docker cache. Build, then load the image into the cluster:

docker build -t myapp:0.1.0 .
kind load docker-image myapp:0.1.0 --name first-app
# then use image: myapp:0.1.0 in the Deployment, never :latest

The kind docs explain why :latest is a trap: it makes the default pull policy Always and the node will try, and fail, to pull it from Docker Hub. Build for your host's architecture; the node images support amd64 and arm64 but must match the host.

Step 12: Clean up

kubectl delete namespace demo          # removes everything you created
kind delete cluster --name first-app   # removes the node containers and the kubectl context

How the steps fit together

First app on kind: the happy path and the debug loop 1. Install kind v0.33.0 kubectl v1.37.2 2. Create cluster kindest/node v1.37.0 1 control, 2 workers 3. Apply Deployment, Service namespace demo 4. Test port-forward 8080:80 curl localhost:8080 5. Change scale, set image, rollout undo Pod not Running or not Ready? get pods -o wide status, node, restarts describe pod Events at the bottom get events --sort-by=.lastTimestamp logs, exec --previous for crashes Done? kubectl delete namespace demo, then kind delete cluster --name first-app. Versions: kind v0.33.0, node image v1.37.0 pinned by digest, kubectl v1.37.2, podinfo 6.16.0 then 6.17.0.
The tutorial's five stages and the four-command debug loop used whenever a Pod is not Running or Ready. Versions come from the kind v0.33.0 release notes and dl.k8s.io stable.txt.

Troubleshooting

Symptom Likely cause Fix
kind create cluster fails with "Cannot connect to the Docker daemon" Docker is not running, or kind picked the wrong provider Start Docker Desktop or sudo systemctl start docker; set KIND_EXPERIMENTAL_PROVIDER=podman if you use Podman
kubectl warns that the client and server version difference exceeds the supported skew Old kubectl on your PATH Install kubectl v1.37.x (Step 1); check with which -a kubectl
kubectl talks to the wrong cluster or says the connection is refused Wrong current context, or the kind cluster was deleted kubectl config get-contexts, then kubectl config use-context kind-first-app
Pods stuck in Pending with "Insufficient cpu" or "Insufficient memory" Requests exceed what the Docker VM gives the nodes Lower requests, or raise Docker Desktop resources; see the Pending guide
ErrImagePull or ImagePullBackOff on a local image Image not loaded into kind, or tagged :latest kind load docker-image myapp:0.1.0 --name first-app; use a versioned tag
exec format error in the logs, Pod in CrashLoopBackOff Image built for a different CPU architecture Rebuild for your host (docker build --platform linux/arm64 on Apple Silicon); see the CrashLoopBackOff guide
Service has no endpoints; curl hangs or is refused Selector does not match Pod labels, or Pods not Ready Compare kubectl get pods --show-labels with the Service selector; check readiness in kubectl describe pod
port-forward fails with "address already in use" Something else is on local port 8080 Use another port: kubectl port-forward service/web 9090:80
port-forward stops after a rollout It was attached to a Pod that was replaced Re-run the command; it picks a current Pod
kubectl rollout status hangs, then ProgressDeadlineExceeded New Pods never become Ready (bad tag, failing probe) kubectl rollout undo deployment/web, then read kubectl describe pod for the new Pod

FAQ

kind vs minikube vs k3d: which should a beginner use?

All three run a real Kubernetes locally. kind is the one upstream Kubernetes uses for its own testing, starts fast, and does multi-node clusters from a short config file, which is why I use it here. minikube has more add-ons and VM drivers; k3d runs the lighter k3s distribution. Any of them will teach you the same kubectl skills.

Why does my kind cluster not get an external IP for a LoadBalancer Service?

kind has no cloud provider to create a load balancer, so the EXTERNAL-IP stays pending. Use kubectl port-forward while learning, or follow the kind LoadBalancer docs to run cloud-provider-kind, which assigns addresses locally.

Can I use the kubectl that comes with Docker Desktop?

Yes, if it is within one minor version of your cluster. Check with kubectl version; the server in this guide is v1.37.0, so any 1.36, 1.37 or 1.38 client is supported. Older clients often work but are outside the support policy.

Is a kind cluster suitable for production?

No. kind is built for testing Kubernetes and for local development and CI. For production use a managed service such as EKS, GKE or AKS, or a distribution meant for real hardware. The manifests you wrote here will work there unchanged.

When something breaks on a real cluster, I check the same things in the same order as on kind: the current context and namespace (most "it's gone" moments are the wrong one), then kubectl get pods -o wide, then the Events at the bottom of kubectl describe pod, then logs with --previous. I also keep every manifest in Git with pinned image tags from the first day, because a rollback is only as good as your record of what was running before. For deeper reading, the official kind quick start, the Deployments documentation, the Service documentation and the kubectl reference are the sources this guide was checked against, and the concepts explainer covers what each object is doing behind the commands.

Related: How to Fix Kubernetes CrashLoopBackOff: Causes and Commands (2026), How to Fix ImagePullBackOff and ErrImagePull in Kubernetes (2026) and Kubernetes Probes Explained: Liveness vs Readiness vs Startup (2026).

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related