Here is the thing I never want to do again: ask a cluster what it is running and not trust the answer. For years that was normal. Someone ran kubectl apply, maybe from a laptop, maybe from a CI job nobody could find anymore, and the live state slowly drifted away from anything written down. When I switched to GitOps, that whole category of uncertainty disappeared. I push to Git, and the cluster converges to match. If I want to know what is deployed, I read a file.

ArgoCD is the tool I reach for to make that happen. This post walks from the smallest possible version up to the config I actually run, so you can stop wherever you have enough.

Why Git as the Source of Truth

Start with the old way:

Developer → kubectl apply → Cluster

The question this can’t answer is the important one: what is actually deployed right now? You query the cluster and hope nobody touched it by hand. Drift creeps in quietly. Rollbacks are manual, and the only audit trail is “someone ran kubectl, probably.”

GitOps moves the truth one step back:

Developer → Git Push → ArgoCD → Cluster

Now Git holds the desired state, and ArgoCD’s job is to make reality match it. Change something by hand and ArgoCD puts it back. Want to know what is running? Read the repo. Want to roll back? Revert the commit. This is the same instinct I described in Sovereign Infrastructure: I want systems I can inspect, not systems I have to interrogate. Imperative commands vanish the moment they finish. Declarative state in Git stays, with a history attached.

The Simplest Version

Before any of the concepts, let me get ArgoCD running so you have something to click on. One namespace, one manifest.

# Create namespace
kubectl create namespace argocd

# Install ArgoCD
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for pods to be ready
kubectl wait --for=condition=Ready pods --all -n argocd --timeout=300s

Done. ArgoCD is up. Now grab the password and open the UI:

# Get the initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

# Port-forward the UI
kubectl port-forward svc/argocd-server -n argocd 8080:443

# Open https://localhost:8080, login as 'admin' with the password above

That is enough to look around. Three concepts make sense of what you are seeing.

The Three Concepts You Need

Application

The Application is the unit you work with. It answers three questions: where do the manifests live, where should they go, and how aggressively should ArgoCD keep them in sync.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/yourorg/yourrepo.git
    targetRevision: main
    path: manifests/my-app
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Project

A Project groups applications and sets access boundaries. The default project allows everything, which is fine while you are learning. In production you will want to fence things off so one team can’t deploy into another team’s namespace.

Sync

Sync is the act of making the cluster match Git. It comes in three flavours:

  • Manual: you click the button
  • Automated: ArgoCD acts the moment Git changes
  • Self-healing: ArgoCD also reverts changes made directly against the cluster

Layer 1: Your First Real Deployment

Time to deploy something. I will keep it boring on purpose: an nginx Deployment and Service, managed entirely through Git.

Step 1: Create a Git Repository

Set up a repo (or reuse one) with this layout:

my-gitops-repo/
└── apps/
    └── nginx/
        ├── deployment.yaml
        ├── service.yaml
        └── kustomization.yaml

deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.25
        ports:
        - containerPort: 80

service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
  - port: 80
    targetPort: 80

kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml

Commit and push.

Step 2: Point ArgoCD at It

# argocd-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: nginx
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/YOURUSER/my-gitops-repo.git
    targetRevision: main
    path: apps/nginx
  destination:
    server: https://kubernetes.default.svc
    namespace: nginx
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

Apply it:

kubectl apply -f argocd-application.yaml

Step 3: Watch It Converge

Open the ArgoCD UI and your application shows up. With automated sync on, it deploys immediately. Otherwise hit “Sync.”

flowchart LR
    subgraph dashboard["ArgoCD Dashboard - nginx"]
        direction LR
        DEP["Deployment<br/>nginx"] --> RS["ReplicaSet<br/>nginx"]
        RS --> POD["Pod x2<br/>nginx"]
        DEP --> SVC["Service<br/>nginx"]
    end

The UI draws the resource tree with health and sync status on every node. No guessing about what is live.

The Workflow That Replaces kubectl

This is the part that changed my habits. Want to bump the replica count?

  1. Edit in Git: change replicas: 2 to replicas: 3 in deployment.yaml
  2. Commit and push
  3. ArgoCD detects the change (within 3 minutes by default, or instantly with webhooks)
  4. Cluster updates on its own

No kubectl, no scripts, no nagging worry about whether I actually ran the command. Git is the record.

