How to Deploy Your First App to Kubernetes with kind and kubectl (2026)
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
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
How to Fix AWS ALB Unhealthy Targets and 502/503 Errors (2026)
Fix AWS Application Load Balancer unhealthy targets and 502, 503 and 504 errors step by step: read the health reason code, fix health check paths and success codes, security groups, ip vs instance targets, EKS readiness gates, deregistration delay, keep-alive timeouts and access logs.
13 minNov 10, 2026How to Build a k3s Cluster at Home: HA etcd, MetalLB, Upgrades (2026)
Step-by-step k3s v1.37.2 homelab cluster: three servers with embedded etcd, agents, kubeconfig, MetalLB 0.16.2 instead of ServiceLB, Traefik ingress, local-path and Longhorn 1.13 storage, system-upgrade-controller v0.21.0 upgrades, snapshots and fixes.
13 minOct 31, 2026How to Upgrade an EKS Cluster to 1.37 Safely: Step by Step (2026)
Upgrade Amazon EKS one minor version at a time without an outage: refresh upgrade insights, scan for removed APIs with Pluto, upgrade the control plane with eksctl or the AWS CLI, then VPC CNI, CoreDNS and kube-proxy, then node groups and Karpenter, with the 7-day rollback.
15 min