Deploying a Full-Stack Notes App on Kubernetes (Full Project Tutorial)

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

Notes

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)}

${escapeHtml(n.body || ‘‘)}

;

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:

Approach

Best for

Pros

Cons

Ingress

Most real setups

One place for routing, TLS termination, virtual hosts

Requires an Ingress controller

NodePort

Quick labs / bare metal

No extra controller required

Exposes high port on every node; clunkier UX

LoadBalancer

Cloud clusters

Easiest external IP story

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
Scroll to Top