Skip to content

Minikube

This activity puts into practice the concepts from the Container Orchestration lecture. You will start a local Kubernetes cluster with minikube, deploy a personalized web service using ConfigMaps, Secrets, Deployments, Services, Ingress, and a small StatefulSet with persistent storage, then watch the control loop heal and roll out your changes. By the end, you will have a personalized page reachable through Kubernetes on your laptop and a stateful workload whose data survives pod replacement.

  • A working Docker environment from the earlier Docker activity, or an equivalent local Docker Engine or Docker Desktop setup that already passes docker version
  • minikube installed before class using the official minikube start guide and verified with minikube version
  • kubectl installed before class using the official Kubernetes tools guide and verified with kubectl version --client
  • Enough free RAM to run Docker plus a small local cluster comfortably

Before you deploy anything, make the cluster itself visible. You will start minikube, enable the two addons this activity depends on, then inspect what Kubernetes placed on the node so you can tell the difference between cluster infrastructure and the application workloads you are about to add.

  1. Verify the three tools this activity assumes are already installed:

    Terminal window
    docker version
    minikube version
    kubectl version --client

    All three commands should print normal version information and return to the prompt. If docker version hangs or reports that the daemon is unavailable, start Docker before continuing.

  2. Create a clean working directory for the manifests you are about to write:

    Terminal window
    mkdir -p ~/cs312-minikube
    cd ~/cs312-minikube

    Keeping the manifests in one directory makes it easy to apply them again later, check them into Git, or reuse them in a different local cluster.

  3. Start a single-node Kubernetes cluster with the Docker driver:

    Terminal window
    minikube start --driver=docker

    This creates a local cluster and configures your kubectl context to point at it. The command prints several setup lines and usually takes a minute or two on the first run.

  4. Enable the ingress and metrics-server addons:

    Terminal window
    minikube addons enable ingress
    minikube addons enable metrics-server

    The ingress addon gives you a local HTTP entry point later in the activity. The metrics-server addon makes kubectl top and the Horizontal Pod Autoscaler possible.

  5. Inspect the cluster at a high level:

    Terminal window
    kubectl cluster-info
    kubectl get nodes -o wide
    kubectl get pods -A
    kubectl get ns
    kubectl get node -o 'jsonpath={.items[0].status.nodeInfo.containerRuntimeVersion}'; echo
    docker ps --filter name=minikube --format 'table {{.Names}}\t{{.Status}}'

    You should see one node in Ready state. The pod list will show a handful of system pods, but docker ps shows only one container on your host: the minikube node itself. Every pod runs inside that node rather than as a sibling host container. Minikube uses the Docker driver to create a node that looks like a real VM to Kubernetes, but from your host it is just a Docker container. The containerRuntimeVersion command shows which runtime Kubernetes sees inside that node. You can change it with minikube start --container-runtime=cri-o or --container-runtime=containerd if you want to see a different runtime in action.

    You may also see one or more pods in Completed status. In many setups, these are Jobs an addon ran once, such as ingress admission setup. They finished cleanly, and Kubernetes keeps them so you can read their logs. You will create your own Job later in this activity and see the same lifecycle.

  6. Look specifically at the system namespace:

    Terminal window
    kubectl get pods -n kube-system

    kube-system is where Kubernetes and addon components live. Your own application objects should not go here.

  7. Identify which pods are DaemonSet-managed:

    Terminal window
    kubectl get daemonset -A

    You should see at least kube-proxy in the list. A DaemonSet schedules exactly one Pod per matching node, which is why each DaemonSet shows one Pod in this single-node cluster but would show one Pod per node in a production cluster. Compare that with the Deployment-managed pods you saw above: those are placed wherever the scheduler finds capacity.


