Capstone Part 1: Cluster & GitOps Foundation
This is the first of six parts that build one continuous project — a small payments/ledger service called ledger, shipped through a real platform you assemble with your own hands. Part 1 lays the concrete: a local cluster, a GitOps controller inside it, and a repo layout every later part will keep writing into. By the end of this page, deleting a file from Git deletes a live Kubernetes object, and no one on your laptop ever runs kubectl apply against the ledger namespace again.
⚖ CNPA vs CNPE — This build-it-yourself capstone format is CNPE-specific — CNPE is performance-based, so exercises like this one are exactly what exam day looks like. CNPA has no lab component at all; it's closed-book multiple choice with zero lookups. Still, the concepts you're about to touch — GitOps reconciliation, App-of-Apps, drift and prune — are exactly the kind of platform-engineering recall CNPA's closed-book format tests too.
Arriving: nothing. An empty laptop with Docker, kubectl, and git. There is no cluster yet. Leaving this page: a kind cluster named platform-dev, with Argo CD running in the platform namespace, reconciling a single root Application that fans out into an App-of-Apps pattern from a repo you control, and a ledger namespace holding a placeholder version of the ledger service — deployed only through Git, never by hand. Part 2 picks up exactly here and replaces that placeholder with a real pipeline-built image and a canary rollout.
Before you can teach a robot to keep your room tidy (that's GitOps, which you met on the GitOps Workflows page), you first need a room. Today you build the room — a little practice bedroom on your own laptop called platform-dev — and you hire the tidying robot and give it its first poster: a folder in Git describing one small app named ledger. Everything you build for the rest of this capstone gets added to that same poster, in that same room.
What this part assumes and what it produces
☺ Like you're 10: Just the tools on your desk — nothing built yet.
You need four things installed locally: a container runtime (Docker or Podman), kind (the primary tool for this whole capstone — minikube works too if you prefer it, but every command below assumes kind), kubectl, and git. You also need one empty Git repository you can push to — call it platform-capstone — because Part 1 through Part 6 all commit into it. Nothing else needs to exist yet: no image registry, no pipeline, no dashboards. Those arrive in later parts.
The world model this whole capstone shares
Six parts, one project, so it's worth naming the shape once, here, so nothing surprises you later:
| Thing | Name | Introduced |
|---|---|---|
| Local cluster | platform-dev (via kind create cluster --name platform-dev) | Part 1 — this page |
| The application | ledger — a small payments/ledger-style service | Part 1 (placeholder) → Part 2 (real image) |
| Container image | ledger:TAG, pushed to registry.local/ledger | Part 2 |
| App namespace | ledger — the application lives here | Part 1 |
| Platform namespace | platform — every add-on (Argo CD, later Prometheus, Kyverno, …) lives here | Part 1 |
| GitOps repo layout | apps/ (Argo CD Application manifests) + ledger/ (the service's own manifests) | Part 1 |
Keep that table in your head across all six parts: whenever a later page says "the ledger namespace" or "the platform namespace," this is where those names were born.
Standing up platform-dev
☺ Like you're 10: One command builds you a tiny practice Kubernetes cluster right on your own laptop, using Docker containers pretending to be machines.
kind (Kubernetes IN Docker) runs a full multi-node-capable Kubernetes cluster as Docker containers — no cloud account, no bill, torn down in seconds. It's the tool named throughout this capstone. (minikube does the same job with a different underlying driver — everything here transfers if you use it instead, but the exact commands below assume kind.)
kind create cluster --name platform-dev kubectl cluster-info --context kind-platform-dev kubectl get nodes # NAME STATUS ROLES AGE VERSION # platform-dev-control-plane Ready control-plane 45s v1.31.x
Create the two namespaces this entire capstone will keep using — platform for platform add-ons, ledger for the application:
kubectl create namespace platform kubectl create namespace ledger
This split is deliberate and holds for all six parts: anything a platform team owns and every team shares (Argo CD, later Prometheus, Kyverno, Linkerd) goes in platform; anything that is the ledger application itself goes in ledger. When Part 6 adds a second tenant namespace for the tenancy exercise, this same rule is what tells you where the new boundary goes.
Installing Argo CD into platform
☺ Like you're 10: You hire the tidying robot and give it its own room to live in.
This capstone uses Argo CD as its GitOps engine (the same tool the GitOps Workflows lesson and the GitOps labs use — read those first if Application, prune, and selfHeal are new words). Install it straight into the platform namespace you already created:
kubectl apply -n platform -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml kubectl -n platform rollout status deploy/argocd-server kubectl -n platform get pods
Install the CLI and log in. The initial admin password lives in a Secret that Argo CD generates on first boot:
# the argocd CLI
brew install argocd
# ...or the release binary (Linux amd64):
curl -sSL -o argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
sudo install -m 555 argocd /usr/local/bin/argocd && rm argocd
argocd version --client
# read the bootstrap password
kubectl -n platform get secret argocd-initial-admin-secret \
-o jsonpath='{.data.password}' | base64 -d ; echo
kubectl -n platform port-forward svc/argocd-server 8080:443 # leave this running
argocd login localhost:8080 --username admin --insecure # in another terminalArgo CD's own install manifests default every Application object's home to whichever namespace you installed the controller into — here, platform, not the upstream docs' usual argocd. Every Application manifest in this capstone therefore sets metadata.namespace: platform, and every kubectl -n command targeting Argo CD's own objects uses platform too. If you see "namespace not permitted" errors, it is almost always this: something is still pointed at argocd.
The GitOps repo layout
☺ Like you're 10: One drawer for "which apps exist," one drawer for "what the ledger app is actually made of."
Create the empty platform-capstone repo now, with exactly this shape. Every later capstone part adds to it — never restructures it:
platform-capstone/ # the repo Argo CD watches — push access required
├── apps/ # one Argo CD Application manifest per thing
│ └── ledger.yaml # points Argo CD at ./ledger below
└── ledger/ # the ledger service's own Kubernetes manifests
├── namespace.yaml
├── deployment.yaml
├── service.yaml
└── kustomization.yamlThis is the same two-drawer idea the GitOps lesson teaches — apps/ holds Application objects (pointers), ledger/ (and later tenants/, in Part 6) holds the actual workload manifests those pointers resolve to. Later parts add folders beside these, never inside them: Part 2 adds a Rollout in place of the Deployment; Part 3 adds a CRD; Part 4 wires Backstage's scaffolder to write into a new folder here; Part 5 and 6 add monitoring and policy manifests under platform-addons/. Note that folder name now — it's introduced properly, with content, in Part 5.
The ledger placeholder
Part 1 doesn't build a real ledger image yet — that's Part 2's job with a proper CI pipeline. For now, commit a placeholder Deployment using a public image so the whole reconciliation story is provable end-to-end:
# ledger/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: ledger
labels:
app.kubernetes.io/part-of: ledger-capstone
---
# ledger/deployment.yaml — Part 2 replaces this image with a pipeline-built ledger:TAG
apiVersion: apps/v1
kind: Deployment
metadata:
name: ledger
namespace: ledger
labels:
app: ledger
spec:
replicas: 2
selector:
matchLabels: { app: ledger }
template:
metadata:
labels: { app: ledger }
spec:
containers:
- name: ledger
image: ghcr.io/stefanprodan/podinfo:6.7.0 # placeholder; Part 2 swaps this for registry.local/ledger
ports:
- containerPort: 9898
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 250m, memory: 128Mi }
---
# ledger/service.yaml
apiVersion: v1
kind: Service
metadata:
name: ledger
namespace: ledger
spec:
selector: { app: ledger }
ports:
- port: 80
targetPort: 9898
---
# ledger/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml# apps/ledger.yaml — the one Application object that owns the ledger workload
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ledger
namespace: platform
finalizers:
- resources-finalizer.argocd.argoproj.io # delete workloads if this Application is ever deleted
spec:
project: default
source:
repoURL: https://github.com/YOU/platform-capstone.git
targetRevision: main
path: ledger
destination:
server: https://kubernetes.default.svc
namespace: ledger
syncPolicy:
automated:
prune: true # deleting a manifest from Git deletes the live resource
selfHeal: true # a manual kubectl edit is reverted back to Git
syncOptions:
- CreateNamespace=trueCommit and push all five files above, then apply only the root that will point at apps/ — that's the next section.
The App-of-Apps root
☺ Like you're 10: Instead of applying one poster per app by hand forever, you apply one poster that says "go read every other poster in this folder" — and from then on, new posters are just files.
Rather than kubectl apply -f apps/ledger.yaml yourself, apply a single root Application that points at the apps/ folder with directory.recurse: true. Its "workload" is other Application objects — the App-of-Apps pattern from the GitOps lesson. This is the only manifest you ever apply by hand for the rest of the capstone; every app after this — the Rollout in Part 2, the operator's sample CR in Part 3, the Backstage-scaffolded services in Part 4 — arrives by adding a file to apps/, never by another kubectl apply.
# root.yaml — apply this once, by hand, and never again
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: platform
spec:
project: default
source:
repoURL: https://github.com/YOU/platform-capstone.git
targetRevision: main
path: apps
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: platform # child Application objects are created here
syncPolicy:
automated:
prune: true
selfHeal: truekubectl apply -f root.yaml kubectl -n platform get applications -w # NAME SYNC STATUS HEALTH STATUS # root Synced Healthy # ledger Synced Healthy kubectl -n ledger get deploy,svc # deployment.apps/ledger 2/2 # service/ledger ClusterIP
Proving reconciliation defeats manual drift
☺ Like you're 10: Mess up the room by hand and watch the robot quietly put it back within seconds.
This is the single most important thing to feel with your own hands before moving on — the whole capstone rests on it. With selfHeal: true already set on the ledger Application, hand-edit the live Deployment and watch Argo CD revert you:
kubectl -n ledger scale deploy/ledger --replicas=5 kubectl -n ledger get deploy ledger -w # 5 ... 5 ... 2 <- Recon reverted it back to what Git says argocd app get ledger --hard-refresh # skip the 3-minute poll if you're impatient argocd app diff ledger # shows no drift once it has settled
Delete a Service by hand and watch the same thing happen to a whole object, not just a field:
kubectl -n ledger delete svc ledger kubectl -n ledger get svc -w # it reappears within one reconciliation pass
Proving Git deletion prunes
☺ Like you're 10: Cross something off the poster, and the robot removes the matching thing from the room — nothing gets left behind as junk.
Now prove the other half. Remove the Service from Git entirely and push:
# delete ledger/service.yaml, and remove it from the resources list in kustomization.yaml git rm ledger/service.yaml # edit ledger/kustomization.yaml to drop the "service.yaml" line git commit -m "remove ledger Service to prove prune" git push argocd app get ledger --hard-refresh kubectl -n ledger get svc # No resources found in ledger namespace. <- prune deleted the live object, not just the Git file
Restore it with git revert and push again — the Service comes back with no kubectl command from you at all. That round trip, in both directions, is the entire GitOps promise made concrete: Git is the only place you ever change desired state, and the cluster is a pure reflection of it.
It's tempting to only test self-heal (drift) and assume prune "obviously" works the same way. It doesn't have to — prune is a separate flag, and Argo CD ships with it off by default precisely because deleting live resources is a bigger blast radius than reverting a field. If you only ever tested self-heal, you have not actually verified your syncPolicy.automated.prune: true is doing anything.
What "done" looks like for Part 1
☺ Like you're 10: A cluster that fixes itself, whether you nudge a setting or erase a whole poster page.
At the end of this part your platform-dev cluster has: a running Argo CD in platform, a root Application fanning out from apps/, a ledger Application reconciling a placeholder ledger Deployment and Service from your platform-capstone repo, prune and selfHeal both proven with your own hands, and a repo layout (apps/ + ledger/) that every remaining part of this capstone will keep extending. Nothing here is thrown away — Part 2 starts by adding a real CI pipeline that builds and pushes registry.local/ledger:TAG, then swaps today's placeholder Deployment for an Argo Rollouts canary that ships through the exact same GitOps path you just built.
Foxy: Why bother with the App-of-Apps root at all? I could just kubectl apply -f apps/ledger.yaml myself.
Benny: You could, once. But Part 3 adds an operator's Application, Part 4 adds Backstage-scaffolded ones, Part 5 and 6 add more still. One root means every future app is a file, not a command you have to remember to run.
Recon: BEEP. And I don't care how many children there are. Desired state, actual state, diff, apply. Same loop, every child, forever.
Gizmo: Or — hot take — just kubectl edit the ledger Deployment straight in prod when you're in a hurry. Nobody's watching. 😈
Timmy: Recon is watching, Gizmo. You just proved it yourself two sections ago — the edit lasted about four seconds.
Dot: Honestly, this is the part I care about most: from here on, "ship the ledger update" just means "open a PR." That's the whole appeal.
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 cluster and repo state from the one before. Progress saves in this browser.
platform-dev clusterkind create cluster --name platform-dev, then confirm with kubectl get nodes and kubectl cluster-info --context kind-platform-dev.kubectl get nodes shows one Ready control-plane node.platform and ledger namespaceskubectl create namespace platform and kubectl create namespace ledger.kubectl get ns lists both.platform-n platform, then kubectl -n platform rollout status deploy/argocd-server.kubectl -n platform get pods shows every Argo CD pod Running.argocd CLI and log inargocd-initial-admin-secret in platform, port-forward svc/argocd-server, and argocd login localhost:8080.argocd app list runs without an auth error.platform-capstone repo with the apps/ + ledger/ layoutledger/namespace.yaml, deployment.yaml, service.yaml, kustomization.yaml, and apps/ledger.yaml exactly as shown above, then push to main.root App-of-Appsroot.yaml pointed at apps/ with directory.recurse: true. This is the only manifest you apply by hand.kubectl -n platform get applications lists both root and ledger, both Synced/Healthy.kubectl -n ledger get deploy,svc.deployment.apps/ledger shows 2/2 ready and a ledger Service exists.kubectl -n ledger scale deploy/ledger --replicas=5, then watch it fall back with kubectl -n ledger get deploy ledger -w.2 within one reconciliation pass, with no command from you.kubectl -n ledger delete svc ledger, then kubectl -n ledger get svc -w.git rm ledger/service.yaml, update kustomization.yaml, commit, push, then argocd app get ledger --hard-refresh.kubectl -n ledger get svc reports no resources found — the live object is gone, not just the Git file.git revertkubectl apply yourself.kubectl -n ledger get svc with zero manual cluster commands.root and ledger Applications both Synced/Healthy, platform-capstone pushed with the apps/ + ledger/ layout, no resource in ledger namespace that you created by hand.1. Which namespace hosts Argo CD in this capstone, and why does that matter for every Application manifest you write? 2. What is the one manifest you ever apply by hand, and what pattern does it use? 3. Name the two syncPolicy fields you proved, and what each one defeats. 4. If you deleted a manifest from Git and the live resource became an orphan instead of disappearing, which flag was missing?
Check your answers
platform. EveryApplicationobject'smetadata.namespacemust match wherever Argo CD itself was installed, or it's rejected as not permitted.root.yaml, using the App-of-Apps pattern — it points atapps/withdirectory.recurse: true, so every later Application is a new file, not a new command.selfHeal: truedefeats manual drift (a hand-edit or hand-scale is reverted);prune: truedefeats orphaned resources (a manifest deleted from Git deletes the live object too).prune: truewas missing (or set tofalse) on that Application'ssyncPolicy.automated.
Part 1 gave you a self-reconciling cluster and a repo that every remaining part extends. Continue to Capstone Part 2 — Pipeline & Canary Delivery, where the placeholder ledger Deployment becomes a real pipeline-built image shipped through an Argo Rollouts canary. Or step back to the full lab track to see how this capstone fits the rest of the hands-on labs, and revisit GitOps Workflows and Platform Architecture & Infrastructure for the concepts behind what you just built.