Deploy your first app to Kubernetes
By the end of this guide you will have a local Kubernetes cluster running a three-replica web application, reachable from your browser, that heals itself when you delete a Pod and updates without downtime.
Time: about 20 minutes. Prerequisites: Docker installed and running.
1. Install the tools
- macOS
- Linux
- Windows
brew install kind kubectl
# kind
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
# kubectl
curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/kubectl
winget install Kubernetes.kind
winget install Kubernetes.kubectl
Verify both are on your PATH:
kind version
kubectl version --client
2. Create a cluster
kind runs each Kubernetes node as a Docker container. This config gives us one control-plane node and two workers, and maps host ports 8080/8443 into the cluster so we can reach it from a browser.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: nativehub
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 30080
hostPort: 8080
protocol: TCP
- role: worker
- role: worker
kind create cluster --config kind-config.yaml
kind writes a context into your kubeconfig and switches to it automatically. Confirm:
kubectl cluster-info --context kind-nativehub
kubectl get nodes -o wide
You should see three nodes in Ready state. If they sit at NotReady for more than a minute, the
CNI is still starting — check with kubectl get pods -n kube-system.
3. Write the manifests
Two objects: a Deployment that keeps three Pods alive, and a Service that gives them one stable address.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app.kubernetes.io/name: web
spec:
replicas: 3
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app.kubernetes.io/name: web
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101 # the nginx-unprivileged image's user
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.27-alpine
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ['ALL']
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 15
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app.kubernetes.io/name: web # must match the Pod labels, not the Deployment's
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080 # matches extraPortMappings in kind-config.yaml
4. Apply and watch
kubectl apply -f k8s/
kubectl rollout status deployment/web --timeout=90s
# The full ownership chain: Deployment -> ReplicaSet -> Pods
kubectl get deploy,rs,pod -l app.kubernetes.io/name=web
Now open http://localhost:8080 — the nginx welcome page is being served through the NodePort mapped by kind.
If it does not load, check that the Service has endpoints. Empty endpoints means no ready Pod matches the selector:
kubectl get endpointslices -l kubernetes.io/service-name=web
5. Watch it heal itself
# Delete a Pod and watch a replacement appear
kubectl get pods -l app.kubernetes.io/name=web --watch &
kubectl delete pod -l app.kubernetes.io/name=web --field-selector=status.phase=Running \
--wait=false | head -1
Nothing special happened here. The ReplicaSet controller compared its desired count (3) against what it observed (2) and created one Pod to close the gap. That same loop is what recovers from a node failure.
Stop the watch with kill %1 when you have seen enough.
6. Roll out a new version
kubectl set image deployment/web nginx=nginxinc/nginx-unprivileged:1.27.3-alpine
kubectl rollout status deployment/web
kubectl rollout history deployment/web
Because maxUnavailable: 0, Kubernetes never lets ready capacity drop below three during the
rollout — it adds a new Pod, waits for it to pass readiness, then removes an old one.
Roll back just as easily:
kubectl rollout undo deployment/web
7. Scale
kubectl scale deployment/web --replicas=5
kubectl get pods -l app.kubernetes.io/name=web -o wide
Look at the NODE column — the scheduler spread the new Pods across the workers based on available
resource requests.
8. Look inside
- Logs
- Shell
- Port-forward
- Events
# Follow logs from every Pod behind the label
kubectl logs -l app.kubernetes.io/name=web --tail=20 --follow --prefix
POD=$(kubectl get pod -l app.kubernetes.io/name=web -o name | head -1)
kubectl exec -it "$POD" -- sh
# inside: curl localhost:8080, cat /etc/resolv.conf, env | sort
# Tunnel straight to the Service, bypassing NodePort entirely
kubectl port-forward svc/web 9090:80
# then: curl localhost:9090
kubectl get events --sort-by=.lastTimestamp | tail -20
kubectl describe deployment web
9. Clean up
kubectl delete -f k8s/
kind delete cluster --name nativehub
Deleting the cluster removes the Docker containers backing the nodes and the kubeconfig context.
What you actually learned
- A Deployment describes desired state; controllers converge the cluster onto it.
- A Service provides a stable address over a changing set of Pods, wired up by label selectors — the most common source of "why is nothing reachable" is a selector mismatch.
- Readiness probes gate traffic; liveness probes trigger restarts. They are not interchangeable.
- Rollouts and rollbacks are just the Deployment scaling ReplicaSets up and down.
Next steps
- Build and publish a Docker image — deploy your own code instead of nginx.
- Write your first Helm chart — parameterise these manifests.
- Debugging CrashLoopBackOff — for when it does not come up green.