Now create the first pieces of desired state. A namespace scopes all the objects you are about to create to one named bucket, which makes cleanup at the end a single command. A ConfigMap and a Secret store the two values the web workload will render into its page.

  1. Create namespace.yaml:

    apiVersion: v1
    kind: Namespace
    metadata:
    name: cs312-minikube

    A namespace gives this activity its own naming scope. The name cs312-minikube will appear on every object you create from here on.

  2. Apply the namespace and make it your current default:

    Terminal window
    kubectl apply -f namespace.yaml
    kubectl config set-context --current --namespace=cs312-minikube
    kubectl config view --minify -o 'jsonpath={..namespace}'; echo

    The final command should print cs312-minikube. From now on, kubectl commands that use this current context and do not specify -n target that namespace until you change the context again. Kubernetes also automatically creates a default ServiceAccount in the new namespace; every Pod runs as some service account even when you do not specify one.

  3. Create web-config.yaml:

    apiVersion: v1
    kind: ConfigMap
    metadata:
    name: orchestra-web-config
    namespace: cs312-minikube
    data:
    message: |
    This page is coming from a Deployment behind a Service.
    ---
    apiVersion: v1
    kind: Secret
    metadata:
    name: orchestra-web-owner
    namespace: cs312-minikube
    type: Opaque
    stringData:
    owner: YOUR_ONID

    Replace YOUR_ONID with your own ONID or another short identifier before saving the file. The ConfigMap holds normal configuration text; the Secret uses Kubernetes’ secret-handling path inside the cluster, but this manifest file still contains the value in plain text on your machine until you remove or ignore it.

  4. Apply the ConfigMap and Secret:

    Terminal window
    kubectl apply -f web-config.yaml

    Kubernetes stores both objects immediately. Nothing is using them yet, but the desired state now exists in the API.

  5. Inspect what was stored:

    Terminal window
    kubectl get configmap,secret
    kubectl get secret orchestra-web-owner -o jsonpath='{.data.owner}'; echo
    kubectl get secret orchestra-web-owner -o jsonpath='{.data.owner}' | base64 --decode; echo

    The first secret lookup prints the base64-encoded form. The second decodes it back to the original text. A Kubernetes Secret is an access-control boundary, not encryption: anyone who can read the Secret object can decode the value in one command. The real protection comes from RBAC limiting which service accounts and users can read which Secrets, and from optional encryption at rest in etcd.


