If you are still SSH-ing into servers to run kubectl apply or relying on a CI pipeline that pushes deployments imperatively, you are doing Kubernetes the hard way. GitOps flips the model: your Git repository becomes the single source of truth, and a controller running inside the cluster continuously reconciles the actual state with the desired state.
ArgoCD is the most popular GitOps tool in the CNCF ecosystem, and for good reason. It gives you a declarative deployment engine, a slick UI, RBAC, multi-cluster support, and an ecosystem of extensions for progressive delivery and secrets management.
This guide walks you through everything — from core principles to a production-ready ArgoCD setup.
What Is GitOps, Really?
GitOps is an operational framework that applies DevOps best practices (version control, code review, CI/CD) to infrastructure automation. The term was coined by Weaveworks in 2017, but the ideas have matured significantly since then.
The Four Principles
| Principle | What It Means |
|---|---|
| Declarative | The entire system is described declaratively (YAML, Helm, Kustomize) |
| Versioned and immutable | Git is the single source of truth; every change is a commit |
| Pulled automatically | Agents inside the cluster pull desired state — no external push access needed |
| Continuously reconciled | Controllers detect drift and self-heal automatically |
The key insight is the pull model. Traditional CI/CD pipelines push changes into the cluster, which means your CI system needs cluster credentials. With GitOps, the agent lives inside the cluster and pulls changes from Git. This dramatically reduces the attack surface.
GitOps vs Traditional CI/CD
Traditional CI/CD:
Developer → Git Push → CI Build → CI Deploy (push to cluster)
↑
CI needs cluster creds
GitOps:
Developer → Git Push → CI Build → Update Git Manifests
↓
ArgoCD (in-cluster) pulls & appliesThe CI pipeline's job ends at building the image and updating the manifest repository. It never touches the cluster directly.
Setting Up ArgoCD on Kubernetes
Prerequisites
You need a running Kubernetes cluster (1.26+) and kubectl configured. For local development, kind or minikube work great.
# Create a kind cluster for testing
kind create cluster --name argocd-lab
# Verify connectivity
kubectl cluster-infoInstall ArgoCD
ArgoCD provides a straightforward installation via manifests.
# Create namespace
kubectl create namespace argocd
# Install ArgoCD (stable release)
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Wait for all pods to be ready
kubectl wait --for=condition=ready pod -l app.kubernetes.io/part-of=argocd -n argocd --timeout=300sThis installs several components:
- argocd-server — the API server and web UI
- argocd-repo-server — clones Git repos and generates manifests
- argocd-application-controller — watches applications and syncs state
- argocd-redis — caching layer
- argocd-dex-server — SSO integration
Access the UI
# Port-forward the server
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Get the initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -dOpen https://localhost:8080 and log in with username admin and the password from above.
Install the CLI
# macOS
brew install argocd
# Linux
curl -sSL -o argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x argocd
sudo mv argocd /usr/local/bin/
# Authenticate
argocd login localhost:8080 --username admin --password <your-password> --insecure
Change the default password immediately:
argocd account update-passwordYour First ArgoCD Application
Repository Structure
A well-organized GitOps repo separates application code from deployment manifests. Here is a typical structure:
gitops-repo/
├── apps/ # ArgoCD Application manifests
│ ├── api.yaml
│ ├── web.yaml
│ └── worker.yaml
├── base/ # Base Kustomize manifests
│ ├── api/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── kustomization.yaml
│ └── web/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
├── overlays/ # Environment-specific patches
│ ├── dev/
│ │ └── kustomization.yaml
│ ├── staging/
│ │ └── kustomization.yaml
│ └── production/
│ └── kustomization.yaml
└── README.mdCreating an Application Manifest
ArgoCD applications are defined as Kubernetes custom resources:
# apps/api.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops-repo.git
targetRevision: main
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: api
syncPolicy:
automated:
prune: true # Delete resources removed from Git
selfHeal: true # Revert manual changes in cluster
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3mApply it:
kubectl apply -f apps/api.yamlSync Policies Explained
The syncPolicy section controls how ArgoCD behaves:
| Policy | Effect |
|---|---|
automated | Syncs automatically when Git changes are detected |
prune: true | Deletes resources that no longer exist in Git |
selfHeal: true | Reverts manual kubectl changes back to Git state |
retry | Retries failed syncs with exponential backoff |
For production, I recommend starting with automated disabled and using manual syncs until you build confidence. Then enable selfHeal first (to prevent drift), and prune last (to prevent accidental deletions).
The App of Apps Pattern
Managing dozens of Application manifests individually gets tedious. The "App of Apps" pattern uses a parent Application that manages child Applications:
# apps/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops-repo.git
targetRevision: main
path: apps # Points to the directory containing all app manifests
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
selfHeal: true
prune: trueNow adding a new application is just adding a YAML file to the apps/ directory and pushing to Git.
Managing Secrets in GitOps
The biggest challenge with GitOps is secrets. You cannot commit plaintext secrets to Git. There are two mainstream solutions.
Option 1: Sealed Secrets
Bitnami Sealed Secrets encrypts secrets using a cluster-side controller. Only the controller can decrypt them, so the encrypted version is safe to commit.
# Install the controller
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.3/controller.yaml
# Install kubeseal CLI
brew install kubesealEncrypt a secret:
# Create a regular secret (don't apply it)
kubectl create secret generic db-creds \
--from-literal=username=admin \
--from-literal=password=supersecret \
--dry-run=client -o yaml > secret.yaml
# Seal it
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
# View the sealed secret (safe to commit)
cat sealed-secret.yaml
The sealed secret looks like this:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-creds
namespace: default
spec:
encryptedData:
username: AgBy3i... # encrypted
password: AgCtr8... # encryptedOption 2: SOPS + Age
Mozilla SOPS encrypts specific values in YAML files using age keys. This integrates with ArgoCD via a plugin.
# Install age and sops
brew install age sops
# Generate an age key
age-keygen -o age-key.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Create a SOPS config
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: .*secrets.*\.yaml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
EOF
# Encrypt a secrets file
sops --encrypt secrets.yaml > secrets.enc.yamlConfigure ArgoCD to decrypt SOPS files using a custom plugin:
# argocd-cm ConfigMap patch
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
configManagementPlugins: |
- name: sops
generate:
command: ["sh", "-c"]
args:
- |
for f in *.yaml; do
if sops --decrypt "$f" > /dev/null 2>&1; then
sops --decrypt "$f"
else
cat "$f"
fi
doneWhich One Should You Pick?
| Criteria | Sealed Secrets | SOPS + Age |
|---|---|---|
| Ease of setup | Simpler | More configuration |
| Key management | Cluster-managed | You manage keys |
| Multi-cluster | Tricky (keys per cluster) | Easy (share age keys) |
| Rotation | Re-seal all secrets | Re-encrypt with SOPS |
| Local development | Need cluster access | Works offline |
For most teams, Sealed Secrets is the faster path. For multi-cluster setups or teams that want more control, SOPS + Age scales better.
Progressive Delivery with Argo Rollouts
Deploying everything at once is risky. Argo Rollouts extends Kubernetes with canary deployments, blue-green deployments, and automated analysis.
Install Argo Rollouts
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml
# Install the kubectl plugin
brew install argoproj/tap/kubectl-argo-rolloutsCanary Deployment
Replace your Deployment with a Rollout:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: api
spec:
replicas: 5
strategy:
canary:
steps:
- setWeight: 10 # Send 10% traffic to canary
- pause: { duration: 5m }
- setWeight: 30
- pause: { duration: 5m }
- analysis:
templates:
- templateName: success-rate
- setWeight: 60
- pause: { duration: 5m }
- setWeight: 100
canaryService: api-canary
stableService: api-stable
trafficRouting:
istio:
virtualService:
name: api-vsvc
routes:
- primary
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: your-registry/api:v2.1.0
ports:
- containerPort: 8080Automated Analysis
The real power of Argo Rollouts is automated rollback based on metrics:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
spec:
metrics:
- name: success-rate
interval: 60s
count: 5
successCondition: result[0] >= 0.95
failureLimit: 3
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
sum(rate(http_requests_total{status=~"2.*", app="api", canary="true"}[5m]))
/
sum(rate(http_requests_total{app="api", canary="true"}[5m]))This queries Prometheus every 60 seconds, checks that the success rate is at least 95%, and automatically rolls back if it fails 3 times.
The Rollout Flow
New image pushed to Git
↓
ArgoCD detects change, syncs
↓
Argo Rollouts creates canary (10% traffic)
↓
Wait 5 minutes → promote to 30%
↓
Run AnalysisTemplate (check error rate, latency)
↓
┌─── PASS ───┐ ┌─── FAIL ───┐
↓ ↓ ↓ ↓
Promote to 60% Auto-rollback to stable
↓
Wait 5 minutes
↓
Promote to 100% (stable)Multi-Cluster Management
ArgoCD supports deploying to multiple clusters from a single control plane.
# Add a remote cluster
argocd cluster add my-staging-cluster --name staging
# List registered clusters
argocd cluster listThen reference the cluster in your Application:
spec:
destination:
server: https://staging-api.example.com # Remote cluster API
namespace: apiApplicationSets for Multi-Cluster
ApplicationSet is a controller that generates Application resources from templates. This is incredibly useful for deploying the same app across environments:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: api
namespace: argocd
spec:
generators:
- list:
elements:
- cluster: dev
url: https://dev-api.example.com
revision: develop
- cluster: staging
url: https://staging-api.example.com
revision: main
- cluster: production
url: https://prod-api.example.com
revision: v2.1.0 # Pin production to a tag
template:
metadata:
name: 'api-{{cluster}}'
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops-repo.git
targetRevision: '{{revision}}'
path: 'overlays/{{cluster}}'
destination:
server: '{{url}}'
namespace: api
syncPolicy:
automated:
selfHeal: trueThis generates three Application resources — one per cluster — each pointing at the correct overlay and Git revision.
ArgoCD vs FluxCD
Both are CNCF-graduated GitOps tools. Here is an honest comparison.
| Feature | ArgoCD | FluxCD |
|---|---|---|
| UI | Rich web UI with dependency graph | No built-in UI (Weave GitOps adds one) |
| Architecture | Centralized controller | Distributed toolkit (source, kustomize, helm, notification controllers) |
| Multi-tenancy | Projects with RBAC | Namespace-scoped reconcilers |
| Helm support | Renders Helm in-cluster | Native Helm controller |
| Multi-cluster | Built-in, single pane of glass | Cluster API or manual setup |
| Progressive delivery | Argo Rollouts (native) | Flagger (separate project) |
| Learning curve | Moderate (UI helps) | Steeper (CLI-first) |
| Resource usage | Higher (UI, Redis, Dex) | Lower footprint |
| Community | Larger (17k+ GitHub stars) | Strong but smaller |
When to Choose ArgoCD
- You want a UI for visibility and onboarding
- Multi-cluster management is a priority
- Your team includes people who prefer clicking over
kubectl - You want progressive delivery tightly integrated
When to Choose FluxCD
- You prefer a lightweight, composable toolkit
- You are already heavy on Helm and want native Helm OCI support
- Resource constraints matter (edge deployments)
- You want tighter integration with Terraform via the Flux Terraform controller
For most teams starting with GitOps, ArgoCD wins on developer experience. The UI alone saves hours of debugging sync issues. FluxCD is excellent for platform teams who want fine-grained control and minimal resource overhead.
Production Checklist
Before going live, make sure you have covered these items:
[ ] ArgoCD installed via Helm chart (not raw manifests) for easier upgrades
[ ] SSO configured (OIDC with your identity provider)
[ ] RBAC policies restricting who can sync to production
[ ] Resource tracking set to annotation-based (avoids label conflicts)
[ ] Notifications configured (Slack, Teams, or PagerDuty)
[ ] Secrets management solution deployed (Sealed Secrets or SOPS)
[ ] Repository credentials stored as Kubernetes secrets (not in ArgoCD UI)
[ ] Health checks defined for custom resources
[ ] Sync windows configured (prevent production deploys on Fridays)
[ ] Disaster recovery: ArgoCD backup with argocd-util export
[ ] Monitoring: ArgoCD metrics exported to Prometheus/GrafanaSync Windows
Prevent deployments during risky periods:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: production
namespace: argocd
spec:
syncWindows:
- kind: deny
schedule: '0 0 * * 5' # No deploys on Fridays
duration: 24h
clusters: ['*']
- kind: deny
schedule: '0 18 * * *' # No deploys after 6 PM
duration: 14h
clusters: ['*']Common Pitfalls
1. Putting app code and manifests in the same repo. Separate them. A commit to application code should not trigger a manifest sync, and vice versa.
2. Not using Kustomize or Helm. Raw YAML duplicated across environments leads to drift. Use overlays.
3. Forgetting about CRDs. If your app depends on CRDs, sync them first using sync waves:
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1" # CRDs sync before the app4. Ignoring resource limits on ArgoCD itself. The repo-server can consume significant memory when rendering large Helm charts. Set appropriate resource requests and limits.
5. Not monitoring ArgoCD. ArgoCD exposes Prometheus metrics on :8082/metrics. Set up alerts for sync failures and degraded health.
Wrapping Up
GitOps with ArgoCD gives you auditable, reproducible, self-healing deployments. Every change goes through a pull request, every deployment is a Git commit, and every rollback is a git revert.
The learning curve is real, but the payoff is enormous: fewer deployment failures, faster recovery, and a clear audit trail of every change that ever hit your cluster.
Start with a single application in a dev cluster. Get comfortable with sync policies and the UI. Then expand to multi-cluster with ApplicationSets. Add progressive delivery with Argo Rollouts when you are ready.
The days of kubectl apply -f yolo.yaml in production should be behind us.