Capstone Part 1 — GitOps Foundation
This is the first of five parts that build one continuous project across the rest of this capstone: cert-tracker, a small service that tracks your own progress through the sixteen-exam Golden Kubestronaut ladder — which certs you've passed, when each one expires, and whether you currently qualify for Kubestronaut or Golden Kubestronaut. Part 1 lays the foundation every later part stands on: a running Argo CD install, a config repo held deliberately separate from cert-tracker's own source, and an Application object whose sync policy actually closes the GitOps loop — prune and selfHeal both explicitly on, not left at their off-by-default settings. By the end of this page you will have watched cert-tracker deploy itself from a git push with no kubectl apply from you, watched a manual edit get reverted within seconds, and watched a resource you deleted from Git disappear from the cluster on its own.
Imagine your bedroom has a poster taped to the door listing exactly how the room should look: bed made, three books on the shelf, lamp on the desk. A small, tireless robot checks the room against that poster every few minutes, forever. Move a book off the shelf yourself, and the robot quietly puts it back — it isn't asking permission, it's just following the poster. Cross a book off the poster instead, and the robot removes that book from the shelf too, because the poster is the only truth it trusts. This page installs that robot, hangs up the first poster, and proves — with your own hands, not just a claim — that both kinds of correction actually happen.
Starting: a Kubernetes cluster you can already reach with kubectl — nothing running on it yet, no Argo CD, no cert-tracker. Leaving this page: Argo CD installed and reachable through the argocd CLI; a cert-tracker app repo holding a small Express service and its Dockerfile; a separate cert-tracker-config repo holding Kustomize manifests under apps/cert-tracker/base and .../overlays/dev; an AppProject named kubestronaut scoping exactly that repo and destination namespace; and an Application object with syncPolicy.automated.prune and .selfHeal both explicitly true. You'll also have firsthand proof that a hand-edited Deployment gets reverted within seconds and a resource deleted from Git disappears from the cluster on its own. Part 2 picks up exactly here and turns the plain Deployment this part creates into a real canary rollout.
What this part assumes, and what it produces
☺ Like you're 10: Just a cluster you can already reach, plus the everyday CLI toolbox — nothing about cert-tracker exists yet.
You need a Kubernetes cluster reachable right now with kubectl — a local kind or minikube cluster is exactly right for this capstone and is what every command below assumes, though any conformant cluster you already administer works identically. You also need Docker (or another OCI-compliant builder) to build cert-tracker's image, git and a GitHub account (or another Git host — swap the URLs), and the argocd CLI installed locally. This course assumes the kubectl fluency of the five core Kubernetes certifications this whole ladder sits on top of — if kubectl apply, namespaces and Deployments aren't yet comfortable, the Kubernetes course is the right stop before this one. Nothing else needs to exist yet: no service mesh, no policy engine, no observability stack, no portal. Those arrive starting in Part 3.
The world model this whole capstone shares
Five parts, one project, so it's worth naming the shape once, here, so nothing surprises you later:
| Thing | Name / value | Introduced |
|---|---|---|
| The application | cert-tracker — tracks your progress through the 16-exam Golden Kubestronaut ladder | Part 1 — this page |
| App source repo | cert-tracker on GitHub — code and Dockerfile only | Part 1 |
| Container image | ghcr.io/kubestronaut/cert-tracker:v0.1.0 | Part 1 |
| GitOps config repo | cert-tracker-config — Kustomize base + overlays, kept separate from app source | Part 1 |
| AppProject | kubestronaut — scopes exactly that repo and the cert-tracker namespace | Part 1 |
| Argo CD Application | cert-tracker — prune: true, selfHeal: true | Part 1 |
| Release strategy | plain Deployment → canary Argo Rollout | Part 1 (Deployment) → Part 2 (Rollout) |
| Mesh & policy | not yet installed | Part 3 |
| Observability | not yet wired | Part 4 |
| Developer portal | not yet catalogued | Part 5 |
Keep that table in your head across all five parts: whenever a later page says "the cert-tracker Application" or "the config repo," this is where those names were born.
Standing up cert-tracker: the app this loop deploys
☺ Like you're 10: A tiny web service that remembers which exams you've passed — small on purpose, so every later part can add to it without a rewrite.
cert-tracker is deliberately small: an Express service, an in-memory store seeded with all sixteen exams from the sixteen-exam ladder, and three routes. Create package.json:
{
"name": "cert-tracker",
"version": "0.1.0",
"private": true,
"scripts": { "start": "node src/index.js" },
"dependencies": { "express": "^4.19.2" }
}// src/index.js
const express = require("express");
const app = express();
app.use(express.json());
// The sixteen exams Golden Kubestronaut is built from
const EXAMS = [
{ id: "kcna", name: "Kubernetes and Cloud Native Associate", family: "kubernetes-core" },
{ id: "kcsa", name: "Kubernetes and Cloud Native Security Associate", family: "kubernetes-core" },
{ id: "cka", name: "Certified Kubernetes Administrator", family: "kubernetes-core" },
{ id: "ckad", name: "Certified Kubernetes Application Developer", family: "kubernetes-core" },
{ id: "cks", name: "Certified Kubernetes Security Specialist", family: "kubernetes-core" },
{ id: "cgoa", name: "Certified GitOps Associate", family: "project-associate" },
{ id: "capa", name: "Certified Argo Project Associate", family: "project-associate" },
{ id: "cba", name: "Certified Backstage Associate", family: "project-associate" },
{ id: "cca", name: "Certified Cilium Associate", family: "project-associate" },
{ id: "ica", name: "Istio Certified Associate", family: "project-associate" },
{ id: "kca", name: "Kyverno Certified Associate", family: "project-associate" },
{ id: "otca", name: "OpenTelemetry Certified Associate", family: "project-associate" },
{ id: "pca", name: "Prometheus Certified Associate", family: "project-associate" },
{ id: "cnpa", name: "Certified Cloud Native Platform Engineering Associate", family: "platform" },
{ id: "cnpe", name: "Certified Cloud Native Platform Engineer", family: "platform" },
{ id: "lfcs", name: "Linux Foundation Certified System Administrator", family: "linux" },
];
const KUBESTRONAUT_IDS = ["kcna", "kcsa", "cka", "ckad", "cks"];
const passed = new Map(); // id -> passedOn (ISO date) — in-memory only, on purpose, for now
app.get("/healthz", (_req, res) => res.status(200).json({ status: "ok" }));
app.get("/exams", (_req, res) => {
res.status(200).json(EXAMS.map((e) => ({ ...e, passedOn: passed.get(e.id) || null })));
});
app.post("/exams/:id/pass", (req, res) => {
const exam = EXAMS.find((e) => e.id === req.params.id);
if (!exam) return res.status(404).json({ error: "no such exam" });
const passedOn = (req.body && req.body.passedOn) || new Date().toISOString().slice(0, 10);
passed.set(exam.id, passedOn);
res.status(200).json({ ...exam, passedOn });
});
app.get("/status", (_req, res) => {
res.status(200).json({
passedCount: passed.size,
totalCount: EXAMS.length,
kubestronaut: KUBESTRONAUT_IDS.every((id) => passed.has(id)),
goldenKubestronaut: EXAMS.every((e) => passed.has(e.id)),
});
});
module.exports = { app };
if (require.main === module) {
const port = process.env.PORT || 8080;
app.listen(port, () => console.log(`cert-tracker listening on :${port}`));
}# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 8080
CMD ["node", "src/index.js"]Scaffold the repo, sanity-check it locally, then build and push the image the Deployment below will reference — swap kubestronaut for your own GitHub username or org; this page keeps the placeholder used throughout the course for consistency:
mkdir cert-tracker && cd cert-tracker
git init -b main
# add package.json, src/index.js, Dockerfile exactly as above
git add .
git commit -m "chore: cert-tracker v0.1.0 — track the sixteen-exam ladder"
gh repo create cert-tracker --public --source=. --remote=origin --push
npm install && npm start &
curl localhost:8080/healthz # {"status":"ok"}
curl localhost:8080/status # passedCount 0, kubestronaut false
kill %1
docker build -t ghcr.io/kubestronaut/cert-tracker:v0.1.0 .
docker push ghcr.io/kubestronaut/cert-tracker:v0.1.0A GitOps repo is not the app repo: structuring cert-tracker-config
☺ Like you're 10: Two separate folders on two separate shelves — one holds the app's own code, the other holds only the poster describing how it should run.
Per CGOA's tooling and patterns domains, this is a deliberate state store boundary, not a filing preference. cert-tracker runs arbitrary application code and takes pull requests from anyone; cert-tracker-config holds nothing but declarative manifests. Scoping Argo CD's AppProject.sourceRepos to the config repo alone is a real, auditable trust boundary — Argo CD is never given a reason to read a single line of cert-tracker's own source. Later capstone parts add sibling directories under this same config repo rather than new repositories, so the state store stays small enough to hand-audit even once mesh, policy and observability manifests join it.
cert-tracker-config/
├── apps/
│ └── cert-tracker/
│ ├── base/
│ │ ├── kustomization.yaml
│ │ ├── deployment.yaml
│ │ └── service.yaml
│ └── overlays/
│ └── dev/
│ ├── kustomization.yaml
│ └── patch-replicas.yaml
└── bootstrap/
├── project.yaml
└── application.yaml# apps/cert-tracker/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cert-tracker
labels: { app: cert-tracker }
spec:
replicas: 2
selector:
matchLabels: { app: cert-tracker }
template:
metadata:
labels: { app: cert-tracker }
spec:
containers:
- name: cert-tracker
image: ghcr.io/kubestronaut/cert-tracker:v0.1.0
ports: [{ containerPort: 8080 }]
readinessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 3 }
livenessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10 }# apps/cert-tracker/base/service.yaml
apiVersion: v1
kind: Service
metadata:
name: cert-tracker
spec:
selector: { app: cert-tracker }
ports: [{ port: 80, targetPort: 8080 }]
---
# apps/cert-tracker/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [deployment.yaml, service.yaml]# apps/cert-tracker/overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cert-tracker
resources: [../../base]
patches:
- path: patch-replicas.yaml
target: { kind: Deployment, name: cert-tracker }
---
# apps/cert-tracker/overlays/dev/patch-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: cert-tracker }
spec:
replicas: 1 # one replica is plenty on a local kind clusterNotice there is no namespace.yaml in base/ — the overlay's namespace: transformer stamps every resource into cert-tracker, and the Application below creates that namespace itself through a sync option, not a tracked manifest. That choice matters once prune: true is on: pruning a tracked Namespace object would take the whole namespace, and everything in it, with it.
mkdir cert-tracker-config && cd cert-tracker-config
git init -b main
# create the tree above
git add .
git commit -m "chore: cert-tracker base + dev overlay"
gh repo create cert-tracker-config --public --source=. --remote=origin --pushInstalling Argo CD
☺ Like you're 10: Move the inspector robot into the building before you hand it a poster to check against.
Install the stable release into its own namespace, and wait for both key workloads to report ready — note the second one is a StatefulSet, not a Deployment, per Argo CD's architecture, which is exactly why kubectl get deploy -n argocd alone would quietly miss it:
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=180s
kubectl -n argocd rollout status statefulset/argocd-application-controller --timeout=180sRetrieve the generated admin password, log in with the CLI, and confirm it's talking to your cluster:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo
kubectl -n argocd port-forward svc/argocd-server 8080:443 &
argocd login localhost:8080 --username admin --insecure
argocd version --shortWiring the reconciliation loop: AppProject and Application
☺ Like you're 10: One rule says which poster this robot is even allowed to read; the other says exactly which poster, and which room, to match it to.
The default AppProject that ships out of the box trusts any source repo and any destination — fine for a demo, a real gap in production. Scope a dedicated project first:
# bootstrap/project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: kubestronaut
namespace: argocd
spec:
description: The capstone project — cert-tracker, and everything later parts add to it
sourceRepos:
- https://github.com/kubestronaut/cert-tracker-config.git
destinations:
- server: https://kubernetes.default.svc
namespace: cert-tracker
clusterResourceWhitelist: [] # no namespaces, no CRDs — CreateNamespace=true below covers the one we need# bootstrap/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-tracker
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: kubestronaut
source:
repoURL: https://github.com/kubestronaut/cert-tracker-config.git
targetRevision: main
path: apps/cert-tracker/overlays/dev
destination:
server: https://kubernetes.default.svc
namespace: cert-tracker
syncPolicy:
automated:
prune: true # off by default even under "automated" — see Argo CD
selfHeal: true # off by default even under "automated" — see Argo CD
syncOptions:
- CreateNamespace=trueprune and selfHeal are two independent booleans, and both default to false — even once automated: {} is present. Miss either one and you get half a GitOps loop: leave out selfHeal and a hand-edited Deployment sits OutOfSync forever, un-reverted; leave out prune and a resource deleted from Git keeps running in the cluster, permanently orphaned. Both flags below are set on purpose, which is the entire point of this part.
Apply both manifests directly with kubectl — this is the one and only manual apply in this whole capstone. Every change after this bootstrap moment ships through cert-tracker-config, never through a command run by hand again:
kubectl apply -n argocd -f bootstrap/project.yaml
kubectl apply -n argocd -f bootstrap/application.yaml
argocd app get cert-tracker
argocd app wait cert-tracker --health --timeout 120Once argocd app get cert-tracker reports Synced and Healthy, confirm the pods are actually answering:
kubectl get pods -n cert-tracker
kubectl -n cert-tracker port-forward svc/cert-tracker 8081:80 &
curl localhost:8081/healthz
curl localhost:8081/statusProving self-heal and prune actually happen
☺ Like you're 10: Break it by hand, twice, on purpose, and watch the robot fix both kinds of mess without you touching git push a second time.
First, prove self-heal. Scale the Deployment down by hand and watch it climb back with no new commit anywhere:
kubectl -n cert-tracker scale deployment/cert-tracker --replicas=0
kubectl -n cert-tracker get deploy cert-tracker -w
# replicas climbs back to 1 within seconds — Argo CD watches the live cluster continuouslyNotice the speed: this correction lands in seconds, because selfHeal watches the live cluster through Kubernetes' own watch API, not through the Git poll interval. A change on the Git side is a different clock entirely — the next section relies on that distinction.
Now prove prune. Remove the Service from cert-tracker-config, push, and watch it vanish from the cluster on its own:
cd cert-tracker-config
git rm apps/cert-tracker/base/service.yaml
# also drop service.yaml from base/kustomization.yaml's resources list
git commit -am "test: (temporary) drop the Service to prove prune"
git push
argocd app sync cert-tracker # or wait for the next ~3min poll
kubectl -n cert-tracker get svc cert-tracker
# Error from server (NotFound): services "cert-tracker" not foundThat error is the entire point of this section — not a claim that prune works, proof. Put it back the GitOps-idiomatic way, by reverting the commit rather than hand-creating the Service:
git revert HEAD --no-edit
git push
argocd app sync cert-tracker
kubectl -n cert-tracker get svc cert-tracker # back, recreated by the reconciler, not by youWhat "done" looks like for Part 1, and where Part 2 picks up
☺ Like you're 10: A robot that's actually watching, a poster it actually trusts completely, and proof of both — not just a working demo you have to take on faith.
At the end of this part you have: cert-tracker running in its own namespace from an image built once and referenced by tag; a cert-tracker-config repo structured as base + overlay, with no cluster-scoped resources tracked in Git; an AppProject scoping exactly one repo and one destination; and an Application with prune and selfHeal both proven, not assumed. Nothing here gets thrown away:
| Part | What it does with Part 1's artifacts |
|---|---|
| 2 — Progressive Delivery | Replaces the plain Deployment in apps/cert-tracker with an Argo Rollout, canarying releases through this exact same Application and config repo |
| 3 — Mesh & Policy | Puts cert-tracker's traffic inside a mesh and adds Kyverno policies that gate what cert-tracker-config is even allowed to declare |
| 4 — Observability | Instruments the /exams and /status routes built here with OpenTelemetry traces and Prometheus metrics |
| 5 — The Portal | Catalogs cert-tracker in Backstage, using this same repo pair as the source of truth the portal displays |
Recon the Robot: Application cert-tracker: Synced, Healthy. Two flags on, one loop closed.
Foxy: "Closed" meaning what, exactly? I could still kubectl edit my way into trouble, right?
Recon: Try it. Scale the Deployment to zero. I'll have it back before you've finished typing the next command.
Gizmo: Or — hear me out — just flip selfHeal off while you're debugging. Way less annoying when you're poking at a pod. 🤑
Timmy: Turn it off deliberately, with a comment saying why, and turn it back on before you walk away. "Annoying" is the reconciler doing exactly its job — the moment you silence it out of convenience is the moment drift starts hiding again.
Benny the Beaver: Same lesson as branch protection, one layer down. Lock the door on main, lock the door on the cluster — neither one's trying to slow you down, just making sure the only way in is the one everyone agreed to.
Recon: Everything past "it runs" is next. Part 2 makes releases safe, not just declared.
Milestones
☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."
Work these in order — each depends on the repo and cluster state from the one before. Progress saves in this browser.
kubectl cluster-info against a local kind/minikube cluster or your own; confirm docker, git, gh and argocd are all on your PATH.kubectl get nodes returns at least one Ready node.cert-tracker: /healthz, /exams, /status, seeded with all 16 examspackage.json and src/index.js exactly as shown above.npm start boots the service and curl localhost:8080/exams returns all 16 exams with passedOn: null.docker build -t ghcr.io/kubestronaut/cert-tracker:v0.1.0 . then docker push, swapping in your own namespace.cert-tracker-config: base/ + overlays/dev/Namespace manifest tracked.kustomize build apps/cert-tracker/overlays/dev renders clean YAML locally, replicas patched to 1.gh repo create cert-tracker --public --source=. --remote=origin --push, then the same for cert-tracker-config.argocd-server and the application-controller StatefulSet are Readykubectl create namespace argocd, apply the stable install manifest, then the two rollout status commands above.argocd-initial-admin-secret, port-forward argocd-server, run argocd login.argocd version --short returns both client and server versions.bootstrap/project.yaml — the kubestronaut AppProjectkubectl apply -n argocd -f bootstrap/project.yaml, exactly as shown above.argocd proj get kubestronaut shows the one scoped source repo and destination.bootstrap/application.yaml with prune and selfHeal both explicitkubectl apply -n argocd -f bootstrap/application.yaml, exactly as shown above.argocd app get cert-tracker reports the kubestronaut project and both sync-policy flags true.argocd app wait cert-tracker --health --timeout 120, then port-forward and curl /healthz and /status.Running in kubectl get pods -n cert-tracker.kubectl -n cert-tracker scale deployment/cert-tracker --replicas=0, then watch it.service.yaml, commit, push, sync, confirm the 404 — then git revert, push, sync again.kubectl apply.cert-tracker Synced and Healthy, both sync-policy flags proven with your own hands, no resource in the cluster that skipped the config repo.1. Why does cert-tracker-config live in a separate repository from cert-tracker's own source, rather than a /deploy folder inside the same repo? 2. syncPolicy.automated was set from the start, yet prune and selfHeal both had to be listed explicitly. What do they default to, and what would you have observed if you'd left selfHeal out? 3. Scaling the Deployment to 0 by hand corrected within seconds — much faster than the Service deletion took to prune. Why the difference in speed? 4. What does clusterResourceWhitelist: [] combined with syncOptions: [CreateNamespace=true] actually buy you, compared to just tracking a Namespace manifest in Git? 5. Name two things this part deliberately left out that a later part adds, and why they weren't needed yet.
Check your answers
- Keeping the GitOps repo separate scopes the state store to exactly what Argo CD should read.
cert-trackerruns CI and takes pull requests carrying arbitrary code;cert-tracker-configholds nothing but declarative manifests. ScopingAppProject.sourceReposto the config repo alone is a real, auditable trust boundary — not "trust the whole app repo, code and all." - Both default to
false, even withautomated: {}present. Leaving outselfHealwould still auto-apply new commits, but a manualkubectledit or scale would sit there un-reverted — Argo CD would reportOutOfSyncand simply wait, since detecting drift and correcting it are two separate switches. selfHealwatches the live cluster continuously through Kubernetes' own watch API, so a change on the cluster side is caught within seconds. Picking up a new commit incert-tracker-configdepends on the poll interval — roughly three minutes by default, or sooner withargocd app syncor a webhook — so a live-side correction is near-instant while a Git-side one waits on that interval.- It stops any Application in the
kubestronautproject from creating or touching cluster-scoped resources at all — no Namespaces, no CRDs, nothing beyond the one namespace already named indestinations.CreateNamespace=truestill gets you the one namespace you need, as a controlled side effect of the sync itself, without granting the project the much larger, general power to create arbitrary cluster-scoped objects. - Any two of: a service mesh and Kyverno policy admission (Part 3), progressive delivery via an Argo Rollout in place of the plain Deployment (Part 2), and OpenTelemetry/Prometheus instrumentation (Part 4). None of them were needed to prove the reconciliation loop itself works, which is this part's only job.
Part 1 gave you a reconciled, self-healing, prune-capable foundation — proved with your own hands, not taken on faith. Continue to Capstone Part 2 — Progressive Delivery, where the plain Deployment built here becomes a real canary rollout. Or step back to Build Your Cert Tracker — Start Here to see how this capstone's five parts fit the rest of the hands-on labs, and revisit GitOps Philosophy and Argo CD for the concepts behind what you just built.