With namespace and configuration in place, you can now create the web workload itself. A Deployment keeps two replica Pods running at all times, an init container inside each Pod renders a personal HTML page from the ConfigMap and Secret, and a ClusterIP Service gives the group a stable address inside the cluster. You will also inspect how the Deployment, ReplicaSet, Pods, probes, and volumes fit together.

  1. Create web-deployment.yaml:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
    name: orchestra-web
    namespace: cs312-minikube
    spec:
    replicas: 2
    strategy:
    type: RollingUpdate
    rollingUpdate:
    maxUnavailable: 0
    maxSurge: 1
    selector:
    matchLabels:
    app: orchestra-web
    template:
    metadata:
    labels:
    app: orchestra-web
    spec:
    initContainers:
    - name: render-page
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
    OWNER="$(cat /secrets/owner)"
    MESSAGE="$(cat /config/message)"
    cat > /work/index.html <<EOF
    <!doctype html>
    <html>
    <head>
    <meta charset="utf-8">
    <title>CS 312 Orchestration</title>
    </head>
    <body>
    <h1>CS 312 Container Orchestration</h1>
    <p><strong>Owner:</strong> ${OWNER}</p>
    <p>${MESSAGE}</p>
    </body>
    </html>
    EOF
    volumeMounts:
    - name: rendered-site
    mountPath: /work
    - name: app-config
    mountPath: /config
    readOnly: true
    - name: app-secret
    mountPath: /secrets
    readOnly: true
    containers:
    - name: nginx
    image: nginx:1.27-alpine
    ports:
    - containerPort: 80
    resources:
    requests:
    cpu: 50m
    memory: 64Mi
    limits:
    cpu: 250m
    memory: 128Mi
    startupProbe:
    httpGet:
    path: /
    port: 80
    periodSeconds: 2
    failureThreshold: 15
    readinessProbe:
    httpGet:
    path: /
    port: 80
    periodSeconds: 5
    livenessProbe:
    httpGet:
    path: /
    port: 80
    periodSeconds: 10
    volumeMounts:
    - name: rendered-site
    mountPath: /usr/share/nginx/html
    readOnly: true
    volumes:
    - name: rendered-site
    emptyDir: {}
    - name: app-config
    configMap:
    name: orchestra-web-config
    - name: app-secret
    secret:
    secretName: orchestra-web-owner
    ---
    apiVersion: v1
    kind: Service
    metadata:
    name: orchestra-web
    namespace: cs312-minikube
    spec:
    type: ClusterIP
    selector:
    app: orchestra-web
    ports:
    - port: 80
    targetPort: 80

    The Pod in this Deployment has two phases: the init container renders the HTML file from the ConfigMap and Secret into an emptyDir volume, then the nginx container serves that file. An emptyDir is scratch space tied to the Pod’s lifetime: it is created when the Pod starts and deleted when it is removed, which is fine here because the init container regenerates it from durable config objects on each start. The rolling update strategy sets maxUnavailable: 0 (never drop below the desired replica count during an update) and maxSurge: 1 (allow one extra Pod above the desired count while the new version starts up).

  2. Apply the Deployment and wait for it to become ready:

    Terminal window
    kubectl apply -f web-deployment.yaml
    kubectl rollout status deployment/orchestra-web

    The rollout status should end with successfully rolled out. Kubernetes does not just start a container once here; it keeps reconciling until the desired number of ready replicas exists.

  3. Inspect the workload hierarchy:

    Terminal window
    kubectl get deployment,replicaset,pods,service -o wide

    You should see one Deployment, one ReplicaSet created by that Deployment, two Pods created by that ReplicaSet, and one ClusterIP Service selecting those Pods.

  4. Capture one Pod name and confirm its owner:

    Terminal window
    WEB_POD=$(kubectl get pods -l app=orchestra-web -o jsonpath='{.items[0].metadata.name}')
    echo "$WEB_POD"
    kubectl get pod "$WEB_POD" -o jsonpath='{.metadata.ownerReferences[0].kind}{"/"}{.metadata.ownerReferences[0].name}{"\n"}'

    The owner reference points to a ReplicaSet, not directly to the Deployment. That is the Deployment/ReplicaSet/Pod hierarchy made concrete: the Deployment manages the ReplicaSet, and the ReplicaSet manages the Pods.

  5. Describe the Pod and look for the init container, probes, and volumes:

    Terminal window
    kubectl describe pod "$WEB_POD"

    Scroll until you find the Init Containers, Containers, Mounts, Liveness, Readiness, and Startup sections. This is the Pod model made concrete: you can see exactly which containers Kubernetes created, which volumes are mounted where, and how each probe is configured.

  6. Prove the Service works from inside the cluster by DNS name:

    Terminal window
    kubectl run curl-test --rm -i --restart=Never --image=busybox:1.36 -- wget -qO- http://orchestra-web

    This command starts a temporary Pod named curl-test running busybox, executes wget -qO- http://orchestra-web inside it, prints the output to your terminal, and then deletes the Pod. The flags do the following: --rm deletes the Pod when it exits, -i connects your terminal to the Pod’s output, --restart=Never makes it a one-shot Pod rather than one that Kubernetes would restart on exit, and -- separates kubectl arguments from the command to run inside the container. The URL http://orchestra-web works because CoreDNS resolves short Service names within the same namespace.

    You should see the HTML page including your personal identifier. The workload is reachable by Service name, not by hardcoded Pod IP.


