Capstone · Part 1 of 6 · Cluster & GitOps Foundation

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.

⚠ Where you are arriving from, and where you're headed

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.

☺ Explain it like I'm 10

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.

🦫🤖Your hosts for this part: Benny the Beaver & Recon the Robot — Benny lays the cluster and the repo rails, and Recon is the reconciliation loop that will spend the rest of this capstone quietly making sure the cluster always matches what's written in Git.

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:

ThingNameIntroduced
Local clusterplatform-dev (via kind create cluster --name platform-dev)Part 1 — this page
The applicationledger — a small payments/ledger-style servicePart 1 (placeholder) → Part 2 (real image)
Container imageledger:TAG, pushed to registry.local/ledgerPart 2
App namespaceledger — the application lives herePart 1
Platform namespaceplatform — every add-on (Argo CD, later Prometheus, Kyverno, …) lives herePart 1
GitOps repo layoutapps/ (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
◆ Key idea

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 terminal
⚠ Namespace choice matters here

Argo 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.yaml

This 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=true

Commit 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: true
kubectl 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
platform-capstone repo apps/ledger.yaml · ledger/* root Application watches apps/ ledger Application watches ledger/ ledger namespace Deployment + Service fans out reconciles one kubectl apply (root.yaml), then every future app is a file in Git

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.

⚠ Don't skip proving both directions

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.

🎬 At the Platform Guild
🦊

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.

0 / 12 milestones complete
1Create the platform-dev cluster
kind create cluster --name platform-dev, then confirm with kubectl get nodes and kubectl cluster-info --context kind-platform-dev.
Done when: kubectl get nodes shows one Ready control-plane node.
2Create the platform and ledger namespaces
kubectl create namespace platform and kubectl create namespace ledger.
Done when: kubectl get ns lists both.
Concept: Multi-tenancy
3Install Argo CD into platform
Apply the stable install manifest with -n platform, then kubectl -n platform rollout status deploy/argocd-server.
Done when: kubectl -n platform get pods shows every Argo CD pod Running.
4Install the argocd CLI and log in
Read the bootstrap password from argocd-initial-admin-secret in platform, port-forward svc/argocd-server, and argocd login localhost:8080.
Done when: argocd app list runs without an auth error.
Concept: Argo CD
5Create the platform-capstone repo with the apps/ + ledger/ layout
Commit ledger/namespace.yaml, deployment.yaml, service.yaml, kustomization.yaml, and apps/ledger.yaml exactly as shown above, then push to main.
Done when: the repo on GitHub (or your Git host) shows both folders with all five files.
6Apply the root App-of-Apps
Apply root.yaml pointed at apps/ with directory.recurse: true. This is the only manifest you apply by hand.
Done when: kubectl -n platform get applications lists both root and ledger, both Synced/Healthy.
7Confirm the ledger placeholder is running
kubectl -n ledger get deploy,svc.
Done when: deployment.apps/ledger shows 2/2 ready and a ledger Service exists.
8Prove self-heal against a scale drift
kubectl -n ledger scale deploy/ledger --replicas=5, then watch it fall back with kubectl -n ledger get deploy ledger -w.
Done when: the replica count returns to 2 within one reconciliation pass, with no command from you.
9Prove self-heal against a deletion
kubectl -n ledger delete svc ledger, then kubectl -n ledger get svc -w.
Done when: the Service reappears without you re-creating it.
10Prove prune by deleting the Service from Git
git rm ledger/service.yaml, update kustomization.yaml, commit, push, then argocd app get ledger --hard-refresh.
Done when: kubectl -n ledger get svc reports no resources found — the live object is gone, not just the Git file.
11Restore it with git revert
Revert the deletion commit and push. Do not run kubectl apply yourself.
Done when: the Service exists again in kubectl -n ledger get svc with zero manual cluster commands.
12Say out loud what state you're leaving for Part 2
Confirm: 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.
Done when: you can describe this state without looking anything up — it's the exact starting point Part 2 assumes.
🐢 Timmy's checkpoint

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
  1. platform. Every Application object's metadata.namespace must match wherever Argo CD itself was installed, or it's rejected as not permitted.
  2. root.yaml, using the App-of-Apps pattern — it points at apps/ with directory.recurse: true, so every later Application is a new file, not a new command.
  3. selfHeal: true defeats manual drift (a hand-edit or hand-scale is reverted); prune: true defeats orphaned resources (a manifest deleted from Git deletes the live object too).
  4. prune: true was missing (or set to false) on that Application's syncPolicy.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.