Skip to main content

Deploy Your First Application on Kubernetes

Deploy a web app to a local minikube cluster with a Deployment, Service, ConfigMap and autoscaler, then practise rolling updates and rollbacks.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project deploys a web application to a local Kubernetes cluster with minikube, using a Deployment, a Service, a ConfigMap and a HorizontalPodAutoscaler. You then practise a rolling update, a bad release with a rollback, and pod self-healing. You finish with a small set of manifests in Git that rebuild the whole setup with one kubectl apply.

The problem this solves

Running one Docker container is fine on a laptop. Picture a team at a company like Swiggy or Spotify shipping a service to real users. They need several copies for reliability, automatic restarts when a container crashes, updates with no downtime, and more copies when traffic spikes. Docker alone does none of that.

Kubernetes is the platform that does. You declare the desired state, for example "run three copies of this app", and Kubernetes keeps working to make reality match. A crashed pod is replaced, an update rolls out gradually, and the autoscaler adds pods under load. These are the same resources you will use every day as a DevOps engineer.

What you will build

flowchart LR K["kubectl apply"] --> D["Deployment"] CM["ConfigMap"] --> P["3 Pods"] D --> P H["Autoscaler"] -->|scales| D B["Browser"] --> S["Service"] S --> P classDef k8s fill:#326CE5,color:#FFFFFF classDef cfg fill:#8B5CF6,color:#FFFFFF classDef scale fill:#10B981,color:#FFFFFF classDef user fill:#475569,color:#FFFFFF class D,P,S k8s class CM cfg class H scale class K,B user
Resource What it does
Pod The smallest unit: one or more containers. Pods are temporary and replaced when they die
Deployment Keeps the desired number of identical pods running and handles rolling updates
Service A stable address that load balances across the pods, even as their IPs change
ConfigMap Configuration kept outside the image, so you change settings without rebuilding
HorizontalPodAutoscaler Adds or removes pods based on CPU usage

What you will have at the end

  • Four manifests (configmap.yaml, deployment.yaml, service.yaml, hpa.yaml) in a Git repository
  • A running app reachable from your browser, with three replicas behind a Service
  • Proof that a rolling update causes no downtime, that a bad release can be rolled back, and that deleted pods come back
  • A short README with your terminal output, which makes a good first portfolio piece

Prerequisites and cost

You need Docker installed and running, about 4 GB of free RAM and 2 CPU cores. Everything runs on your laptop, so there is no cloud cost.

Milestone 1: Install the Tools and Start a Local Cluster

This milestone installs kubectl and minikube and starts a single-node Kubernetes cluster on your machine.

minikube runs a real Kubernetes cluster inside a container on your laptop, which makes it ideal for learning without cloud bills. kubectl is the command-line client you use to talk to any Kubernetes cluster. Both work the same way against a managed cluster later.

Bash
# macOS (Homebrew)
brew install minikube kubectl
# Linux
curl -LO https://github.com/kubernetes/minikube/releases/latest/download/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
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
minikube version
kubectl version --client

Start the cluster and enable the metrics add-on, which the autoscaler needs to read CPU usage:

Bash
minikube start --driver=docker --cpus=2 --memory=4096
# Takes 2 to 3 minutes the first time
minikube addons enable metrics-server
kubectl cluster-info
kubectl get nodes
# Expected: one node named minikube with STATUS Ready
Tip

minikube is for learning. In production you use a managed service such as Amazon EKS, Google GKE or Azure AKS. The resources you write here work the same way on all of them.

Common Mistake

Running minikube start while Docker is not running. It fails with a driver error. Start Docker Desktop (or the Docker service) first, then retry.

Milestone 2: Write the ConfigMap and Deployment

This milestone writes the two manifests that define what runs: the configuration and the Deployment that keeps three pods alive.

A Deployment does not manage pods directly by name. It uses labels and a selector: the Deployment says "I own every pod labelled app: webapp", and the pod template stamps that label on each pod it creates. If the two do not match, the Deployment is rejected, so keep them in sync. Resource requests and limits tell the scheduler how much CPU and memory a pod needs and caps what it may use. The autoscaler in the next milestone depends on the CPU request.

Bash
mkdir k8s-first-deploy && cd k8s-first-deploy && git init

Create configmap.yaml:

