Skip to main content

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

brew install kind 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-config.yaml
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.

k8s/deployment.yaml
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
k8s/service.yaml
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

# Follow logs from every Pod behind the label
kubectl logs -l app.kubernetes.io/name=web --tail=20 --follow --prefix

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