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
- What is GitOps?
- Project Structure for GitOps
- Installing ArgoCD
- Defining an ArgoCD Application
- Updating the CI/CD Pipeline
- Proving Self-Heal Works
- Rollback is a Git Operation
- ArgoCD CLI Quick Reference
- Real-World Lessons from This Setup
- What GitOps Changes
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:
-
All desired cluster state lives in Git as YAML
-
An agent runs inside the cluster and watches that Git repo
-
When Git changes, the agent applies the diff automatically
-
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.