Rollback Is Just Revert

Something broke after a change? Revert the commit, push, and ArgoCD walks the cluster back.

git revert HEAD
git push

# ArgoCD automatically rolls back the cluster

The whole history of the cluster is now sitting in git log:

git log --oneline apps/nginx/
# a1b2c3d Scale nginx to 3 replicas
# d4e5f6g Initial nginx deployment

Self-Healing in Action

Turn on selfHeal: true and try to outsmart it:

# Manually scale the deployment
kubectl scale deployment nginx -n nginx --replicas=5

# Wait a moment
kubectl get deployment nginx -n nginx
# NAME    READY   UP-TO-DATE   AVAILABLE
# nginx   3/3     3            3

ArgoCD spotted the drift and pulled it back to what Git says. This is the feature that kills the worst failure mode in infrastructure: the setting someone changed last year that nobody remembers and nobody dares touch. If it is not in Git, it does not survive.

Layer 2: Sync Policies in Detail

The sync policy is where you tune how opinionated ArgoCD gets:

syncPolicy:
  automated:
    prune: true      # Delete resources removed from Git
    selfHeal: true   # Revert manual changes
    allowEmpty: false # Don't sync if source is empty
  syncOptions:
    - CreateNamespace=true
    - PruneLast=true  # Prune after other syncs complete
    - ApplyOutOfSyncOnly=true  # Only apply changed resources
  retry:
    limit: 5
    backoff:
      duration: 5s
      factor: 2
      maxDuration: 3m

My advice: start with automated sync, prune, and selfHeal all on. That gives you the full experience, where Git really is the truth and the cluster has no secrets. You can always relax it for a specific application that genuinely needs hands-on handling, but make that the exception you reach for deliberately.

Private Repositories

Real repos are usually private, so ArgoCD needs credentials:

# Using the CLI
argocd repo add https://github.com/yourorg/private-repo.git \
  --username git \
  --password ghp_yourtoken

# Or as a Kubernetes secret
kubectl create secret generic private-repo \
  -n argocd \
  --from-literal=url=https://github.com/yourorg/private-repo.git \
  --from-literal=username=git \
  --from-literal=password=ghp_yourtoken

kubectl label secret private-repo -n argocd \
  argocd.argoproj.io/secret-type=repository

For SSH:

argocd repo add git@github.com:yourorg/private-repo.git \
  --ssh-private-key-path ~/.ssh/id_rsa

Helm and Kustomize

ArgoCD speaks both Helm and Kustomize natively, so you rarely have to flatten anything by hand.

Helm Charts

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: prometheus
  namespace: argocd
spec:
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: prometheus
    targetRevision: 25.0.0
    helm:
      values: |
        server:
          persistentVolume:
            enabled: true
            size: 10Gi
  destination:
    server: https://kubernetes.default.svc
    namespace: monitoring

Kustomize Overlays

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app-production
spec:
  source:
    repoURL: https://github.com/yourorg/yourrepo.git
    path: apps/my-app/overlays/production
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: production

Point it at a directory with a kustomization.yaml and it figures out the rest.

Mistakes I See People Make

Forgetting to Enable Prune

Without prune: true, deleting a manifest from Git leaves the live resource hanging around. You delete the file, assume it is gone, and weeks later trip over the orphan.

Putting Secrets in Git

Never commit plain secrets. Reach for one of these instead:

The “Just This Once” Manual Change

The second you fix something by hand, you have created drift. Either commit the fix to Git or let selfHeal undo it. There is no quiet middle ground that stays clean.

One Giant Application for Everything

A single Application managing your whole platform turns into a mess fast. Split it by service or team so a sync failure in one place does not block everything else.

Where to Go Next

You now have a working GitOps loop. The interesting features build on top of it:

My Recommendation

  1. Start with one application and get the workflow into your fingers.
  2. Enable automated sync plus selfHeal so you feel what Git-as-truth actually does.
  3. Use a dedicated gitops repo, separate from your application source code.
  4. Set up webhooks for sync that triggers on push instead of polling.
  5. Notice when you want manual control and ask what the system is missing instead of reaching for kubectl.

Go back to that first feeling I described, asking a cluster what it is and not trusting the answer. Once the answer lives in Git and the cluster keeps itself honest, that uncertainty is gone, and you stop wanting to go back to it.