Hands-on Kubernetes & DevOpsPart 3 of 5 — open for all chapters

January 30, 2026

GitOps with ArgoCD: How Real Teams Deploy to Kubernetes

Make Git the single source of truth for cluster state: no more kubectl apply from your laptop, with audit trails and drift correction.

Quick navigation

The Problem with Manual Deployments

After building a CI/CD pipeline in the previous post, deployments looked like this:

git push
↓
GitHub Actions builds image
↓
kubectl apply -f k8s/ ← the problem

That last step — kubectl apply from a developer's laptop — breaks several things at once:

No audit trail. Who deployed what and when? Check the Slack logs and hope someone mentioned it.

Configuration drift. What if someone runs kubectl edit deployment to fix something urgently and forgets to update the Git manifests? Now Git and the cluster disagree silently.

No single source of truth. Is the running cluster in sync with main branch? With the staging branch? With a hotfix someone applied at 2am? Unknown.

GitOps solves all of this with one rule:

Git is the only source of truth. The cluster must always match what's in Git. Nobody kubectl applies manually.


What is GitOps?

GitOps is a deployment pattern where:

  1. All desired cluster state lives in Git as YAML

  2. An agent runs inside the cluster and watches that Git repo

  3. When Git changes, the agent applies the diff automatically

  4. If the cluster drifts from Git (manual changes, crashes), the agent corrects it

The key mental shift: you don't deploy to Kubernetes, you commit to Git. The cluster pulls its own state rather than having state pushed to it.

ArgoCD is the most widely adopted GitOps agent. It's a Kubernetes-native tool that watches Git repos and continuously reconciles cluster state to match them.


Project Structure for GitOps

The first change is separating application code from infrastructure manifests. GitOps works best when K8s config has its own clear home:

k8s-portfolio/
├── app/ # application source code
│ ├── index.js
│ └── package.json
├── Dockerfile
├── gitops/ # ArgoCD watches this folder
│ └── my-api/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ └── servicemonitor.yaml
├── argocd-app.yaml # defines the ArgoCD Application
└── .github/
└── workflows/
└── deploy.yml

Some teams go further and use two completely separate repos — one for app code, one for K8s manifests. This lets you control who can change infrastructure separately from who can change application code. For a single-developer project, one repo with clear folder separation works fine.


Installing ArgoCD

kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl get pods -n argocd --watch

ArgoCD installs several components:

  • argocd-server — the UI and API

  • argocd-application-controller — the reconciliation loop that compares Git to cluster

  • argocd-repo-server — clones your Git repos and renders manifests

  • argocd-dex-server — handles SSO and authentication

Access the UI:

kubectl port-forward svc/argocd-server -n argocd 8081:443 &

Get the generated admin password:

kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d && echo

Open https://localhost:8081 and login with admin and that password.


Defining an ArgoCD Application

An ArgoCD Application is a Kubernetes custom resource that says: "watch this Git path and keep this namespace in sync with it."

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kaungmyathan22/golang-k8s-portfolio
    targetRevision: HEAD
    path: gitops/my-api # ArgoCD watches this specific folder
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true # delete resources removed from Git
      selfHeal: true # revert manual cluster changes back to Git
    syncOptions:
      - CreateNamespace=true

Two settings define the GitOps contract:

prune: true — if a file is deleted from gitops/my-api/, ArgoCD deletes the corresponding Kubernetes resource. Git says it shouldn't exist, so it doesn't. Without this, deleted manifests leave orphaned resources running forever.

selfHeal: true — if someone manually edits a resource with kubectl edit or kubectl label, ArgoCD reverts it back to what Git says within 3 minutes. This is the enforcement mechanism that makes GitOps a real guarantee rather than a suggestion.

Apply it:

kubectl apply -f argocd-app.yaml

ArgoCD immediately starts comparing gitops/my-api/ to your cluster. Resources in Git but not in the cluster get created. Resources in the cluster but not in Git get pruned (if prune is enabled).


Updating the CI/CD Pipeline

The CI pipeline changes significantly. It no longer deploys anything — it just builds the image and updates the image tag in Git. ArgoCD handles the actual deployment.