Stateless workloads can be replaced freely because they carry no persistent data. Stateful ones need storage that survives a Pod restart. This section adds a second workload backed by a PersistentVolumeClaim (PVC) so you can prove that deleting a Pod does not destroy the data on its volume.

  1. Create stateful-note.yaml:

    apiVersion: v1
    kind: Service
    metadata:
    name: note-pad
    namespace: cs312-minikube
    spec:
    clusterIP: None
    selector:
    app: note-pad
    ports:
    - port: 8080
    targetPort: 8080
    ---
    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
    name: note-pad
    namespace: cs312-minikube
    spec:
    serviceName: note-pad
    replicas: 1
    selector:
    matchLabels:
    app: note-pad
    template:
    metadata:
    labels:
    app: note-pad
    spec:
    containers:
    - name: note-pad
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
    mkdir -p /data
    if [ ! -f /data/index.html ]; then
    echo '<h1>Stateful note pad</h1><p>Edit /data/index.html to make persistence visible.</p>' > /data/index.html
    fi
    httpd -f -p 8080 -h /data
    ports:
    - containerPort: 8080
    volumeMounts:
    - name: notes
    mountPath: /data
    volumeClaimTemplates:
    - metadata:
    name: notes
    spec:
    accessModes:
    - ReadWriteOnce
    resources:
    requests:
    storage: 100Mi

    Setting clusterIP: None makes this a headless Service: it does not get a virtual ClusterIP, so DNS queries resolve directly to individual Pod IPs instead of a load-balanced virtual address. Combined with the StatefulSet’s stable pod names, each replica gets a predictable DNS name in the form <pod-name>.<service-name>, here note-pad-0.note-pad. The volumeClaimTemplates field tells Kubernetes to create one PVC per replica and bind it to that replica’s ordinal identity, so note-pad-0 always reattaches to the same volume even after rescheduling. On stock minikube, the default StorageClass and local provisioner usually bind this claim automatically. If the PVC stays Pending, inspect kubectl get storageclass and kubectl describe pvc notes-note-pad-0 before continuing.

  2. Apply the StatefulSet and wait for it to be ready:

    Terminal window
    kubectl apply -f stateful-note.yaml
    kubectl rollout status statefulset/note-pad

    This should create one Pod named note-pad-0 and one PVC named something like notes-note-pad-0.

  3. Inspect the objects Kubernetes created for you:

    Terminal window
    kubectl get statefulset,pod,pvc

    Notice that the Pod name is ordinal and stable. That is the first sign that a StatefulSet is not treating its Pods as interchangeable.

  4. Write your own content into the mounted volume:

    Terminal window
    kubectl exec note-pad-0 -- sh -c 'printf "<h1>Persisted from YOUR_ONID</h1>\n<p>This file lives on a PVC.</p>\n" > /data/index.html && cat /data/index.html'

    Replace YOUR_ONID with your own identifier before you run the command. You are writing directly into the persistent storage mounted at /data.

  5. Read that same file back through the Pod’s stable DNS name:

    Terminal window
    kubectl exec note-pad-0 -- wget -qO- http://note-pad-0.note-pad:8080/

    The DNS name note-pad-0.note-pad comes from the StatefulSet plus the headless Service. That naming pattern is what lets clients target a specific replica when ordering matters.

  6. Delete the Pod and watch the StatefulSet replace it:

    Terminal window
    kubectl delete pod note-pad-0
    kubectl get pod note-pad-0 -w

    Wait until the replacement Pod returns to Running, then stop the watch with Ctrl+C.

  7. Confirm that the file survived the Pod replacement:

    Terminal window
    kubectl exec note-pad-0 -- cat /data/index.html

    The Pod was replaced, but the file should still be there. That is the operational distinction between Pod identity and storage identity.


Deployments and StatefulSets manage workloads that should always be running. Some work is finite: a database migration, a data import, a nightly report. A Job runs one or more Pods to completion and then stops. This section runs a Job so you can see what a Pod in the Succeeded phase looks like.

  1. Create batch-job.yaml:

    apiVersion: batch/v1
    kind: Job
    metadata:
    name: hello-job
    namespace: cs312-minikube
    spec:
    completions: 1
    template:
    spec:
    restartPolicy: Never
    containers:
    - name: hello
    image: busybox:1.36
    command:
    - sh
    - -c
    - 'echo "Job complete from YOUR_ONID"; sleep 2'

    Replace YOUR_ONID before saving. The restartPolicy: Never tells Kubernetes not to restart the container after it exits; the Job controller tracks completion separately. With completions: 1, the Job is finished when one Pod exits with status 0.

  2. Apply the Job and watch the Pod move through its lifecycle:

    Terminal window
    kubectl apply -f batch-job.yaml
    kubectl get pods -w

    You should see a Pod move through PendingRunningCompleted. Stop the watch with Ctrl+C once the Pod shows Completed.

  3. Read the output and inspect the Job’s final state:

    Terminal window
    kubectl logs job/hello-job
    kubectl get job hello-job
    kubectl get pod -l job-name=hello-job

    The log should show your message. The Job’s COMPLETIONS column should read 1/1. The Pod status Completed means all containers exited cleanly with status 0, which Kubernetes calls the Succeeded phase. Kubernetes keeps the completed Pod around by default so you can read its logs.

  4. Delete the Job:

    Terminal window
    kubectl delete job hello-job

    Deleting a Job cascades to its Pods. In production, the ttlSecondsAfterFinished field on a Job spec can automate this cleanup; without it, completed Pods accumulate until you remove them manually.


Watch the Control Loop Publish and Update Your App

Section titled “Watch the Control Loop Publish and Update Your App”

