Kubernetes Administration — Level by Level›08 · Checkpoint: deploy a 3-tier app

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.

Beginner → Practitioner
Key wordsnamespace3-tier appRecreate strategysession affinitytroubleshooting

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) and describe pod (scheduling).
  • web CrashLoopBackOff → kubectl -n shop logs deploy/web. host not found in upstream "adminer" means nginx started before the adminer Service existed. Apply again, or check the Service name.
  • 502 Bad Gateway in the browser → does the adminer Service 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
  1. Browser shows 502 Bad Gateway: nginx is up, but its upstream failed.
  2. kubectl -n shop get pods: everything is Running and 1/1. Pods aren't the problem.
  3. kubectl -n shop describe svc adminer shows Endpoints: <none> and Selector: app=admin.
  4. kubectl -n shop get pods --show-labels: the pods are labelled app=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.yaml into 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.