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?
- Edit in Git: change
replicas: 2toreplicas: 3in deployment.yaml - Commit and push
- ArgoCD detects the change (within 3 minutes by default, or instantly with webhooks)
- 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:
- Sealed Secrets
- External Secrets Operator backed by Vault
- SOPS encrypted secrets
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:
- App-of-Apps pattern: manage applications with applications
- Drift detection: monitor for unauthorized changes
- Disaster recovery: rebuild whole clusters from Git
- ApplicationSets: generate applications dynamically
- Notifications: Slack or email on sync events
My Recommendation
- Start with one application and get the workflow into your fingers.
- Enable automated sync plus selfHeal so you feel what Git-as-truth actually does.
- Use a dedicated gitops repo, separate from your application source code.
- Set up webhooks for sync that triggers on push instead of polling.
- 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.
