Your First App on Kubernetes
The capstone: take the container you built earlier and give it a Deployment, a Service, configuration, storage, probes and an Ingress — then update it and roll it back.
The capstone: take the container you built earlier and give it a Deployment, a Service, configuration, storage, probes and an Ingress — then update it and roll it back.
Fifteen lessons of objects, each solving what the previous one left open. This one puts them together on the notes application from the Docker course: a Deployment behind a Service behind an Ingress, configured from a ConfigMap and a Secret, talking to a Postgres with real storage, with probes and resource requests — then updated, and rolled back.
Follow it end to end on your local cluster. By the finish you will have a directory of manifests you could apply to any cluster, and a checklist to hold new workloads against.
01 — configuration
A ConfigMap and a Secret. First, because everything else reads from them.
02 — the database, with storage
A PersistentVolumeClaim and a Deployment, plus a ClusterIP Service so the application can find it by name.
03 — the cache
Same shape, no storage. Redis loses its data on restart and that is the point of a cache.
04 — the application
A Deployment with probes and resource requests, and a Service in front of it.
05 — the way in
An Ingress, so a hostname outside the cluster reaches the Service inside it.
Then break it, update it, and roll it back
The three steps that turn a working deployment into one you trust.
The same image as the Docker capstone: a small notes API that stores
notes in Postgres, counts views in Redis, and exposes /health and a
/ready that checks both dependencies.
Nothing about the image changes. That is worth pausing on — the artefact you built with a Dockerfile is exactly what a cluster runs, and this whole lesson is about the layer above it.
Get the image into the cluster. Locally that means loading it, because a kind cluster cannot see your machine's Docker images:
And make a namespace, so the whole thing can be removed in one command:
01-config.yaml. Ordinary settings in a ConfigMap, the password in a
Secret:
In a real deployment that Secret is created out of band or by a tool like Sealed Secrets — it is written inline here so the lesson is followable, and a file like this one never belongs in a repository.
02-postgres.yaml. One replica, its own claim, a probe that reports
readiness honestly:
Two deliberate choices. strategy: Recreate because two Postgres
processes must never hold one data directory — a rolling update would
briefly run both. And PGDATA in a subdirectory, because a freshly
provisioned volume is not always empty.
03-cache.yaml. Stateless here, so no claim:
04-api.yaml. This is where every lesson shows up at once:
Read it against the course. Three replicas so a node failure is
survivable, spread across nodes. maxUnavailable: 0 so capacity never
drops during a release. Configuration from the ConfigMap and Secret,
with no values in the template. Readiness on /ready, which checks the
database and cache; liveness on /health, which checks only the
process. A startup probe so a slow first connection is not mistaken for
a hang. A preStop pause so endpoint removal propagates before shutdown.
Requests for the scheduler and a memory limit for safety.
Note the Service's targetPort: http — the container port's name, so
moving the port is one edit.
05-ingress.yaml, assuming the ingress-nginx controller from the
Ingress lesson:
Then check, in the order that isolates a problem to one layer:
Now exercise it properly:
This is the part worth not skipping. Three experiments, each proving one promise the course made.
Self-healing.
Three replacements appear and the Service keeps answering, because readiness holds new pods out of the endpoints until they can serve.
Durability.
The note you created is still there afterwards. It was never in the pod — it was in a volume the pod merely mounted.
Nothing happens.
The value changes, kubectl get configmap confirms it, and
the running pods keep the old one until you restart them.
Self-healing. Delete every application pod:
Three replacements appear and the Service keeps answering, because readiness holds new pods out of the endpoints until they can serve.
Durability. Destroy the database pod and check your note:
The note is still there. It was never in the pod.
Configuration. Change the log level and watch nothing happen:
Environment variables are read once at start-up. The restart is the mechanism, and there is no substitute for it.
Change something in the application, build a new tag, load it, and roll it out:
Watch it happen, one pod at a time:
Then rehearse the thing you will one day need in a hurry:
And rehearse a release that fails, which is the more valuable drill:
The new pod never became ready, so it never took traffic, and
maxUnavailable: 0 meant the old pods were never removed. A broken
release was a non-event.
Or, for the whole cluster:
Hold any new workload against this. Each line is one lesson.
You can deploy a real multi-service application to a cluster, keep its data, configure it from outside, get traffic to it, ship a new version without dropping a request, and put the old one back. That is the foundation, and it is most of what day-to-day work needs.
Three directions from here, in the order they usually become urgent.
Templating, because copying these manifests for a second environment
does not scale — Kustomize is built into kubectl and is the shorter
path; Helm is the packaging ecosystem. Observability, because
kubectl logs does not survive a pod being replaced — Prometheus for
metrics, Loki or a hosted service for logs. And GitOps, where a
controller like Argo CD or Flux applies your repository to the cluster
continuously, so the cluster and the repository cannot drift apart.
Beyond those: StatefulSets and operators for stateful workloads, NetworkPolicies and RBAC for real isolation, the Gateway API as Ingress's successor, and autoscaling both pods and nodes.
Before any of it, deploy something of your own with the checklist above open. The gap between following a lesson and writing manifests for your own application from scratch is where this actually becomes a skill.
NAME READY STATUS RESTARTS AGE
api-6c9f7d4b58-7xk2m 1/1 Running 0 40s
api-6c9f7d4b58-mn4pq 1/1 Running 0 40s
api-6c9f7d4b58-vz8lt 1/1 Running 0 40s
cache-5f8d9c7b4-jd5rw 1/1 Running 0 50s
postgres-7b6c5d8f9-kp2nx 1/1 Running 0 50sWorkload
[ ] a Deployment, not a bare pod
[ ] replicas > 1 for anything that must stay up
[ ] labels stable, and the selector free of any version
[ ] topologySpreadConstraints so replicas are not on one node
[ ] resource requests set (CPU and memory)
[ ] a memory limit set, above the observed peak
[ ] image tagged with a real version, never :latest
Health
[ ] a readiness probe that checks dependencies
[ ] a liveness probe that checks only the process
[ ] a startup probe if it starts slowly
[ ] terminationGracePeriodSeconds and a preStop pause
[ ] the container command in bracket form, so SIGTERM arrives
Configuration and data
[ ] no configuration or credential in the image or the template
[ ] Secrets separate from ConfigMaps
[ ] volumes mounted readOnly where nothing writes
[ ] a PVC for every piece of state, and a plan for backups
[ ] a rollout restart after changing a ConfigMap read as env vars
Traffic and release
[ ] a Service, and its Endpoints confirmed non-empty
[ ] an Ingress pointing at the Service name and Service port
[ ] maxUnavailable: 0 where capacity matters
[ ] change-cause recorded on each revision
[ ] a rollback rehearsed before it is neededdocker build -t notes-api:0.1.0 .
kind load docker-image notes-api:0.1.0 --name learningkubectl create namespace notes
kubectl config set-context --current --namespace=notesapiVersion: v1
kind: ConfigMap
metadata:
name: notes-config
data:
LOG_LEVEL: info
REDIS_URL: redis://cache:6379/0
DATABASE_HOST: postgres
DATABASE_PORT: '5432'
DATABASE_NAME: notes
DATABASE_USER: postgres
---
apiVersion: v1
kind: Secret
metadata:
name: notes-secrets
type: Opaque
stringData:
DATABASE_PASSWORD: devpassapiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 4Gi
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
strategy:
type: Recreate # one writer, one volume
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_DB
value: notes
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: notes-secrets
key: DATABASE_PASSWORD
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ['pg_isready', '-U', 'postgres', '-d', 'notes']
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512MiapiVersion: v1
kind: Service
metadata:
name: cache
spec:
selector:
app: cache
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cache
spec:
replicas: 1
selector:
matchLabels:
app: cache
template:
metadata:
labels:
app: cache
spec:
containers:
- name: redis
image: redis:7-alpine
readinessProbe:
exec:
command: ['redis-cli', 'ping']
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128MiapiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
annotations:
kubernetes.io/change-cause: 'notes-api 0.1.0: initial release'
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
terminationGracePeriodSeconds: 30
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: api
containers:
- name: api
image: notes-api:0.1.0
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: notes-config
- secretRef:
name: notes-secrets
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 3
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 30
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 5']
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 384MiapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: notes
spec:
ingressClassName: nginx
rules:
- host: notes.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80kubectl apply -f k8s/
kubectl get all
kubectl rollout status deployment/api# 1. Are the pods running and ready?
kubectl get pods# 2. Does the Service have endpoints?
kubectl describe service api | grep Endpoints
# 3. Does it answer from inside the cluster?
kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- \
wget -qO- http://api/health
# 4. Does it answer from outside?
curl -H "Host: notes.localhost" http://localhost/healthcurl -H "Host: notes.localhost" -X POST http://localhost/notes \
-H 'Content-Type: application/json' \
-d '{"body":"kubernetes keeps this running"}'
curl -H "Host: notes.localhost" http://localhost/noteskubectl get pods --watch
# in another terminal:
kubectl delete pods -l app=apikubectl delete pod -l app=postgres
kubectl rollout status deployment/postgres
curl -H "Host: notes.localhost" http://localhost/noteskubectl patch configmap notes-config \
-p '{"data":{"LOG_LEVEL":"debug"}}'
kubectl exec deploy/api -- env | grep LOG_LEVEL # still info
kubectl rollout restart deployment/api
kubectl exec deploy/api -- env | grep LOG_LEVEL # now debugdocker build -t notes-api:0.2.0 .
kind load docker-image notes-api:0.2.0 --name learning
kubectl set image deployment/api api=notes-api:0.2.0
kubectl annotate deployment/api \
kubernetes.io/change-cause='notes-api 0.2.0: search endpoint'
kubectl rollout status deployment/apikubectl get replicasets --watchkubectl rollout history deployment/api
kubectl rollout undo deployment/api
kubectl rollout status deployment/apikubectl set image deployment/api api=notes-api:9.9.9 # no such tag
kubectl get pods # new pod ImagePullBackOff
curl -H "Host: notes.localhost" http://localhost/health # still serving
kubectl rollout undo deployment/apikubectl delete namespace notes # everything, including the PVCkind delete cluster --name learning# Getting a local image into a local cluster
kind load docker-image notes-api:0.1.0 --name learning
minikube image load notes-api:0.1.0
# Apply and confirm
kubectl create namespace notes
kubectl config set-context --current --namespace=notes
kubectl apply -f k8s/
kubectl get all
kubectl rollout status deployment/api
# Verify, layer by layer
kubectl get pods # 1. ready?
kubectl describe service api | grep Endpoints # 2. endpoints?
kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- \
wget -qO- http://api/health # 3. inside?
curl -H "Host: notes.localhost" http://localhost/health # 4. outside?
# The three drills
kubectl delete pods -l app=api # self-healing
kubectl delete pod -l app=postgres # durability
kubectl patch configmap notes-config \
-p '{"data":{"LOG_LEVEL":"debug"}}' \
&& kubectl rollout restart deployment/api # config
# Release and reverse
kubectl set image deployment/api api=notes-api:0.2.0
kubectl annotate deployment/api \
kubernetes.io/change-cause='0.2.0: search endpoint'
kubectl rollout status deployment/api
kubectl rollout history deployment/api
kubectl rollout undo deployment/api
# Clean up
kubectl delete namespace notes
kind delete cluster --name learning