GitOps Dev Zone Bootstrap with Argo CD

Deploy to Kubernetes via Git commits only, with self-healing drift correction.

Introduction

30 Second Summary

Every system that moves code to a server has a deployment path. When that path is informal, deployments become untraceable and hand edits survive because nothing is watching.

In this project, you will build a GitOps deployment path on a local Kubernetes cluster where a commit is the only way a resource reaches the development namespace. You will prove the path works by watching Argo CD revert a hand edit in seconds.

What You'll Build

You will push a manifest change, watch the deployment appear in the cluster without ever running kubectl apply, then scale it by hand and watch the engine snap it back within seconds.

By the end of this project, you'll have:

  • A working GitOps path where the only mechanism that deploys to the development namespace is a commit to a Kustomize overlay directory, never a push to the cluster.
  • A self-healing drift drill where a hand edit is automatically reverted within seconds, timed, and the sync event captured from the engine's history as proof.
  • An integrated delivery through one epic and five stories in Linear, each on its own branch through a pull request that closes its ticket on merge, ending in a tagged release with documentation present in the repo.
  • Secret Mission: Promote to integration with manual sync, demonstrating a two-speed model where dev self-heals and higher zones require explicit approval.

Are there any prerequisites?

This project assumes you have Docker Engine running on WSL2, a GitHub account, git, and kubectl installed. You should be familiar with Kubernetes primitives like Pods, Deployments, Services, and Namespaces.

Before We Start

Before any hands-on work begins, let's lock in what you're building and why this particular moment matters.

A federal data platform's development zone is empty right now. That makes this the cheapest hour to install the deployment path that every later zone will inherit. Once workloads are running, changing the shape underneath them costs real coordination. Doing it now costs a commit.

Stand Up the Local Cluster and Reconciliation Engine

The development zone must exist before anything can deploy into it. This step creates the local Kubernetes cluster and installs the reconciliation engine that watches for drift.

You will use kind to spin up the cluster inside Docker containers, then install Argo CD as the engine that pulls desired state from Git and applies it. The engine's initial credential gets rotated and the cleartext secret deleted, which is what Argo CD's own documentation says to do with it.

In this step, get ready to:
  • Create a local Kubernetes cluster using kind.
  • Install Argo CD as the reconciliation engine in its own namespace.
  • Rotate the admin credential and delete the cleartext secret.
Install kind and create the cluster

kind (Kubernetes IN Docker) creates a full cluster using Docker containers as nodes. Check whether kind is already installed by running this command:

kind version

✔️ I see version 0.32.0 or higher

Your kind installation is ready. Move on to creating the cluster below.

ⓧ I see an older version

You have an older version of kind that may not support the features this project uses. Upgrade to v0.32.0 by downloading the current release binary:

curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
  • Run kind version again to confirm it now shows v0.32.0.

ⓧ Command not found

kind is not installed yet. Install v0.32.0 by downloading the release binary:

curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
  • Confirm the install by running:
kind version

You should see output starting with kind v0.32.0.

  • Create the cluster by running this command:
kind create cluster --name argocd-cluster

This takes 1-2 minutes. You should see output ending with a message that the cluster is ready and kubectl is configured to use it.

What does this command do?

kind pulls a node image and runs it as a Docker container. The --name argocd-cluster flag names the cluster so you can reference it later. kind also sets your kubectl context automatically so all future kubectl commands target this cluster.

  • Verify the cluster is running:
kubectl cluster-info --context kind-argocd-cluster

You should see the Kubernetes control plane address and CoreDNS listed as running.

Cluster creation failed?

Confirm Docker Engine is running in WSL2 by running docker ps. If you see a connection error, start Docker Engine first.

If you see a cgroup-related error, check that your WSL2 distribution uses systemd (Ubuntu 22.04+ does by default).

Help me troubleshoot my kind cluster creation error

Install Argo CD into the cluster

Argo CD runs inside the cluster in its own namespace. It needs a dedicated namespace before the components can be applied.

  • Create the argocd namespace by running:
kubectl create namespace argocd
  • Apply the Argo CD stable manifest by running:
kubectl apply -n argocd --server-side --force-conflicts -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

What does this install?

The stable manifest installs all Argo CD components (API server, repo server, application controller, Redis, Dex) into the argocd namespace. These components work together to watch your Git repository and reconcile the cluster toward the declared state.

  • Wait for all Argo CD pods to become ready by running:
kubectl wait --for=condition=Ready pods --all -n argocd --timeout=120s

You should see each pod listed with condition met.

  • Port-forward the Argo CD server to your local machine by running:
kubectl port-forward svc/argocd-server -n argocd 8080:443 &

Why the ampersand?

The & sends the port-forward to the background so you can continue using the same terminal. The forwarding stays active until you kill the process or close the terminal.

🙋‍♀️ Port 8080 already in use?

If another process uses port 8080, stop it first or choose a different local port (e.g. 8443:443). This project assumes 8080 throughout.

Help me free up port 8080

Install the Argo CD CLI and rotate the credential

The CLI lets you manage Argo CD from the terminal. Check whether the Argo CD CLI is already installed:

argocd version --client

✔️ I see version v3.5 or higher

Your Argo CD CLI is ready. Skip ahead to retrieving the initial password below.

ⓧ I see an older version

This project uses Argo CD v3.5.0. Update to the latest stable version by running:

VERSION=$(curl -L -s https://raw.githubusercontent.com/argoproj/argo-cd/stable/VERSION)
curl -sSL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/download/v$VERSION/argocd-linux-amd64
sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd
rm argocd-linux-amd64
  • Confirm the update by running argocd version --client again.

ⓧ Command not found

Install the Argo CD CLI by downloading the latest stable release:

VERSION=$(curl -L -s https://raw.githubusercontent.com/argoproj/argo-cd/stable/VERSION)
curl -sSL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/download/v$VERSION/argocd-linux-amd64
sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd
rm argocd-linux-amd64
  • Confirm the install by running:
argocd version --client

You should see a version starting with v3.5.

  • Retrieve the initial admin password by running:
argocd admin initial-password -n argocd

Copy the password from the output. You will use it to log in and immediately change it.

  • Log in to Argo CD from the CLI by running:
argocd login localhost:8080 --insecure

Why --insecure?

The port-forward uses the server's self-signed TLS certificate. The --insecure flag skips certificate verification for this local connection. You would never use this flag against a production server.

When prompted, enter admin as the username and paste the initial password. You should see a login success message.

  • Change the admin password by running:
argocd account update-password

Enter the current (initial) password when prompted, then choose a new password you will remember.

  • Delete the cleartext initial secret by running:
kubectl -n argocd delete secret argocd-initial-admin-secret

Why delete this secret?

The Argo CD documentation states: the secret stores the initially generated password in clear text and serves no other purpose. Deleting it removes a credential that is no longer needed from the cluster.

Before you verify, what do you think will happen if you try to retrieve the initial password now that the secret is gone?

  • Confirm the secret is gone by running:
argocd admin initial-password -n argocd

You should see an error containing not found. This confirms the cleartext secret no longer exists in the cluster.

  • Open https://localhost:8080 in your browser.
  • Accept the self-signed certificate warning.
  • Log in with username admin and the new password you set.

You should see the Argo CD dashboard with no applications listed yet. The engine is installed, the credential is rotated, and the cleartext secret is gone.

Browser won't connect to localhost:8080?

Check that the port-forward process is still running by typing jobs in your terminal. If it is not listed, re-run the port-forward command from earlier.

If you see a timeout, wait 30 seconds and try again. The server sometimes takes a moment to become responsive after first boot.

Help me connect to the Argo CD UI

Close Story 1 in Linear

Story 1 (Cluster+Engine) predates the repository, so this ticket is closed by confirmation rather than by PR merge.

  • Switch to your Linear project.
  • Open the Story 1 ticket (Cluster+Engine).
  • Change its status to Done.

Why close by confirmation instead of PR?

The cluster and engine install happened before the repository existed. There is no commit to close this ticket automatically. The confirmation is what you just proved: the engine runs, the credential is rotated, and the UI accepts the new password.

Your cluster is live and the reconciliation engine is installed with its credential hardened. Next up, you will build the deployment repository with the manifest tree and documentation set that the engine will watch.

Build the Deployment Repository

A GitOps system requires its desired state to live in version control, separate from application source code. If deployment manifests share a repository with the application, a CI build that updates a manifest tag triggers a new commit, which triggers a new build, creating a loop.

In this step, you will create the manifest repository that the reconciliation engine reads from. You will also shape the overlay directories for three zones now, while the decision is cheap, because that shape is the part most likely to be argued about later when an environment is late.

In this step, get ready to:
  • Create a private GitHub repository with a Kustomize overlay tree shaped for three zones.
  • Write the decision records, topology diagram, and documentation set.
  • Generate a read-only Personal Access Token and merge the first PR.
Create the repository and build the Kustomize tree

The deployment repository holds nothing but manifests. No application source lives here. Splitting it into its own repository of its own is what the arrival of a second zone triggers, named in DR-002 rather than left to be discovered.

  • Open github.com in your browser.
  • In the upper-right corner, click the + menu and select New repository.
  • In the Repository name field, type platform-deploy.
  • Set visibility to Private.
  • Leave Add a README file unchecked (you will populate the repo from the command line).
  • Click Create repository.

GitHub shows the Quick Setup page with the remote URL for your empty repository.

  • Move to your Desktop and clone the empty repository by running these commands:
cd ~/Desktop
git clone https://github.com/[[GITHUB_USER="your-github-username"]]/platform-deploy.git
cd platform-deploy
  • Create the working branch for Story 2 by running:
git checkout -b story/02-repo-and-docs
  • Create the full directory structure by running:
mkdir -p base overlays/dev overlays/int overlays/prod docs/decisions
  • Confirm the directories exist by running:
ls -R

You should see the base/, overlays/dev/, overlays/int/, overlays/prod/, and docs/decisions/ directories listed.

Now you will populate the base layer with an nginx workload that proves the deployment path without introducing application complexity.

  • Create the Deployment manifest by running:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.27
        ports:
        - containerPort: 80

What does this manifest do?

This defines a single-replica Deployment running the nginx:1.27 image. It is the simplest workload that proves the path works.

The label app: nginx links the Deployment's pod selector to the Service you create next.

  • Create the Service manifest by running:
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
  - port: 80
    targetPort: 80
  • Create the base Kustomization file that ties the two resources together by running:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
  • Verify the base layer renders correctly by running:
kubectl kustomize base/

You should see the full YAML for both the Deployment and the Service printed to your terminal.

Seeing an error about missing resources?

Confirm that base/kustomization.yaml lists both deployment.yaml and service.yaml under resources:. File names are case-sensitive.

help me troubleshoot my kubectl kustomize base/ error

Next, create the dev overlay that targets the dev namespace.

  • Create the namespace resource for the dev overlay:
apiVersion: v1
kind: Namespace
metadata:
  name: dev
  • Create the dev overlay's kustomization file:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dev
resources:
- ../../base
- namespace.yaml

What does the dev overlay add?

The namespace: dev field injects a namespace into every resource rendered from the base. The namespace.yaml resource tells Argo CD to create the namespace itself when it syncs.

  • Verify the dev overlay renders a Namespace, Deployment, and Service by running:
kubectl kustomize overlays/dev

You should see three resources printed: a Namespace named dev, a Deployment with namespace: dev, and a Service with namespace: dev.

Now create the integration and production overlays. These are deliberately empty of patches. Their value is that when higher zones arrive under schedule pressure, promotion is a patch in a directory that already exists.

  • Create the integration overlay kustomization:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: int
resources:
- ../../base
  • Create the integration README explaining why the overlay is empty:
# Integration Overlay

This overlay is deliberately empty of patches. It exists now so that when
the integration zone arrives, promotion is a patch in a directory that already
exists rather than a design conversation held under pressure by whoever
happens to be free.

The kustomization.yaml references the base and sets the namespace. Add
patches here when the zone is provisioned.
  • Create the production overlay kustomization:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base
  • Create the production README:
# Production Overlay

This overlay is deliberately empty of patches. It exists now so that when
the production zone arrives, promotion is a patch in a directory that already
exists rather than a design conversation held under pressure by whoever
happens to be free.

The kustomization.yaml references the base and sets the namespace. Add
patches here when the zone is provisioned.
  • Confirm all overlay kustomizations are valid by running:
kubectl kustomize overlays/int && kubectl kustomize overlays/prod

Both commands should print valid YAML with their respective namespaces (int and prod).

Why create empty overlays now?

This is DR-003: the decision to shape the tree before the zones exist. The alternative is to create overlays when zones are provisioned. The tradeoff is empty directories in the repo. The consequence is that promotion becomes a patch in an existing directory rather than a design conversation held under pressure.

Write the pre-artifacts and documentation set

The documentation set ships with the work in the same PR, not after it. Every page carries a reviewed date and every claim traces to a file, a command, or a named source.

  • Create the four decision records in docs/decisions/. Each record names the alternative it beat, the tradeoff accepted, the consequence, and the reversal trigger. Create DR-001.md:
# DR-001: Reconciliation Engine Selection

**Status:** Accepted
**Date:** 2026-08-11

## Decision
Argo CD chosen over Flux CD.

## Alternative beaten
Flux CD (Apache 2.0, CNCF Graduated).

## Tradeoff accepted
Heavier install footprint.

## Consequence
UI allows a reviewer to inspect sync status, diff, and history
without handing over cluster credentials.

## Reversal trigger
Flux CD gains equivalent built-in UI capability and the team
prefers the lighter footprint.

What makes a good decision record?

A decision record is not a justification paragraph. It names the alternative, the tradeoff, the consequence, and the trigger that would reverse it. That structure lets a future reader decide whether the trigger has fired without re-arguing the original decision.

  • Create DR-002.md:
# DR-002: Separate Deploy Repository

**Status:** Accepted
**Date:** 2026-08-11

## Decision
Desired state separated from application source.

## Alternative beaten
Monorepo with app source and manifests together.

## Tradeoff accepted
Two repositories to maintain.

## Consequence
Build cannot retrigger itself. Code changes and deployment
changes never share one history.

## Reversal trigger
Single-zone platform with no promotion path.
  • Create DR-003.md:
# DR-003: Empty Overlay Directories

**Status:** Accepted
**Date:** 2026-08-11

## Decision
Int and prod overlay directories created now, deliberately empty.

## Alternative beaten
Create overlays when zones are provisioned.

## Tradeoff accepted
Empty directories in repo.

## Consequence
Promotion is a patch in an existing directory rather than a
design conversation held under pressure.

## Reversal trigger
Zones are never provisioned.
  • Create DR-004.md:
# DR-004: Self-Heal in Development Only

**Status:** Accepted
**Date:** 2026-08-11

## Decision
Automatic sync with self-heal in development only. Manual sync
everywhere else.

## Alternative beaten
Automatic sync in all zones.

## Tradeoff accepted
Manual approval step required for int and prod.

## Consequence
Drift is corrected automatically in dev. Higher zones require
explicit approval before any change reaches the namespace.

## Reversal trigger
Approval process is automated with equivalent audit trail.
  • Confirm all four decision records exist by running:
ls docs/decisions/

You should see DR-001.md, DR-002.md, DR-003.md, and DR-004.md listed.

Now create the topology diagram and the documentation set. Each file carries a reviewed date and traces its claims to files or commands in this repository.

  • Create the topology diagram marking which edges this hour builds and which remain unbuilt:
# Topology Diagram

**Reviewed:** 2026-08-11

## Edges built this hour
- kind cluster (argocd-cluster)
- argocd namespace with Argo CD installed
- dev namespace (created by engine via GitOps path)
- GitOps path: platform-deploy repo -> Argo CD -> dev namespace

## Edges NOT built (remain unbuilt)
- int zone deployment (overlay exists, no Application)
- prod zone deployment (overlay exists, no Application)
- Cross-account boundaries
- Admission policy integration
- Platform guardrails
  • Create the remaining documentation set files. Each file should carry a reviewed date and reference specific files or commands. Create them by running:
cat > docs/architecture.md << 'EOF'
# Architecture at a Glance

**Reviewed:** 2026-08-11

Desired state lives in `platform-deploy` (this repo). Argo CD reads
`overlays/dev/` and reconciles the dev namespace. No push to cluster.
See `docs/topology.md` for the full edge map.
EOF

cat > docs/quickstart.md << 'EOF'
# Quickstart

**Reviewed:** 2026-08-11

1. `kind create cluster --name argocd-cluster`
2. Install Argo CD: see `docs/operations.md`
3. Register repo: `argocd repo add <URL>`
4. Apply project and app: `kubectl apply -f argocd/`
EOF

cat > docs/operations.md << 'EOF'
# Operations Guide

**Reviewed:** 2026-08-11

## Sync status
`argocd app get platform-dev-app`

## Force sync
`argocd app sync platform-dev-app`

## View history
`argocd app history platform-dev-app`
EOF
cat > docs/runbook.md << 'EOF'
# Runbook

**Reviewed:** 2026-08-11

Handover test: a person who did not build this deploys a change
working from this document alone while the author stays quiet.
Every question they ask is a defect in this document.

## Deploy a change
1. Edit manifests in `overlays/dev/`
2. Commit and push to `main`
3. Watch: `argocd app get platform-dev-app`
4. Confirm pods: `kubectl get pods -n dev`
EOF

cat > docs/decision-index.md << 'EOF'
# Decision Index

**Reviewed:** 2026-08-11

| ID | Title | File |
|----|-------|------|
| DR-001 | Engine selection | docs/decisions/DR-001.md |
| DR-002 | Separate deploy repo | docs/decisions/DR-002.md |
| DR-003 | Empty overlays now | docs/decisions/DR-003.md |
| DR-004 | Self-heal dev only | docs/decisions/DR-004.md |
EOF

cat > docs/configuration-reference.md << 'EOF'
# Configuration Reference

**Reviewed:** 2026-08-11

| Parameter | File | Value |
|-----------|------|-------|
| Sync policy (dev) | argocd/application-dev.yaml | automated, selfHeal |
| Destination namespace | overlays/dev/kustomization.yaml | dev |
| Source path | argocd/application-dev.yaml | overlays/dev |
EOF
cat > docs/fidelity-note.md << 'EOF'
# Fidelity Note

**Reviewed:** 2026-08-11

This rehearsal does NOT reproduce:

1. Platform guardrails and admission policy
2. Cross-account encryption-key semantics
3. Impact-level baselines
4. Real provisioning lead times
5. The authorization process itself
6. The limit that one cluster cannot show a zone permission
   boundary holds

The drift revert proves correction in one namespace, not that the
real platform will accept these manifests.
EOF
  • Confirm the full documentation set exists by running:
ls docs/

You should see: architecture.md, configuration-reference.md, decision-index.md, decisions/, fidelity-note.md, operations.md, quickstart.md, runbook.md, and topology.md.

Why ship documentation with the work?

Documentation written after the fact drifts from reality within a sprint. Shipping it in the same PR means it is reviewed alongside the code, carries the same merge timestamp, and cannot be deferred.

Create the PAT, open the PR, and merge

Argo CD needs to read this private repository over HTTPS. A fine-grained Personal Access Token scoped to read-only on this single repository is the smallest credential that works.

  • In GitHub, click your profile photo in the upper-right corner.
  • Click Settings.
  • In the left sidebar, click Developer settings.
  • Under Personal access tokens, click Fine-grained tokens.
  • Click Generate new token.
  • Under Token name, enter argocd-platform-deploy-read.
  • Set Expiration to 90 days (or your preferred duration).
  • Under Repository access, select Only select repositories and choose platform-deploy.
  • Under Permissions, expand Repository permissions and set Contents to Read-only.
  • Click Generate token.
  • Copy the token value and store it securely (you will need it in the next step to register the repo with Argo CD). Do not commit it to any file.

Why contents:read only?

Argo CD only needs to clone and read manifests from the repository. Granting contents:read is the smallest permission that works. If the token is compromised, an attacker can read manifests but cannot modify them.

Now commit everything and push to open the pull request.

  • Stage and commit all files by running:
git add -A
git commit -m "feat: kustomize tree, decision records, and documentation set"
  • Push the branch to GitHub by running:
git push -u origin story/02-repo-and-docs
  • Open your browser and navigate to your platform-deploy repository on GitHub.
  • GitHub shows a banner prompting you to open a pull request for the recently pushed branch. Click Compare & pull request.
  • Set the base branch to main and the compare branch to story/02-repo-and-docs.
  • Add a title like Story 2: Repo, overlays, and documentation set.
  • Click Create pull request.
  • Click Merge pull request, then Confirm merge.
  • In Linear, move Story 2 (Repo+Docs) to Done.

Before you move on, what do you expect kubectl kustomize overlays/dev to produce? Think about which resources and which namespace should appear.

  • Run the final verification from your local repo root:
kubectl kustomize overlays/dev

You should see a Namespace, a Deployment, and a Service, all targeting namespace dev. That output is exactly what Argo CD will render and apply in the next step.

PR merge failed or Story 2 not closing?

If the merge button is disabled, confirm that the base branch is main and that you have push access to the private repo. On the free tier, branch protection rules are not active unless you enabled them manually.

help me debug why my GitHub PR won't merge

✔️ Awesome, I've got everything!

Great. Confirm you have saved all files and your PR is merged to main.

ⓧ I'd like to double check the full code

Your repository should contain all of the following files. Scroll up and confirm each one matches:

  • base/deployment.yaml (nginx Deployment, 1 replica, image nginx:1.27).
  • base/service.yaml (Service on port 80).
  • base/kustomization.yaml (references both base resources).
  • overlays/dev/namespace.yaml (Namespace named dev).
  • overlays/dev/kustomization.yaml (namespace dev, references base and namespace.yaml).
  • overlays/int/kustomization.yaml (namespace int, references base).
  • overlays/int/README.md (explains why the overlay is empty).
  • overlays/prod/kustomization.yaml (namespace prod, references base).
  • overlays/prod/README.md (explains why the overlay is empty).
  • docs/decisions/DR-001.md through DR-004.md (four decision records).
  • docs/topology.md, docs/architecture.md, docs/quickstart.md, docs/operations.md, docs/runbook.md, docs/decision-index.md, docs/configuration-reference.md, docs/fidelity-note.md.

Your manifest repository is built, reviewed, and merged. Next, you will point Argo CD at this repository and fence it so that only this source can reach the dev namespace.

Connect the Repository and Fence the Application

Argo CD cannot read manifests directly from your workstation filesystem. It pulls desired state from a Git repository over HTTPS, authenticating with a read-only token registered once at install.

To enforce the principle of least privilege, you create an AppProject that fences the application to exactly one source repository and one destination namespace. This is the smallest permission that works.

In this step, get ready to:
  • Register the private repository in Argo CD with the read-only PAT.
  • Apply an AppProject manifest that restricts the application to one repo and one namespace.
  • Prove the fence by attempting a violation and observing the refusal.
Register the repository in Argo CD

The engine needs a one-time registration of the repository URL and credentials. After this, it can pull manifests whenever it needs to reconcile.

  • Create the working branch for Story 3 by running this command:
  • Create the branch for Story 3 by running:
git checkout -b story/03-connect-fence
  • Register the private repository in Argo CD by running this command:
argocd repo add [[REPO_URL="https://github.com/your-username/platform-deploy.git"]] --username argocd --password your-pat-here

What does this command do?

The argocd repo add command registers the repository URL and credentials with the Argo CD server. The engine stores these credentials internally and uses them whenever it needs to pull manifests.

The --username argocd value is arbitrary for GitHub PAT authentication. GitHub authenticates using the token itself, not the username. The --password flag takes your read-only PAT.

  • Verify the connection succeeded by running:
argocd repo list

You should see your repository URL listed with a STATUS of Successful.

Seeing a Failed status?

  • Double-check that your PAT has contents:read scope and is scoped to the correct repository.
  • Verify the URL matches exactly, including the .git suffix. HTTPS URLs with and without .git are treated as different sources.
  • Make sure your PAT has not expired.

Still stuck? Why is my Argo CD repository connection failing with my GitHub PAT?

Apply the AppProject manifest

The AppProject is the permission fence. It declares which repository the engine may pull from and which namespace it may deploy into. Anything outside these boundaries is refused.

  • Create the argocd/ directory inside your platform-deploy repository by running:
mkdir -p argocd
  • Create the file argocd/project.yaml with the following content:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: platform-dev
  namespace: argocd
spec:
  description: Development zone deployment fence
  sourceRepos:
  - [[REPO_URL="https://github.com/your-username/platform-deploy.git"]]
  destinations:
  - namespace: dev
    server: https://kubernetes.default.svc
  clusterResourceWhitelist:
  - group: ''
    kind: Namespace

What does this manifest do?

  • sourceRepos restricts which Git repositories Applications in this project can pull from. Only your one deploy repo is listed.
  • destinations restricts which namespaces the engine may deploy into. Only the dev namespace on the local cluster is permitted.
  • clusterResourceWhitelist allows the engine to create Namespace resources (cluster-scoped). Without this, the engine cannot create the dev namespace automatically.
  • Apply the AppProject to the cluster by running:
kubectl apply -f argocd/project.yaml
  • Verify the project was created by running:
argocd proj get platform-dev -o yaml

You should see the sourceRepos list containing exactly your repository URL and the destinations list containing exactly the dev namespace.

Project not showing up?

  • Make sure the namespace: argocd field is present in the metadata. All AppProject resources must live in the argocd namespace.
  • Check that kubectl apply did not report a validation error. The apiVersion must be argoproj.io/v1alpha1.

Need help? Why is my AppProject not appearing in Argo CD after kubectl apply?

Prove the fence by attempting a violation

The proof that a permission boundary holds is demonstrated by trying to cross it, not by reading the configuration. You will attempt to create an Application that points at an unauthorized repository and watch the engine refuse.

Before you run the next command, predict: will Argo CD allow you to create an Application that references a repository not listed in the AppProject?

  • Attempt to create an Application pointing at an unauthorized repository by running:
argocd app create bad-app --repo https://github.com/someone/other-repo.git --path . --dest-server https://kubernetes.default.svc --dest-namespace dev --project platform-dev

The command fails. You see an error indicating that the repository is not permitted in the project.

Why does this matter?

This is the proof that the permission boundary holds. If someone (or some automation) tries to deploy from a source you have not explicitly approved, the engine refuses. The fence works by allow-list, not by trust.

On a real platform this means a build pipeline cannot trick the engine into pulling from an arbitrary repository. Only the approved source can place resources in the development namespace.

  • Stage, commit, and push your changes by running:
git add argocd/project.yaml
git commit -m "Add AppProject fence for development zone"
git push origin story/03-connect-fence
  • Open a pull request from story/03-connect-fence to main on GitHub.
  • Merge the pull request.
  • In Linear, confirm Story 3 is marked closed by the merge.

✔️ Awesome, I've got everything!

Great. Double check you have saved argocd/project.yaml and that your PR is merged.

ⓧ I'd like to double check the full code

Here is the complete argocd/project.yaml file:

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: platform-dev
  namespace: argocd
spec:
  description: Development zone deployment fence
  sourceRepos:
  - https://github.com/your-org/platform-deploy.git
  destinations:
  - namespace: dev
    server: https://kubernetes.default.svc
  clusterResourceWhitelist:
  - group: ''
    kind: Namespace

The repository is connected, the fence is proven, and Story 3 is closed. Next up, you will deploy a service through the GitOps path where the only mechanism is a commit to the manifest directory.

Deploy Through the GitOps Path

The repository is registered, the fence is in place, and the engine is watching. Now comes the defining property of this entire system: a resource reaches the development namespace by a commit to the manifest directory, with no push to the cluster at any point.

In this step you will commit an Application manifest with automated sync enabled but without self-heal. The engine pulls on Git changes and deploys what it finds. It does not yet correct live drift. That distinction matters in the next step.

In this step, get ready to:
  • Commit the Application manifest with automated sync and CreateNamespace=true.
  • Watch the engine pull from Git and deploy to the dev namespace.
  • Verify the workload is running without any direct cluster push.
Create the Application manifest on a new branch

The Application custom resource tells Argo CD which repository path to watch, which cluster namespace to deploy into, and what sync policy to apply. You will write this manifest on a dedicated branch and push it through a pull request, just like the previous stories.

  • Create and switch to a new branch by running:
git checkout -b story/04-deploy
  • Create the file argocd/application-dev.yaml with the following content:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-dev-app
  namespace: argocd
spec:
  project: platform-dev
  source:
    repoURL: [[REPO_URL="https://github.com/your-username/platform-deploy.git"]]
    targetRevision: main
    path: overlays/dev
  destination:
    server: https://kubernetes.default.svc
    namespace: dev
  syncPolicy:
    automated: {}
    syncOptions:
    - CreateNamespace=true

What does this manifest do?

  • project: platform-dev binds this Application to the AppProject fence you created in Step 3. The engine will refuse to sync if the source or destination falls outside that fence.
  • source tells the engine to watch the overlays/dev path on the main branch of your repository.
  • syncPolicy.automated: {} enables automated sync. The engine deploys whenever it detects a new commit on the watched path. The empty object means self-heal is NOT enabled.
  • CreateNamespace=true allows the engine to create the dev namespace if it does not already exist.

💡 Why no selfHeal yet?

Automated sync pulls from Git on change. Self-heal reverts live drift even when no new commit exists. You are enabling them separately so you can see the difference in the next step. DR-004 documents this split: development gets both, higher zones get neither.

  • Commit and push the new file by running:
git add argocd/application-dev.yaml
git commit -m "Add dev Application with automated sync"
git push -u origin story/04-deploy

You should see Git confirm the new branch was pushed to origin.

Push rejected or permission error?

  • Confirm you are on the story/04-deploy branch by running git branch. The active branch should have an asterisk next to it.
  • If Git asks for credentials, use your GitHub username and the Personal Access Token you created in Step 2.
  • help me debug a git push error to my private GitHub repository

✔️ Awesome, I've got everything!

Great. Double check you have saved argocd/application-dev.yaml and that the push succeeded.

ⓧ I'd like to double check the full code

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-dev-app
  namespace: argocd
spec:
  project: platform-dev
  source:
    repoURL: https://github.com/your-org/platform-deploy.git
    targetRevision: main
    path: overlays/dev
  destination:
    server: https://kubernetes.default.svc
    namespace: dev
  syncPolicy:
    automated: {}
    syncOptions:
    - CreateNamespace=true
Merge the PR and watch the engine sync

Once you merge this manifest into main, the engine detects the new Application resource and begins reconciling it. No one runs kubectl apply against the dev namespace. The engine does everything.

  • Open a pull request from story/04-deploy to main in your GitHub repository.
  • Merge the pull request.
  • In Linear, confirm that Story 4 is marked closed.

The engine polls for changes on a default interval of 3 minutes. It may take a moment to detect the new commit.

  • Check the sync status by running:
argocd app get platform-dev-app

You should see the Sync Status show Synced and the Health Status show Healthy. If it still shows OutOfSync or Progressing, wait 30 seconds and run the command again.

What just happened?

The engine saw the Application resource appear on main. It read the overlays/dev path, ran Kustomize to render the base plus the dev overlay, and applied the resulting Namespace, Deployment, and Service to the cluster. You did not touch kubectl apply. The engine did all the work.

🙋‍♀️ Status stuck on Unknown or ComparisonError?

  • Run argocd app get platform-dev-app and check the CONDITIONS section for error details. A common cause is the repoURL in application-dev.yaml not matching the repository URL registered in Argo CD.
  • Confirm the port-forward is still active. If your terminal session ended, restart it with kubectl port-forward svc/argocd-server -n argocd 8080:443.
  • help me debug why my Argo CD Application is not syncing
Verify the deployment reached the namespace

The engine claims it synced successfully. Now confirm that actual resources exist in the dev namespace, deployed entirely by the reconciliation engine with no manual push.

Before you run the next command, what do you expect to see in the dev namespace? How many replicas should exist?

  • Check the deployment by running:
kubectl get deployment -n dev

You should see a deployment named nginx with 1/1 replicas ready. That matches the replicas: 1 in your base/deployment.yaml.

  • Confirm the pod is running:
kubectl get pods -n dev

You should see one pod with a name starting with nginx- in Running status.

What did this prove?

A service reached the development namespace entirely by a commit to the manifest directory. You never ran kubectl apply against the dev namespace. The engine read Git, rendered the Kustomize overlay, and applied the result. That is the GitOps deployment path working as designed.

The automated sync policy means any future commit to overlays/dev on main triggers a new reconciliation. But because self-heal is off, a hand edit to the live cluster will persist until the next Git commit arrives.

  • Open https://localhost:8080 in your browser and log in. Confirm the platform-dev-app Application tile shows a green Synced badge and a Healthy heart icon.

Don't see the nginx pod running?

  • Check if the dev namespace exists by running kubectl get ns dev. If it does not exist, the CreateNamespace=true sync option may not be set in your Application manifest.
  • Verify the AppProject allows Namespace as a cluster-scoped resource. Run argocd proj get platform-dev -o yaml and confirm clusterResourceWhitelist includes kind Namespace.
  • help me figure out why my nginx pod is not appearing in the dev namespace

A service is running in the development namespace and you never touched the cluster directly. Next up, you will test what happens when someone does touch the cluster directly, and watch the engine respond.

Run the Drift Drill

Your deployment reached the namespace through a commit. Nobody ran kubectl apply against the dev namespace at any point.

But what happens when someone edits the cluster directly? A hand scale, a label patch, a quick fix that never touches Git. This step tests what the engine does about that kind of drift.

In this step, get ready to:
  • Demonstrate that automated sync alone does not revert a hand edit.
  • Enable self-heal and prove the engine reverts drift within seconds.
  • Capture the sync event as a proof artifact, close Story 5, and tag the release.
Scale the deployment by hand

The Application currently has automated sync enabled but selfHeal is false. You are about to bypass the GitOps path by editing the cluster directly.

Before you run this, what do you think the engine will do? Will it revert your hand edit, or just notice it?

  • Scale the nginx deployment to 3 replicas by running this command:
kubectl scale deployment/nginx -n dev --replicas=3
  • Confirm the replicas scaled up by running:
kubectl get deployment nginx -n dev

You should see 3/3 in the READY column. The cluster accepted your hand edit.

  • Now check the Application status by running:
argocd app get platform-dev-app

The status shows OutOfSync. The engine detected that the live state diverged from Git.

  • Wait 30 seconds, then check the deployment again:
sleep 30 && kubectl get deployment nginx -n dev

Replicas still show 3. The engine saw the drift but did NOT revert it.

Why didn't the engine fix the drift?

Automated sync without self-heal only triggers on a new Git commit. It detects that the live state is out of sync, but it waits for the next commit to reconcile. No commit means no action.

This is the gap: a hand edit survives indefinitely because nothing is watching and correcting. That is exactly the failure mode DR-004 names as the reason self-heal exists in the development zone.

Enable self-heal and prove the revert

Now you will close that gap. Self-heal tells the engine to reconcile the cluster back to the Git-declared state whenever drift is detected, whether or not a new commit triggered the check.

  • Enable self-heal on the Application by running:
argocd app set platform-dev-app --self-heal

What does self-heal do?

With self-heal enabled, the engine compares the live cluster state against the Git-declared desired state every 5 seconds by default. If it detects drift, it immediately re-applies the manifests from Git without waiting for a new commit.

  • First, confirm the replicas are back to 1 after the previous sync (self-heal just corrected the earlier drift):
kubectl get deployment nginx -n dev

You should see 1/1 READY. The engine already corrected the 3-replica drift from the previous substep.

Now you will scale again and time how fast the engine reverts. What do you expect to happen this time?

  • Scale the deployment to 5 replicas and immediately watch the replica count by running:
kubectl scale deployment/nginx -n dev --replicas=5 && echo "Scaled at $(date +%T)" && sleep 6 && kubectl get deployment nginx -n dev && echo "Checked at $(date +%T)"

The READY column should show 1/1 again. The engine reverted your hand edit within 5 seconds. Note the timestamps to confirm the elapsed time.

What just happened?

The Argo CD application controller detected the live state diverging from Git-declared state. Because self-heal is enabled, it immediately re-applied the manifests. The default self-heal timeout is 5 seconds.

This is the self-healing reconciliation loop in action. No human intervened. No new commit was pushed. The engine saw drift and corrected it autonomously.

🙋‍♀️ Replicas not reverting to 1?

Wait a few more seconds and check again. The self-heal timeout is 5 seconds, but network latency in a local cluster can add a moment.

Confirm self-heal is actually enabled by running argocd app get platform-dev-app and checking that the Sync Policy line shows Automated with Self Heal.

Help me troubleshoot why self-heal isn't reverting my drift.

Capture the proof and close the build

You have a timed, observable revert. Now you need to capture the engine's own record of that event as an artifact, commit it, and close the final story.

  • Create the story/05-drift-drill branch by running:
git checkout -b story/05-drift-drill
  • Capture the sync history as your drift evidence artifact by running:
argocd app history platform-dev-app > docs/drift-evidence.txt
echo "" >> docs/drift-evidence.txt
echo "Elapsed revert time: approximately 5 seconds" >> docs/drift-evidence.txt
  • Verify the file was created by running:
cat docs/drift-evidence.txt

You should see a table showing the sync history entries, including the most recent automated self-heal sync event with its timestamp.

Why capture the evidence this way?

The drift evidence comes from the engine's own history, not from your assertion. This is proof that can be shown to a reviewer: the engine recorded the sync event, and you preserved that record in version control alongside the manifests that define the desired state.

  • Commit and push the evidence by running:
git add docs/drift-evidence.txt
git commit -m "Story 5: drift drill evidence with timed revert"
git push -u origin story/05-drift-drill
  • Open your repository on GitHub.
  • Create a pull request from story/05-drift-drill to main.
  • Merge the pull request. This closes Story 5 in Linear.
  • Switch back to main and tag the release by running:
git checkout main
git pull
git tag v1.0.0
git push origin v1.0.0

You should see the tag push confirmation in your terminal output.

  • Return to your Linear project.
  • Confirm all five stories are closed.
  • Mark the Linear project as Completed.

Git push rejected?

If the push fails because main has diverged, run git pull --rebase before pushing again. If the tag already exists, delete it with git tag -d v1.0.0 and recreate it.

Help me fix my git push issue for the v1.0.0 tag.

Before you confirm the final state, predict what you expect to see: how many sync history entries should exist, and what should the most recent one represent?

  • Run the final verification by running:
argocd app history platform-dev-app

You should see multiple sync entries. The most recent entry is the automated self-heal operation that reverted your 5-replica scale back to 1. That entry is your captured proof that drift correction works.

Completion checklist

Confirm the following. Every PR is merged (5 total). Every Linear story is closed. The Linear project shows Completed. The release is tagged v1.0.0. The documentation set is present in the repo. The drift evidence is captured as an artifact rather than asserted.

Your deployment path is complete. A commit is the only way a resource reaches the namespace. A hand edit is corrected in seconds. The proof is captured, the build is closed, and the release is tagged.

Secret mission

Promote to Integration

The development zone self-heals. But what happens when a change needs to reach a higher zone where automatic deployment is not acceptable? Populate the integration overlay, create a manual-sync Application, and demonstrate the two-speed model alongside the self-healing dev zone.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers and free-tier services. There are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A kind cluster named argocd-cluster running as Docker containers (no cloud cost).
  • A private GitHub repository (free tier, no cost).
  • A GitHub Personal Access Token with contents:read scope (revocable).
  • A Linear project (free tier, no cost).

Keep everything running

No action needed. Choose this if you are still actively building on this deployment path or plan to extend it soon.

  • The kind cluster persists until you stop Docker Engine or explicitly delete it. No cost accrues.
  • The GitHub repository and PAT remain active on the free tier.
  • The Linear project stays accessible on the free plan.

Pause - I'll come back to this later

Shut down running processes to free up memory while preserving your work for later.

  • Stop the port-forward process if it is still running (press Ctrl+C in the terminal where you started it).
  • Stop Docker Engine. The cluster state is preserved in container storage and will resume when Docker starts again.
  • Your GitHub repository, PAT, and Linear project remain intact and ready when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the kind cluster by running this command:
  • Delete the GitHub Personal Access Token. Go to your GitHub profile picture, click Settings, then Developer settings in the left sidebar. Under Personal access tokens, click Fine-grained tokens. Find the token you created for this project and click Delete.
  • Delete the GitHub repository. Navigate to your repository on GitHub, click Settings, scroll to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name.
  • Archive or delete the Linear project. Open the project in Linear, go to its settings, and choose Delete or Archive.
kind delete cluster --name argocd-cluster

What does this command do?

This removes all Docker containers that make up your local Kubernetes cluster. Your kubeconfig context for the cluster is also cleaned up.

Nice Work!

Nice Work!

You just bootstrapped a full GitOps deployment path for a development zone, delivered it through tracked pull requests, and proved it works by watching the engine revert your own hand edit in seconds.

You've learned how to:

  • Deploy a service through a GitOps path where the only mechanism that places a resource in the namespace is a commit to the manifest directory, never a push to the cluster.
  • Run a drift drill with Argo CD self-heal enabled, time the automatic revert, and capture the sync event from the engine's own history as a proof artifact.
  • Deliver infrastructure work through Linear stories on their own branches, each closed by a merged PR, ending in a tagged release (v1.0.0) with the documentation set present in the repository.
  • Secret Mission: Promoted to integration with manual sync, demonstrating the two-speed model where dev self-heals and higher zones require explicit approval. Wrote the readout for two audiences.

Ready to quiz yourself?