YAML
apiVersion: v1
kind: ConfigMap
metadata:
name: webapp-config
labels:
app: webapp
data:
# Each key becomes an environment variable in the pods
APP_NAME: "DevOps Network App"
APP_VERSION: "1.0.0"
ENVIRONMENT: "learning"
LOG_LEVEL: "info"

Create deployment.yaml:

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
labels:
app: webapp
spec:
replicas: 3
# Which pods this Deployment owns. Must match template.metadata.labels
selector:
matchLabels:
app: webapp
# Replace pods gradually so the app stays up during updates
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # start at most 1 extra pod during an update
maxUnavailable: 0 # never drop below the desired count
template:
metadata:
labels:
app: webapp
spec:
containers:
- name: webapp
image: nginx:1.28-alpine
ports:
- containerPort: 80
name: http
# Inject every ConfigMap key as an environment variable
envFrom:
- configMapRef:
name: webapp-config
# requests = guaranteed minimum, limits = hard maximum
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
# Readiness: is the pod ready to receive traffic?
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
# Liveness: should Kubernetes restart this container?
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
Tip

Environment variables from a ConfigMap are read when a pod starts. If you edit the ConfigMap later, run kubectl rollout restart deployment/webapp so new pods pick up the change.

Common Mistake

A selector.matchLabels that does not match the pod template labels. The apply fails with a selector error. Copy the same app: webapp label into both places.

Milestone 3: Add the Service and the Autoscaler

This milestone writes the Service that exposes the pods and the autoscaler that adds pods under load.

Pods get new IP addresses every time they are replaced, so nothing should talk to a pod IP directly. A Service gives the group one stable address and load balances across every healthy pod that matches its selector. NodePort publishes that address on a port of the node, which is how minikube reaches it from your laptop. The HorizontalPodAutoscaler (HPA) watches average CPU across the pods and changes the replica count to hold a target, here 70 percent of the CPU each pod requested.

Create service.yaml:

YAML
apiVersion: v1
kind: Service
metadata:
name: webapp
labels:
app: webapp
spec:
type: NodePort
selector:
app: webapp
ports:
- name: http
port: 80 # port the Service listens on
targetPort: 80 # port on the pod
nodePort: 30080 # port on the node (30000-32767)

Create hpa.yaml:

YAML
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: webapp-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: webapp
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Common Mistake

Creating an HPA for a Deployment whose containers have no CPU requests. The HPA shows <unknown> as its target and never scales, because utilisation is measured against the request. Keep the requests block from Milestone 2.

Milestone 4: Deploy and Explore the App

This milestone applies all four manifests and learns the commands you will use for debugging on any cluster.

kubectl apply is declarative: you hand Kubernetes a file describing the end state and it works out what to create or change. Running it again with the same files changes nothing, which is why manifests belong in Git.

Bash
kubectl apply -f .
kubectl get pods --watch
# Expected: 3 pods go from ContainerCreating to Running 1/1. Press Ctrl+C
kubectl get deployments,services,hpa
# Expected: webapp 3/3 ready, a NodePort service on 80:30080, an HPA with 2 to 6 replicas
minikube service webapp --url
# Open the printed URL in your browser to see the nginx welcome page
Tip

On macOS and Windows with the Docker driver, minikube service webapp --url keeps running to hold a tunnel open. Leave that terminal alone and use another one for the next commands.

Now explore the app the way you would during an incident:

Bash
POD=$(kubectl get pods -l app=webapp -o jsonpath='{.items[0].metadata.name}')
kubectl describe pod $POD # read the Events section at the bottom
kubectl logs $POD
kubectl logs -l app=webapp --tail=5
kubectl exec -it $POD -- sh # a shell inside the container; type exit to leave
kubectl top pods # CPU and memory (metrics-server needs a minute to warm up)
Command Use it to
kubectl get pods -o wide See which node and IP each pod has
kubectl describe pod NAME Find out why a pod is Pending or crashing
kubectl logs -f NAME Follow application logs
kubectl exec -it NAME -- sh Look around inside a running container
kubectl top pods Check live CPU and memory use
kubectl get events --sort-by=.lastTimestamp See recent cluster events in order
Common Mistake

Reading only kubectl get pods when something is wrong. The status column is a summary. The real reason is almost always in kubectl describe pod (Events) or kubectl logs.

Milestone 5: Roll Out an Update, Break It, Roll It Back