You now have a stateless and a stateful workload running in the cluster. This section makes the control loop visible: you will delete Pods and watch them heal, change replica counts, roll out and roll back an update, add an HPA, and finally publish the page through an Ingress so it is reachable from your browser.

This section puts the control loop directly in front of you. Delete one Pod, change the desired replica count, and roll out a configuration change while Kubernetes keeps the Deployment available throughout.

  1. Delete one web Pod and watch the Deployment heal the gap:

    Terminal window
    kubectl get pods -l app=orchestra-web
    WEB_POD=$(kubectl get pods -l app=orchestra-web -o jsonpath='{.items[0].metadata.name}')
    kubectl delete pod "$WEB_POD"
    kubectl get pods -l app=orchestra-web -w

    One Pod disappears, then a replacement appears automatically. Stop the watch with Ctrl+C after the replica count returns to the desired number. When deletion begins, Kubernetes marks the Pod unready for normal Service traffic and updates EndpointSlice conditions so load balancers stop using it as a ready backend while it is shutting down. Applications that need connection draining still need to handle shutdown gracefully.

  2. Scale the Deployment manually to three replicas:

    Terminal window
    kubectl scale deployment orchestra-web --replicas=3
    kubectl get pods -l app=orchestra-web

    This is still declarative. You are not starting a third Pod directly. You are changing the desired replica count and letting Kubernetes do the work.

  3. Check that metrics-server is feeding live resource data:

    Terminal window
    kubectl top pods

    If you get a metrics API error, wait one minute and run the command again. The addon can take a short time to become ready after the cluster starts.

  4. Create web-edge.yaml with an HPA and an Ingress:

    apiVersion: autoscaling/v2
    kind: HorizontalPodAutoscaler
    metadata:
    name: orchestra-web
    namespace: cs312-minikube
    spec:
    scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: orchestra-web
    minReplicas: 2
    maxReplicas: 5
    metrics:
    - type: Resource
    resource:
    name: cpu
    target:
    type: Utilization
    averageUtilization: 50
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: orchestra-web
    namespace: cs312-minikube
    spec:
    ingressClassName: nginx
    rules:
    - http:
    paths:
    - path: /
    pathType: Prefix
    backend:
    service:
    name: orchestra-web
    port:
    number: 80

    An HPA (Horizontal Pod Autoscaler) watches a metric and automatically increases or decreases a Deployment’s replica count to keep that metric near a target. Here the target is 50% average CPU utilization, with a floor of 2 replicas and a ceiling of 5. The Ingress object declares how HTTP traffic entering the cluster should be routed to the Service.

  5. Apply the HPA and Ingress, then inspect the autoscaler object:

    Terminal window
    kubectl apply -f web-edge.yaml
    kubectl get hpa

    The current utilization might be 0%, very low, or temporarily unknown. The important point is the target: the HPA is now another controller watching the same Deployment.

  6. Update the web message and apply it:

    Open web-config.yaml and replace the message value with:

    Rolling updates keep this page online while the desired state changes.

    Then apply:

    Terminal window
    kubectl apply -f web-config.yaml

    Nothing visible changes yet because the init container only re-renders the page when a Pod starts. The Deployment template has not changed, so there is no automatic rollout from this ConfigMap update by itself.

  7. Roll the Deployment so new Pods pick up the new desired content:

    Terminal window
    kubectl rollout restart deployment/orchestra-web
    kubectl rollout status deployment/orchestra-web

    Because the Deployment strategy allows one surge Pod and zero unavailable Pods, Kubernetes can replace the old replicas without dropping the whole service at once.

  8. Now make one Pod-template change that the Deployment can actually roll back:

    Edit web-deployment.yaml and change the nginx image from nginx:1.27-alpine to nginx:1.28-alpine, then apply it again:

    Terminal window
    kubectl apply -f web-deployment.yaml
    kubectl rollout status deployment/orchestra-web

    This creates a new Deployment revision because the pod template changed. Unlike the ConfigMap edit above, this change is tracked in the ReplicaSet history.

  9. Roll back to the previous revision:

    Terminal window
    kubectl rollout undo deployment/orchestra-web
    kubectl rollout status deployment/orchestra-web

    Kubernetes reverts the Deployment’s pod template to the previous ReplicaSet. The same rolling strategy applies in reverse: new Pods from the older spec replace the current ones while keeping the Deployment available throughout.

  10. Confirm the image rolled back and the page still shows the updated message:

    Terminal window
    kubectl get deployment orchestra-web -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
    kubectl run curl-test --rm -i --restart=Never --image=busybox:1.36 -- wget -qO- http://orchestra-web

    The image should be back to nginx:1.27-alpine, while the page should still show the updated rolling-update message. The rollback changed the Deployment template, but it did not revert the separate ConfigMap object.

