Lesson 08 of 32 · Level 1 — Foundations
Checkpoint: deploy a 3-tier app
Put Level 1 together: deploy a real three-tier app (web → app → database) with Deployments, Services, a ConfigMap, a Secret and persistent storage, then break it and fix it.
What you'll build
A small but real three-tier application in its own namespace, shop:
browser
│ kubectl port-forward
▼
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ web (nginx) │──►│ adminer (app) │──►│ postgres (data) │──► PVC db-data (1Gi)
│ 2 replicas │ │ 2 replicas │ │ 1 replica │
└──────────────┘ └────────────────┘ └──────────────────┘
Service: web Service: adminer Service: postgres
ConfigMap: nginx env: default server Secret: db-cred
- web is nginx acting as a reverse proxy. Its config comes from a ConfigMap.
- adminer is a small web app for managing databases. It talks to postgres by Service name.
- postgres stores data on a PersistentVolumeClaim, with credentials from a Secret.
It's a shop. web is the front door and the greeter. adminer is the shop assistant who takes your request. postgres is the storeroom with a notebook of everything that's been sold. The notebook is kept in a locker (the PVC), so even if the storeroom worker goes home, tomorrow's worker opens the same locker.
Step 1: namespace, Secret and storage
Save everything below into one file, shop.yaml, section by section. The --- lines separate objects.
apiVersion: v1
kind: Namespace
metadata:
name: shop
---
apiVersion: v1
kind: Secret
metadata:
name: db-cred
namespace: shop
type: Opaque
stringData: # plain text here; stored base64-encoded
POSTGRES_USER: shop
POSTGRES_PASSWORD: change-me-please
POSTGRES_DB: shop
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data
namespace: shop
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Step 2: the database tier
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: shop
spec:
replicas: 1
strategy:
type: Recreate # stop the old pod before starting a new one (RWO disk!)
selector:
matchLabels: { app: postgres }
template:
metadata:
labels: { app: postgres }
spec:
containers:
- name: postgres
image: postgres:16
envFrom:
- secretRef: { name: db-cred }
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
periodSeconds: 5
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim: { claimName: db-data }
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: shop
spec:
selector: { app: postgres }
ports:
- port: 5432
Step 3: the app tier
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: adminer
namespace: shop
spec:
replicas: 2
selector:
matchLabels: { app: adminer }
template:
metadata:
labels: { app: adminer }
spec:
containers:
- name: adminer
image: adminer:4
env:
- name: ADMINER_DEFAULT_SERVER
value: postgres # the Service name from step 2
ports:
- containerPort: 8080
readinessProbe:
httpGet: { path: /, port: 8080 }
---
apiVersion: v1
kind: Service
metadata:
name: adminer
namespace: shop
spec:
selector: { app: adminer }
sessionAffinity: ClientIP # keep each client on the same adminer pod (see below)
ports:
- port: 8080
Why sessionAffinity?
Adminer stores your login session in the pod's memory and disk. With two replicas, a normal Service could send your next click to the other pod, which has never heard of you, and you'd be logged out at random. sessionAffinity: ClientIP pins each client to one pod. It's a quick fix; the proper fix for real apps is to keep sessions in a shared store (such as Redis or a database) so any replica can serve any request. Spotting hidden state like this is a core platform skill.
Step 4: the web tier
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-conf
namespace: shop
data:
default.conf: |
server {
listen 80;
location /healthz { return 200 "ok\n"; }
location / {
proxy_pass http://adminer:8080;
proxy_set_header Host $host;
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: shop
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
readinessProbe:
httpGet: { path: /healthz, port: 80 }
volumeMounts:
- name: conf
mountPath: /etc/nginx/conf.d
volumes:
- name: conf
configMap: { name: nginx-conf }
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: shop
spec:
selector: { app: web }
ports:
- port: 80
Step 5: deploy and verify
$ kubectl apply -f shop.yaml
namespace/shop created
secret/db-cred created
persistentvolumeclaim/db-data created
deployment.apps/postgres created
service/postgres created
deployment.apps/adminer created
service/adminer created
configmap/nginx-conf created
deployment.apps/web created
service/web created
$ kubectl get pods -n shop -w
Wait until all five pods show 1/1 Running (the first postgres start takes about 20 seconds). Then:
$ kubectl get deploy,svc,pvc -n shop
$ kubectl -n shop port-forward svc/web 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Open http://localhost:8080. In the Adminer login, choose System: PostgreSQL. The server is already postgres. Log in as user shop with the password from the Secret, database shop.
Now add some data. From a second terminal:
$ kubectl -n shop exec deploy/postgres -- psql -U shop -d shop -c "CREATE TABLE orders (id serial PRIMARY KEY, item text);"
CREATE TABLE
$ kubectl -n shop exec deploy/postgres -- psql -U shop -d shop -c "INSERT INTO orders (item) VALUES ('pizza'), ('pasta');"
INSERT 0 2
Refresh Adminer: the orders table is there. You've just built and connected three tiers. 🎉
If a pod isn't Ready
- postgres Pending →
kubectl -n shop describe pvc db-data(storage) anddescribe pod(scheduling). - web CrashLoopBackOff →
kubectl -n shop logs deploy/web.host not found in upstream "adminer"means nginx started before theadminerService existed. Apply again, or check the Service name. - 502 Bad Gateway in the browser → does the
adminerService have endpoints?
Challenges
Try each one before opening the solution.
Challenge 1: prove the data survives
Delete the database pod. When the new one is Ready, are your two orders still there?
Solution
$ kubectl -n shop delete pod -l app=postgres
$ kubectl -n shop get pods -l app=postgres -w
$ kubectl -n shop exec deploy/postgres -- psql -U shop -d shop -c "SELECT * FROM orders;"
id | item
----+-------
1 | pizza
2 | pasta
The Deployment created a new pod, which mounted the same PVC. That's why the data is still there.
Challenge 2: scale the app tier
Scale adminer to 4 replicas. How many IPs are behind the adminer Service now? Did anything in nginx need to change?
Solution
$ kubectl -n shop scale deployment adminer --replicas=4
$ kubectl -n shop get endpointslices -l kubernetes.io/service-name=adminer
Four endpoints. nginx still talks to the single name adminer; the Service spreads traffic across whatever pods are Ready. Nothing else changes, which is the point of Services.
Challenge 3: change configuration safely
Make nginx add a response header X-Served-By with the pod's hostname, then confirm it with curl -sI.
Solution
Edit the ConfigMap (kubectl -n shop edit configmap nginx-conf) and add this line inside location /:
add_header X-Served-By $hostname;
nginx only reads its config at start-up, so roll the pods:
$ kubectl -n shop rollout restart deployment/web
$ kubectl -n shop rollout status deployment/web
$ curl -sI http://localhost:8080 | grep -i x-served-by
X-Served-By: web-7c9d8b6f5-2kqzp
(Restart the port-forward if it disconnected during the rollout.) Run curl a few times: the hostname changes between the two web pods.
Challenge 4: an incident
A teammate runs this, then says "the site is down":
kubectl -n shop patch svc adminer -p '{"spec":{"selector":{"app":"admin"}}}'
Diagnose it from symptoms, as if you didn't know what they typed. What do you see at each layer, and what's the fix?
Solution
- Browser shows 502 Bad Gateway: nginx is up, but its upstream failed.
kubectl -n shop get pods: everything isRunningand1/1. Pods aren't the problem.kubectl -n shop describe svc adminershows Endpoints: <none> and Selector: app=admin.kubectl -n shop get pods --show-labels: the pods are labelledapp=adminer. The selector doesn't match.
Fix:
$ kubectl -n shop patch svc adminer -p '{"spec":{"selector":{"app":"adminer"}}}'
Prevention: manage manifests in Git and apply them through a pipeline or GitOps, so a hand-typed patch gets reviewed, or at least reverted automatically.
Going deeper: what we'd change for production
- Database: a single-replica Deployment is fine for learning. In production use a StatefulSet or, better, a Postgres operator (for example CloudNativePG) for replication, failover and backups.
- Secrets: don't keep a real password in YAML. Use External Secrets or Sealed Secrets, and rotate it.
- Traffic: replace port-forward with an Ingress (lesson 05) or Gateway plus TLS (lesson 19), and restrict who can talk to postgres with a NetworkPolicy.
- Resources: add CPU/memory requests and limits to every container, and a PodDisruptionBudget for web and adminer so node drains keep the site up.
- Packaging: turn
shop.yamlinto a Helm chart or Kustomize overlay per environment.
Clean up
$ kubectl delete namespace shop
namespace "shop" deleted
Deleting the namespace removes everything inside it, including the PVC (and, with the Delete reclaim policy, the data).
You've finished Level 1
You can now explain the architecture, run workloads with the right controller, connect them with Services and DNS, configure them with ConfigMaps and Secrets, keep data with PVCs, and debug the common failures between them. Level 2 — Operator moves from using a cluster to running one: building it with kubeadm, upgrades, etcd backups, scheduling and RBAC.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.