This milestone updates the image with no downtime, ships a deliberately broken version, and rolls back.

During a rolling update the Deployment starts a new pod, waits for its readiness probe to pass, then removes an old one. With maxUnavailable: 0 there is always full capacity serving traffic. Every change creates a numbered revision, which is what makes rollback possible.

Bash
kubectl set image deployment/webapp webapp=nginx:1.29-alpine
kubectl rollout status deployment/webapp
# Expected: successfully rolled out
kubectl rollout history deployment/webapp
# Expected: two revisions

Now ship a bad release by pointing at an image tag that does not exist:

Bash
kubectl set image deployment/webapp webapp=nginx:does-not-exist
kubectl get pods
# Expected: new pods show ErrImagePull or ImagePullBackOff
# Old pods keep running, so the site still answers
kubectl rollout status deployment/webapp --timeout=30s
# Expected: waiting, then a timeout. The rollout is stuck
kubectl rollout undo deployment/webapp
kubectl rollout status deployment/webapp
kubectl get pods
# Expected: back to 3 healthy pods

The site stayed up the whole time, because the readiness probe and maxUnavailable: 0 kept the old pods serving until new ones were healthy.

Tip

kubectl rollout undo is the first thing to try when a release goes wrong. It returns to the previous revision in seconds, without a rebuild.

Common Mistake

Fixing a bad release by editing pods one by one. Pods are disposable and the Deployment recreates them from its template. Change the Deployment, or roll it back.

Milestone 6: Test Self-Healing and Autoscaling

This milestone proves Kubernetes repairs itself and reacts to load.

The Deployment controller compares the number of running pods with replicas all the time. Delete one and it creates a replacement. The HPA does the same for load: it checks CPU every 15 seconds or so and adjusts the replica count.

Bash
POD=$(kubectl get pods -l app=webapp -o jsonpath='{.items[0].metadata.name}')
kubectl delete pod $POD
kubectl get pods --watch
# Expected: one pod Terminating, a new one appears and reaches Running
# Press Ctrl+C. READY returns to 3/3 within seconds

Generate load and watch the autoscaler. Open two terminals:

Bash
# Terminal 1: watch the autoscaler
kubectl get hpa webapp-hpa --watch
# Terminal 2: start a load generator inside the cluster
kubectl run load --image=busybox:1.36 --restart=Never -- \
/bin/sh -c "while true; do wget -q -O- http://webapp > /dev/null; done"

CPU on a static nginx page is low, so scaling can take a few minutes and may stay small on a slow laptop. Watching TARGETS rise above 70 percent and REPLICAS increase is the goal. Stop the load when you are done:

Bash
kubectl delete pod load
# Replicas scale back down after about 5 minutes of low CPU
Common Mistake

Expecting the HPA to react instantly. It samples on an interval and scales down slowly on purpose, to avoid flapping. Give it a few minutes before deciding it is broken.

Validation & Testing

This final step verifies every resource and proves the update caused no downtime, then cleans up.

Bash
# 1. Three pods running
kubectl get pods -l app=webapp
# Expected: 3 pods, STATUS Running, READY 1/1
# 2. ConfigMap values reach the container
kubectl exec deploy/webapp -- env | grep APP_NAME
# Expected: APP_NAME=DevOps Network App
# 3. The app answers through the Service
APP_URL=$(minikube service webapp --url)
curl -s -o /dev/null -w "%{http_code}\n" "$APP_URL"
# Expected: 200
# 4. The autoscaler is healthy
kubectl get hpa webapp-hpa
# Expected: TARGETS shows a CPU percentage (not <unknown>), MINPODS 2, MAXPODS 6
# 5. A rolling update causes no downtime
( for i in $(seq 1 120); do curl -s -o /dev/null -w "%{http_code}\n" "$APP_URL"; sleep 0.5; done ) > codes.txt &
kubectl set image deployment/webapp webapp=nginx:1.28-alpine
kubectl rollout status deployment/webapp
wait
sort codes.txt | uniq -c
# Expected: only 200 responses

Clean up when you are finished:

Bash
kubectl delete -f .
minikube stop

Commit the four manifests to Git with a README that includes your outputs from steps 1 to 5. A reviewer can see that you can deploy, update, roll back and debug an application on Kubernetes.

Found a mistake, an outdated step or a broken link?