The Service is still internal to the cluster. This section first shows you NodePort, which opens a port directly on the node’s IP address, then replaces it with the ingress controller for a cleaner HTTP entry point.

  1. Temporarily expose the Service as a NodePort:

    Terminal window
    kubectl patch service orchestra-web -p '{"spec":{"type":"NodePort"}}'
    minikube service orchestra-web -n cs312-minikube --url

    Copy the URL printed and curl it (on macOS/WSL with the Docker driver, minikube service --url may keep a tunnel open, so run the curl in a second terminal):

    Terminal window
    curl <the-url-from-above>/

    You should see the HTML page. Notice the port number in the URL: by default, NodePort allocates from the 30000-32767 range. This is a usable but blunt entry point: every node in the cluster exposes that port whether or not it is running one of the Pods, and clients need to know both the node address and the high-numbered port.

  2. Switch back to ClusterIP and use Ingress instead:

    Terminal window
    kubectl patch service orchestra-web -p '{"spec":{"type":"ClusterIP"}}'

    Ingress is the cleaner production pattern: one external entry point, path-based or hostname-based routing to many Services, and no high-numbered ports visible to clients. The steps below set that up.

  3. Start the minikube tunnel in a second terminal and leave it running:

    Terminal window
    minikube tunnel

    Leave that command running while you finish this section. On macOS it may ask for your password because it needs permission to create network routes.

  4. In your original terminal, wait for the Ingress to report an address:

    Terminal window
    kubectl get ingress

    On Linux, an IP address will appear in the ADDRESS column within a few seconds. On macOS, the ADDRESS column may stay empty even while the tunnel is working correctly; the address to use in the next step is 127.0.0.1 regardless.

  5. Fetch the page through the Ingress endpoint:

    Terminal window
    curl http://127.0.0.1/

    You should now see the same personalized HTML page you generated earlier, this time reached through the ingress controller rather than from inside the cluster.

  6. Open the same URL in a browser and leave the page visible:

    The page should show your identifier and the updated rolling-update message. This is the clean, visible end state for the activity: a real page, served through Kubernetes, with your own text on it.


Once you have verified the Ingress page, remove the activity resources so your next minikube exercise starts from a known state. The fastest cleanup is to stop any helper tunnels, delete the namespace, and reset your default kubectl namespace.

  1. Stop any helper terminals you started for local access:

    If minikube service ... --url or minikube tunnel is still running in another terminal, press Ctrl+C there first.

    This closes the local helper tunnel and releases any routes or forwarded ports it created.

  2. Delete the activity namespace and everything in it:

    Terminal window
    kubectl delete namespace cs312-minikube --wait=true

    Kubernetes cascades that delete to the Deployment, StatefulSet, Service, PVC, ConfigMap, Secret, HPA, Ingress, and any completed Job objects you created in this activity.

  3. Reset your current kubectl namespace to default:

    Terminal window
    kubectl config set-context --current --namespace=default
    kubectl config view --minify -o 'jsonpath={..namespace}'; echo

    The final command should print default. This keeps later kubectl commands from pointing at a namespace that no longer exists.


You now have the core Kubernetes object model running locally: namespaces, Services, Deployments, StatefulSets, PVCs, probes, an HPA, and an Ingress. The natural next step is to move one layer up the stack and start working with the ecosystem that grows around those raw manifests.

If you want to keep going, repackage the three web manifest files as a Helm chart so the owner, message, and replica counts move into values.yaml. Build a separate staging overlay with Kustomize so you can change the message and replica count without copying the whole manifest set. To see the operator pattern become concrete, install cert-manager on a throwaway minikube cluster and compare kubectl get crd before and after the install. When the later lab arrives, take the same mental model to k3s on EC2 and pay attention to what changes: the control plane packaging, the storage defaults, the ingress controller, and the operational cost of exposing the cluster to real users.