Real teams rarely deploy ‘an app’. They deploy a set of moving parts: a browser UI, an API, a database, a background worker, and a queue—each with its own failure modes. The first time you put that puzzle on Kubernetes, the friction usually isn’t the YAML. It’s the hidden coupling: hard-coded URLs, missing health checks, images that aren’t built the same way twice, and services that work on your laptop but disappear once the pods restart.
I’m going to walk you through a full project tutorial I use when onboarding engineers to Kubernetes: a small Notes product with a static frontend and a Python Flask backend API. You’ll containerize both tiers, push images to a registry, deploy them with Deployments, connect them with Services, and expose the frontend so you can open it in a browser outside the cluster. Along the way, I’ll use the defaults I trust in 2026: readiness/liveness probes, sane resource requests, rolling updates, and a deployment workflow that doesn’t leave you guessing what’s running.
You can follow this on a local cluster (Minikube) or a multi-node cluster created with kubeadm. I’ll call out the differences when they matter.
What We’re Building (and Why This Shape Works)
Here’s the architecture we’ll ship:
- Frontend: static HTML/JS served by NGINX.
- Backend: Flask API serving JSON over HTTP.
- Kubernetes objects:
– Deployment for each tier to keep pods running and support rolling updates.
– Service for each tier to provide stable networking inside the cluster.
– Ingress (or a LoadBalancer/NodePort) to expose the frontend outside the cluster.
I like this setup because it forces you to learn the parts that matter:
- Your backend is discovered through a Service name, not an IP address.
- Your frontend talks to the backend through an internal DNS name.
- You get a clean boundary: frontend is public, backend stays cluster-internal.
A simple analogy I use with new engineers: a Deployment is the ‘factory manager’ that keeps the right number of worker pods running, and a Service is the ‘front desk phone number’ that always reaches the right workers even when they come and go.
Prerequisites
You need:
- A working Kubernetes cluster.
– Local: Minikube is the fastest path.
– Bare metal/VMs: a kubeadm cluster is fine too.
- kubectl configured to talk to your cluster.
- Docker (or another OCI-compatible build tool) to build images.
Optional but helpful:
- k9s for browsing cluster resources interactively.
- A container registry account (Docker Hub, GHCR, GitLab registry, etc.).
If you like shorter commands, set an alias:
alias k=kubectl
Project Layout: Files You’ll Create
I’ll keep the project minimal, but complete:
notes-app/
backend/
app.py
requirements.txt
Dockerfile
frontend/
index.html
app.js
nginx.conf
Dockerfile
k8s/
00-namespace.yaml
10-backend.yaml
20-frontend.yaml
30-ingress.yaml
You can name the folder anything. I’ll refer to it as notes-app.
Phase 1 — Containerizing the Backend (Flask API)
I’m going to build the backend as a small Flask service with endpoints that match how Kubernetes thinks:
- GET /healthz for liveness (is the process alive?).
- GET /readyz for readiness (is the app ready to receive traffic?).
- GET /api/notes and POST /api/notes for the product.
Backend code: backend/app.py
from future import annotations
import os
import time
from flask import Flask, jsonify, request
app = Flask(name)
# A toy in-memory store.
# In a real system you would back this with a database.
NOTES: list[dict] = [
{‘id‘: 1, ‘title‘: ‘First note‘, ‘body‘: ‘Kubernetes makes pods replaceable.‘}
]
# Simulate slow startup to see readiness behavior.
STARTUPDELAYSECONDS = float(os.getenv(‘STARTUPDELAYSECONDS‘, ‘0‘))
BOOTED_AT = time.time()
def uptime_seconds() -> float:
return time.time() – BOOTED_AT
@app.get(‘/healthz‘)
def healthz():
# Liveness: if this fails repeatedly, Kubernetes restarts the container.
return jsonify({‘status‘: ‘ok‘})
@app.get(‘/readyz‘)
def readyz():
# Readiness: block traffic until startup work finishes.
if uptimeseconds() < STARTUPDELAY_SECONDS:
return jsonify({‘status‘: ‘starting‘}), 503
return jsonify({‘status‘: ‘ready‘})
@app.get(‘/api/notes‘)
def list_notes():
return jsonify({‘notes‘: NOTES})
@app.post(‘/api/notes‘)
def create_note():
payload = request.get_json(silent=True) or {}
title = str(payload.get(‘title‘, ‘‘)).strip()
body = str(payload.get(‘body‘, ‘‘)).strip()
if not title:
return jsonify({‘error‘: ‘title is required‘}), 400
next_id = (max([n[‘id‘] for n in NOTES]) + 1) if NOTES else 1
note = {‘id‘: next_id, ‘title‘: title, ‘body‘: body}
NOTES.append(note)
return jsonify(note), 201
if name == ‘main‘:
port = int(os.getenv(‘PORT‘, ‘5000‘))
# 0.0.0.0 is required inside containers so the app is reachable.
app.run(host=‘0.0.0.0‘, port=port)
Backend dependencies: backend/requirements.txt
flask==3.0.3
Pinning makes builds reproducible. On a real team I’d pair this with dependency update automation plus image scanning in CI, but the pinned file is enough for a solid tutorial.
Backend container image: backend/Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install –no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD python app.py
A few notes from experience:
- Keep the base image explicit so builds don’t silently change.
- Install dependencies before copying the whole source so rebuilds are faster when only code changes.
- Bind to 0.0.0.0 inside containers, or Kubernetes won’t be able to reach your app.
Phase 1 — Containerizing the Frontend (Static UI + NGINX Reverse Proxy)
You have two common choices for frontend-to-backend connectivity:
1) Frontend calls the backend via a full URL like https://api.example.com.
2) Frontend calls a relative path like /api, and the frontend server proxies it to the backend.
For a first Kubernetes project, I prefer option 2 because it avoids the ‘what is my API URL in each environment?’ problem. The browser hits the frontend, the frontend container proxies /api to the backend Service name inside the cluster, and everything stays predictable.
Frontend page: frontend/index.html
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
.row { display: flex; gap: 0.75rem; margin: 0.75rem 0; }
input, textarea { width: 100%; padding: 0.6rem; }
button { padding: 0.6rem 1rem; cursor: pointer; }
.note { border: 1px solid #ddd; padding: 0.75rem; margin: 0.75rem 0; border-radius: 8px; }
.muted { color: #555; font-size: 0.95rem; }
.error { color: #b00020; }
code { background: #f6f6f6; padding: 0.1rem 0.25rem; border-radius: 4px; }
Notes
Frontend served by NGINX. API routed through /api.
Frontend logic: frontend/app.js
const statusEl = document.getElementById(‘status‘);
const errorEl = document.getElementById(‘error‘);
const notesEl = document.getElementById(‘notes‘);
function setStatus(msg) {
statusEl.textContent = msg;
}
function setError(msg) {
errorEl.textContent = msg;
}
function escapeHtml(str) {
return String(str)
.replaceAll(‘&‘, ‘&‘)
.replaceAll(‘<', '<')
.replaceAll(‘>‘, ‘>‘)
.replaceAll("‘", ‘'‘);
}
function renderNotes(notes) {
notesEl.innerHTML = ‘‘;
for (const n of notes) {
const div = document.createElement(‘div‘);
div.className = ‘note‘;
div.innerHTML = ${escapeHtml(n.title)}
;
notesEl.appendChild(div);
}
}
async function fetchNotes() {
setError(‘‘);
setStatus(‘Loading…‘);
const res = await fetch(‘/api/notes‘);
if (!res.ok) {
const txt = await res.text();
throw new Error(Failed to load notes: ${res.status} ${txt});
}
const data = await res.json();
renderNotes(data.notes || []);
setStatus(‘‘);
}
async function createNote() {
setError(‘‘);
setStatus(‘Saving…‘);
const title = document.getElementById(‘title‘).value.trim();
const body = document.getElementById(‘body‘).value.trim();
const res = await fetch(‘/api/notes‘, {
method: ‘POST‘,
headers: { ‘Content-Type‘: ‘application/json‘ },
body: JSON.stringify({ title, body })
});
if (!res.ok) {
const txt = await res.text();
throw new Error(Failed to create note: ${res.status} ${txt});
}
document.getElementById(‘title‘).value = ‘‘;
document.getElementById(‘body‘).value = ‘‘;
await fetchNotes();
setStatus(‘‘);
}
document.getElementById(‘refresh‘).addEventListener(‘click‘, () => {
fetchNotes().catch(err => setError(err.message));
});
document.getElementById(‘create‘).addEventListener(‘click‘, () => {
createNote().catch(err => setError(err.message));
});
fetchNotes().catch(err => setError(err.message));
I’m escaping HTML because real apps eventually render user input. If you skip this in a tutorial, someone will copy it into a real system and ship an XSS hole.
NGINX config: frontend/nginx.conf
This is the key: /api is proxied to the backend Service name.
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxyhttpversion 1.1;
proxysetheader Host $host;
proxysetheader X-Real-IP $remote_addr;
proxysetheader X-Forwarded-For $proxyaddxforwardedfor;
# Service DNS name inside the cluster:
proxy_pass http://backend:5000/api/;
}
}
Frontend container image: frontend/Dockerfile
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
COPY app.js /usr/share/nginx/html/app.js
EXPOSE 8080
I’m serving on 8080 so I don’t need privileged ports. Kubernetes doesn’t require it, but it’s a habit that makes later hardening easier.
Phase 1 — Building and Publishing Images (Registry and Tags That Don’t Lie)
Kubernetes pulls images from a registry. That means you must build images and push them somewhere your cluster can access.
I recommend using a tag that identifies the exact build you deployed. In 2026, most teams tag images with a Git commit SHA and optionally a semver tag for releases.
For a quick tutorial run, you can still use latest, but be aware of the trap: Kubernetes may not pull a newer latest image unless you set imagePullPolicy: Always or change the tag.
Build and push
From the project root:
# Replace with your registry path
REGISTRY_USER=‘your-username‘
# Backend
docker build -t ${REGISTRY_USER}/notes-backend:0.1.0 ./backend
docker push ${REGISTRY_USER}/notes-backend:0.1.0
# Frontend
docker build -t ${REGISTRY_USER}/notes-frontend:0.1.0 ./frontend
docker push ${REGISTRY_USER}/notes-frontend:0.1.0
If your cluster can’t pull private images, either make the repo public for the tutorial or configure an imagePullSecret.
Quick sanity checks
I like to run at least the backend locally once:
docker run –rm -p 5000:5000 ${REGISTRY_USER}/notes-backend:0.1.0
The frontend container is intentionally ‘in-cluster shaped’ because its NGINX config points to the backend Service name. Don’t be surprised if it can’t fetch notes outside Kubernetes without tweaks.
Phase 2 — Orchestrating with Kubernetes: Deployments and Services
Now the fun part: deploy to Kubernetes.
I define everything in YAML because it’s auditable, repeatable, and works well with GitOps (Argo CD / Flux) if you adopt that later.
Namespace: k8s/00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: notes
A namespace is a clean boundary. It keeps your resources grouped and makes cleanup as simple as deleting one namespace.
Backend Deployment + Service: k8s/10-backend.yaml
Replace the image name with your registry path.
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: notes
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
– name: backend
image: your-username/notes-backend:0.1.0
ports:
– containerPort: 5000
env:
– name: PORT
value: ‘5000‘
– name: STARTUPDELAYSECONDS
value: ‘0‘
readinessProbe:
httpGet:
path: /readyz
port: 5000
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
—
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: notes
spec:
selector:
app: backend
ports:
– name: http
port: 5000
targetPort: 5000
type: ClusterIP
Why I set probes and resources even in a tutorial:
- Readiness prevents sending traffic to a pod that hasn’t finished booting.
- Liveness catches ‘stuck’ processes and restarts them.
- Requests/limits keep one noisy container from starving the node.
Frontend Deployment + Service: k8s/20-frontend.yaml
Replace the image name with your registry path.
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: notes
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
– name: frontend
image: your-username/notes-frontend:0.1.0
ports:
– containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
—
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: notes
spec:
selector:
app: frontend
ports:
– name: http
port: 80
targetPort: 8080
type: ClusterIP
Notice what I did there: the Service port is 80 even though the container listens on 8080. That’s not required, but it often makes your Ingress and your mental model cleaner (the service is ‘http on 80’).
Phase 2 — Exposing the Frontend (Ingress vs NodePort vs LoadBalancer)
Inside the cluster, everything talks via Services. Outside the cluster, you need an entry point.
You have three common approaches:
Best for
Cons
—
—
Most real setups
Requires an Ingress controller
Quick labs / bare metal
Exposes high port on every node; clunkier UX
Cloud clusters
Needs a cloud LB integration (or MetalLB)For a tutorial that’s still “how teams do it,” I prefer Ingress, but I’ll show a port-forward fallback so you’re not blocked.
Ingress: k8s/30-ingress.yaml
This assumes you have an Ingress controller installed (like NGINX Ingress). If you’re on Minikube, there’s a one-command addon. On kubeadm, you typically install a controller via a manifest or Helm.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notes
namespace: notes
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: ‘false‘
spec:
ingressClassName: nginx
rules:
– host: notes.local
http:
paths:
– path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
A couple practical notes:
- I’m using a host (notes.local) because hosts become important the moment you have more than one service.
- I’m not enabling TLS in the main tutorial to keep the first successful browser load fast. Later I’ll show how I add TLS.
If you don’t have Ingress yet (Minikube)
Enable the controller:
minikube addons enable ingress
Then you need to make notes.local resolve to your Minikube IP. Get the IP:
minikube ip
Add an entry in your hosts file mapping that IP to notes.local. Then you can browse http://notes.local.
If you don’t have Ingress yet (kubeadm cluster)
On kubeadm, you need to install an Ingress controller. The key detail: whatever controller you choose must match the ingressClassName you used (nginx in this example).
Also, on bare metal you may not have a cloud LoadBalancer, so you either:
- expose the Ingress controller with NodePort, or
- install MetalLB so Services of type LoadBalancer get an IP.
For the first pass, NodePort is fine; MetalLB is the “do it properly on bare metal” follow-up.
Port-forward fallback (works anywhere)
If you want zero infrastructure dependencies, port-forward the frontend service:
kubectl -n notes port-forward svc/frontend 8080:80
Then browse http://localhost:8080.
This is also a great debugging trick: it lets you verify the app before you diagnose Ingress issues.
Phase 2 — Applying the Manifests and Verifying Everything
From the project root:
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/10-backend.yaml
kubectl apply -f k8s/20-frontend.yaml
kubectl apply -f k8s/30-ingress.yaml
Now watch the rollouts:
kubectl -n notes get deploy
kubectl -n notes rollout status deploy/backend
kubectl -n notes rollout status deploy/frontend
Get pods and services:
kubectl -n notes get pods -o wide
kubectl -n notes get svc
kubectl -n notes get ingress
What “healthy” looks like
When it’s working, you should see:
- backend pods in Running state, READY 1/1
- frontend pods in Running state, READY 1/1
- services created
- ingress created (and, depending on controller, an address assigned)
If something is pending, I don’t guess—I inspect events. This command is my first reflex:
kubectl -n notes describe pod
Scroll to Events. That’s where Kubernetes explains what it’s waiting for.
Phase 2 — Testing the App from Inside the Cluster
Before I even try the browser, I confirm service-to-service connectivity from inside the cluster. This step catches 80% of “it works locally but not in Kubernetes.”
Run a temporary curl pod:
kubectl -n notes run tmp-curl –rm -it –image=curlimages/curl:8.11.1 –restart=Never — sh
Inside that shell:
curl -sS http://backend:5000/healthz
curl -sS http://backend:5000/api/notes
Then check the frontend service:
curl -sS -I http://frontend/
curl -sS http://frontend/api/notes
If http://frontend/api/notes works, your proxy path is correct, your Service discovery is correct, and your backend is reachable.
This also gives you a clean debugging split:
- If internal curl fails, the issue is in Deployments/Services/DNS.
- If internal curl works but browser fails, the issue is Ingress/exposure/DNS on your laptop.
Phase 3 — Deployment Workflow That Doesn’t Lie (Tags, Rollouts, and Rollbacks)
Once the first deploy works, the next thing teams do is change code. Kubernetes isn’t hard because it’s complex; it’s hard because you must be disciplined about “what image is running right now.”
The mistake I see constantly
- Developer builds a new image with the same tag (often latest).
- They run kubectl apply.
- Nothing changes, because the node already has that tag cached.
You can band-aid this with imagePullPolicy: Always, but that’s not a real release workflow. A real workflow changes the image reference.
A practical tagging strategy for this tutorial
- Use semver for human readability: 0.1.0, 0.1.1
- Optionally add git SHA tags if you want precise traceability
Example:
docker build -t ${REGISTRY_USER}/notes-backend:0.1.1 ./backend
docker push ${REGISTRY_USER}/notes-backend:0.1.1
Then update the Deployment image:
kubectl -n notes set image deploy/backend backend=${REGISTRY_USER}/notes-backend:0.1.1
Watch the rollout:
kubectl -n notes rollout status deploy/backend
Rollback is not optional
If you ship enough, you will ship a bad build. The difference between calm teams and chaotic teams is whether rollback is muscle memory.
To roll back the backend deployment:
kubectl -n notes rollout undo deploy/backend
To see rollout history:
kubectl -n notes rollout history deploy/backend
I keep this close because when something breaks, you want fewer moving parts, not more.
Phase 3 — Readiness, Liveness, and What They Actually Protect You From
Probes aren’t checkbox features; they’re safety rails. But they’re easy to misuse, especially if you copy/paste them.
Readiness probe: controlling traffic
Readiness answers: “Should this pod receive traffic right now?”
In our backend, /readyz returns 503 until STARTUPDELAYSECONDS has passed. That lets you simulate startup work like:
- loading ML models
- warming caches
- running migrations (though I prefer running migrations as a Job)
- waiting for a dependency
Try this in your backend Deployment:
– name: STARTUPDELAYSECONDS
value: ‘20‘
Apply it and watch:
kubectl -n notes apply -f k8s/10-backend.yaml
kubectl -n notes get pods -w
You’ll see pods Running but not Ready for a while. During that time, the Service won’t route traffic to them. That’s the point.
Liveness probe: recovering from “stuck”
Liveness answers: “Is the process still healthy?”
If the liveness probe fails repeatedly, Kubernetes restarts the container. That helps with:
- deadlocks
- stuck worker loops
- memory leaks that eventually wedge the app
But liveness can also harm you if it restarts a slow-starting app. That’s why I set a bigger initialDelaySeconds for liveness than readiness.
Practical rule I use
- Make readiness strict and early.
- Make liveness conservative and late.
If you only add one probe in a tutorial, make it readiness. It prevents serving broken traffic during rollouts.
Phase 3 — Resource Requests and Limits: Small Numbers, Real Benefits
Requests and limits are where Kubernetes starts behaving like a reliable scheduler, not just a container runner.
- Requests reserve capacity. The scheduler uses them to decide where a pod can fit.
- Limits cap usage. The runtime enforces them.
If you skip requests entirely, Kubernetes is forced to guess how many pods can fit on a node. That guess is usually wrong in production.
What happens when you set CPU limits too low
CPU limits can cause throttling. Throttling isn’t always obvious, and it’s one reason latency spikes happen under load.
In this tutorial, I kept CPU limits modest. In real services, I often set requests but omit CPU limits until I have performance data. Memory limits are more important because an OOM kill is often better than a node-wide meltdown.
How I tune in practice
1) Start with conservative requests.
2) Observe real usage (metrics).
3) Raise requests until p95 latency is stable under load.
4) Set limits only if you need enforcement.
Phase 4 — Common Pitfalls (and How I Debug Them Without Guessing)
This is the section that saves time. Kubernetes failures are usually predictable.
Pitfall 1: ImagePullBackOff
Symptoms:
- Pods stuck in ImagePullBackOff
Causes:
- wrong image name or tag
- private registry requires auth
- cluster nodes can’t reach registry
Fix path:
1) Describe the pod:
kubectl -n notes describe pod
2) If it’s a private registry, create an imagePullSecret:
kubectl -n notes create secret docker-registry regcred \
–docker-server= \
–docker-username= \
–docker-password= \
–docker-email=
3) Reference it in your Deployment:
spec:
template:
spec:
imagePullSecrets:
– name: regcred
Pitfall 2: Service works, but frontend can’t reach backend
If the browser hits /api and it fails, the issue is almost always the proxy path.
Checklist:
- Is NGINX proxying /api/ (with trailing slash) correctly?
- Does proxy_pass include /api/ so paths don’t double up?
- Is the backend Service name correct (backend)?
- Is the backend listening on the expected port (5000)?
My preferred test:
- curl from inside the frontend pod:
kubectl -n notes exec -it deploy/frontend — sh
Then:
wget -qO- http://backend:5000/healthz
wget -qO- http://backend:5000/api/notes
If that works, the issue is NGINX config. If it doesn’t, it’s Service/DNS.
Pitfall 3: It works on Minikube, fails on kubeadm
Typical causes:
- Ingress isn’t installed on kubeadm
- no LoadBalancer integration (needs MetalLB)
- firewall/security groups blocking NodePort range
The fastest workaround is always port-forward:
kubectl -n notes port-forward svc/frontend 8080:80
Get the app working via port-forward first. Then fix Ingress.
Pitfall 4: Pods restart every few seconds
Look for:
- CrashLoopBackOff
Then:
kubectl -n notes logs –previous
Common causes:
- app binds to 127.0.0.1 instead of 0.0.0.0
- wrong command
- missing env var
- liveness probe is too aggressive
Pitfall 5: Readiness never becomes ready
If /readyz is always 503, confirm:
- STARTUPDELAYSECONDS is what you expect
- the app’s concept of readiness is correct
In real systems, readiness often depends on dependencies. Example: backend isn’t ready until it can reach the database.
I’m careful here because “database is down” and “pod is not ready” can cause a full outage if every pod blocks readiness forever. If a dependency is optional, don’t gate readiness on it.
Phase 4 — Configuration: Environment Variables, ConfigMaps, and Secrets
Even with a tiny app, you quickly need configuration that differs per environment:
- log level
- feature flags
- API timeouts
- external service URLs
I avoid baking environment configuration into the image. Images should be portable; configuration should be injected.
Use a ConfigMap for non-sensitive config
Example (optional): k8s/15-backend-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: backend-config
namespace: notes
data:
STARTUPDELAYSECONDS: ‘0‘
PORT: ‘5000‘
Then reference it:
envFrom:
– configMapRef:
name: backend-config
Use a Secret for sensitive config
Even though this tutorial doesn’t need a password, I still want you to see the pattern.
Example (optional):
kubectl -n notes create secret generic backend-secrets \
–from-literal=API_KEY=‘replace-me‘
Then reference:
env:
– name: API_KEY
valueFrom:
secretKeyRef:
name: backend-secrets
key: API_KEY
Practical rule: treat Secrets as “sensitive but not magical.” They’re base64-encoded, not encrypted by default. In production I combine Secrets with encryption at rest and tight RBAC.
Phase 5 — Scaling and Availability: What Changes When You Add Load
This app is small, but it demonstrates the core scaling primitives.
Scale the backend
Manual scaling:
kubectl -n notes scale deploy/backend –replicas=6
Now watch endpoints:
kubectl -n notes get endpoints backend
You’ll see multiple pod IPs. That’s why Services matter: clients talk to the Service name, not individual pods.
Horizontal Pod Autoscaler (HPA)
Autoscaling needs metrics. Many clusters use metrics-server for basic CPU/memory metrics.
If metrics are available, you can create an HPA like:
kubectl -n notes autoscale deploy/backend –cpu-percent=60 –min=3 –max=10
Then:
kubectl -n notes get hpa
I treat HPA as a second step, not the first step. The first step is always stable readiness and correct resource requests. Autoscaling can’t fix a broken probe.
Disruption and rolling updates
Rolling updates are why Deployments exist. With readiness probes, rolling updates become safe:
- New pods start.
- They become Ready.
- Traffic shifts.
- Old pods terminate.
Without readiness probes, a rollout can send users to half-started pods, which looks like random failures.
Phase 6 — Production Considerations (The Stuff That Bites Later)
You can deploy this tutorial app and it will work. But “works” isn’t the same as “operates well.” Here are the additions I typically make next.
Logging
In Kubernetes, stdout/stderr is the default contract. If your app logs to files inside the container, those logs die with the pod unless you set up volumes.
I keep it simple:
- app logs to stdout
- cluster ships logs via an agent (Fluent Bit, Vector, etc.)
Monitoring
If you can’t measure it, you can’t debug it.
At minimum, I want:
- request rate, errors, latency (RED metrics)
- container CPU/memory
- pod restarts
TLS
Ingress is usually where TLS terminates.
In a real cluster, I add:
- cert-manager
- an Issuer (Let’s Encrypt or internal CA)
- TLS section on the Ingress
I skipped it in the main path because TLS should be an “after it works” improvement, not a prerequisite.
Security hardening (in small steps)
The most valuable early wins:
- Run as non-root where possible.
- Use read-only root filesystem for the frontend container (often possible).
- Drop Linux capabilities.
- Add NetworkPolicies so only the frontend can talk to the backend.
You don’t have to do all of this today. But you should know where “secure by default” actually lives.
Stateful storage (why we didn’t do it here)
Our notes are in memory, so they vanish if a pod restarts. That’s intentional.
I like first Kubernetes projects to be stateless so you learn:
- rolling updates
- service discovery
- probes
- exposure
Then, the next project adds:
- a database (Postgres)
- PersistentVolumes
- backups
- migration Jobs
Kubernetes is a lot easier when you learn it in layers.
Phase 7 — Alternative Approaches (So You Know What You’re Choosing)
This tutorial uses plain YAML and kubectl apply. That’s a good baseline.
Here are two common evolutions:
Kustomize
If you want dev/staging/prod overlays without a templating language, Kustomize is a great next step.
What changes:
- you define a base (common manifests)
- you define overlays that patch images, replicas, hosts, etc.
Helm
If you’re packaging reusable deployments or installing third-party apps, Helm is standard.
What changes:
- values.yaml controls environment differences
- charts provide templates + versioned releases
I still like teaching raw YAML first, because it makes Helm output readable instead of magical.
Phase 8 — Cleanup (So You Don’t Leave a Trail)
Delete the namespace to remove everything:
kubectl delete namespace notes
That’s one of the underrated joys of namespaces: you can cleanly reset the whole project.
What You Learned (and Why It Matters)
By the end of this project, you’ve deployed a real two-tier app and used the Kubernetes primitives teams rely on every day:
- Container images that are reproducible and registry-backed
- Deployments for self-healing and rolling updates
- Services for stable networking and discovery
- Probes for safe traffic management
- Ingress (or port-forward) for external access
More importantly, you’ve built a mental model: pods are replaceable, services are stable, and your job is to design the app so it tolerates that reality.
If you want the most valuable next upgrade, tell me which direction you care about:
- add a Postgres database with persistence and migrations
- add CI that builds and pushes images with git SHA tags
- add Ingress TLS with cert-manager
- add NetworkPolicies and non-root hardening
- add HPA + load testing so you can see scaling happen under pressure