- name: Update image tag in GitOps manifests
  run: |
    sed -i "s|image: my-api:.*|image: my-api:$IMAGE_TAG|g" \
      gitops/my-api/deployment.yaml
    git config user.name "github-actions"
    git config user.email "actions@github.com"
    git add gitops/my-api/deployment.yaml
    git commit -m "ci: update image tag to $IMAGE_TAG" || exit 0
    git push

The new flow:

Developer pushes code
↓
GitHub Actions: test → build image → update image tag in Git → push
↓
ArgoCD detects Git change (within 3 minutes)
↓
ArgoCD applies updated deployment.yaml to cluster
↓
Kubernetes performs rolling update

Every deployment is now a Git commit. Every rollback is a git revert. Every change is auditable, reviewable via pull request, and reversible.


Proving Self-Heal Works

This is the moment GitOps becomes real rather than theoretical.

Add a label to your deployment manually:

kubectl label deployment my-api environment=staging --overwrite
kubectl get deployment my-api --show-labels
# app=my-api,environment=staging

Wait 3 minutes:

kubectl get deployment my-api --show-labels
# app=my-api

The environment=staging label is gone. ArgoCD detected the drift from Git — your deployment.yaml doesn't have that label — and reverted it automatically.

In production this prevents a class of incidents that are almost impossible to debug without GitOps: configuration drift. Someone applies a quick fix directly to the cluster during an incident, forgets to update Git, and three weeks later a routine deployment reverts the fix because it was never in the manifests. With selfHeal: true, that scenario can't happen — the cluster always reflects Git within minutes.


Rollback is a Git Operation

With traditional kubectl deployments, rollback means:

kubectl rollout undo deployment/my-api

This works but it's invisible — no audit trail, no code review, no way to see what specifically changed.

With GitOps, rollback means:

git revert HEAD
git push

ArgoCD syncs the revert automatically. The rollback is a commit in Git — reviewable, attributable, and visible in the deployment history alongside every other change. In a post-mortem, you can see exactly what was running at any point in time.


ArgoCD CLI Quick Reference

# Check application status
argocd app get my-api
# List all applications
argocd app list
# Force immediate sync (don't wait 3 min poll)
argocd app sync my-api
# See what differs between Git and cluster
argocd app diff my-api
# Rollback to a specific revision
argocd app rollback my-api <revision>
# Check registered repos
argocd repo list

Real-World Lessons from This Setup

Port-forward is not reliable for long sessions. kubectl port-forward drops connections when idle. For a production ArgoCD setup, expose it via Ingress with a proper TLS certificate. For local development, run the port-forward in a restart loop:

while true; do
kubectl port-forward svc/argocd-server -n argocd 8081:443
sleep 2
done &

Public vs private repos. ArgoCD caches repository authentication state. If a repo starts private and becomes public, ArgoCD may still try to authenticate. Fix it by removing and re-adding the repo via argocd repo rm then argocd repo add. The UI Settings → Repositories page is more reliable than the CLI for this.

Auto-sync polls every 3 minutes. If you need an immediate sync after a push, run argocd app sync my-api. In production teams often set up webhooks so GitHub notifies ArgoCD of pushes immediately rather than waiting for the poll.


What GitOps Changes

Before GitOps, the question "what's running in production?" required asking someone or checking the cluster directly. After GitOps, the answer is always "whatever's in the main branch of the gitops repo."

That single source of truth changes how teams work:

  • Deployments happen via pull request — reviewable, approvable, and linked to the code change that caused them

  • Incidents are easier to diagnose — you can see exactly what changed and when by looking at Git history

  • New engineers can understand the full system — everything is in files, in Git, with history

  • Disaster recovery is fast — point ArgoCD at a new cluster and it rebuilds everything from Git

The operational overhead of setting this up — restructuring repos, updating pipelines, learning ArgoCD — pays back immediately the first time you need to answer "what exactly is running right now and how did it get there."


Part of a hands-on DevOps learning series. Code at github.com/kaungmyathan22/golang-k8s-portfolio.