Skip to content
CI/CDMarch 4, 20266 min read

GitOps with ArgoCD: The Right Way to Deploy to Kubernetes

kubectl apply in production is an antipattern. GitOps solves the core problem: making your Git repository the single source of truth for what is running in your cluster. Here's a complete ArgoCD setup from scratch.

Here is a deployment pattern we see constantly in startup audits: a developer runs kubectl apply -f k8s/ from their laptop to deploy a fix, the change is not tracked anywhere, and two weeks later nobody knows what version is actually running in production.

GitOps fixes this at the source. The Git repository becomes the authoritative record of cluster state. The cluster reconciles itself to match. No manual kubectl commands. No configuration drift.

What GitOps Actually Is

GitOps is a pattern, not a tool. The principles:

  1. Declarative - your desired cluster state is described in files, not imperative commands
  2. Versioned - everything lives in Git with full history
  3. Automated - a system continuously reconciles actual cluster state with the Git-declared state
  4. Observable - when actual state drifts from desired state, the system alerts you

ArgoCD implements this pattern for Kubernetes. It watches Git repositories and automatically applies changes to clusters when the repository changes.

Why ArgoCD Over Push-Based CD

Most CI/CD pipelines are push-based: your pipeline runs kubectl apply or helm upgrade. The problems:

  • Your pipeline needs cluster credentials - now a compromised CI server can modify production
  • There is no continuous reconciliation - if someone manually modifies a resource, no one knows
  • No live view of what version is actually deployed vs what should be deployed

ArgoCD is pull-based. The cluster pulls from Git. CI only needs write access to Git, not to the cluster.

Installation

bash
kubectl create namespace argocd kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml # Wait for ArgoCD to be ready kubectl wait --for=condition=available deployment/argocd-server -n argocd --timeout=120s # Get initial admin password kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

For production, install via Helm with HA and ingress:

bash
helm repo add argo https://argoproj.github.io/argo-helm helm upgrade --install argocd argo/argo-cd \ --namespace argocd \ --create-namespace \ --set server.ingress.enabled=true \ --set server.ingress.hostname=argocd.yourdomain.com \ --set server.ingress.ingressClassName=nginx \ --set server.ingress.tls=true \ --set redis-ha.enabled=true \ --set controller.replicas=1 \ --set server.replicas=2 \ --set repoServer.replicas=2

Repository Structure

The most important decision in GitOps is how you structure your repository. Two common patterns:

App-of-Apps (Recommended)

One root ArgoCD Application that manages all other Applications:

├── argocd/
│   ├── root.yaml              # The root Application
│   └── apps/
│       ├── api.yaml           # Application for your API
│       ├── worker.yaml        # Application for your worker
│       └── monitoring.yaml    # Application for Prometheus/Grafana
├── apps/
│   ├── api/
│   │   ├── Chart.yaml
│   │   ├── values.yaml
│   │   └── values-prod.yaml
│   └── worker/
│       ├── Chart.yaml
│       └── values.yaml
└── infrastructure/
    ├── cert-manager/
    ├── external-dns/
    └── ingress-nginx/

Root Application

yaml
# argocd/root.yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: root namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.io spec: project: default source: repoURL: https://github.com/your-org/k8s-config targetRevision: HEAD path: argocd/apps destination: server: https://kubernetes.default.svc namespace: argocd syncPolicy: automated: prune: true # delete resources removed from Git selfHeal: true # revert manual changes

Individual Application

yaml
# argocd/apps/api.yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: api namespace: argocd spec: project: default source: repoURL: https://github.com/your-org/k8s-config targetRevision: HEAD path: apps/api helm: valueFiles: - values.yaml - values-prod.yaml destination: server: https://kubernetes.default.svc namespace: production syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true retry: limit: 3 backoff: duration: 5s maxDuration: 3m factor: 2 ignoreDifferences: - group: apps kind: Deployment jsonPointers: - /spec/replicas # ignore HPA-managed replica counts

The CI/CD Flow

With GitOps, CI and CD are separated:

CI (stays the same):

  1. Developer opens PR
  2. Tests run, image builds
  3. Image pushed to registry with sha256 tag: api:abc123def
  4. PR merged to main

CD (GitOps-specific):

  1. CI pipeline updates the image tag in the Git repository:
bash
# In your CI pipeline - update image tag in values file yq -i '.image.tag = "${{ github.sha }}"' apps/api/values-prod.yaml git config user.email "[email protected]" git config user.name "CI Bot" git add apps/api/values-prod.yaml git commit -m "chore: deploy api@${{ github.sha }}" git push
  1. ArgoCD detects the change and applies it
  2. Deployment rolls out with the new image

The cluster credentials never touch your CI system.

Handling Secrets in ArgoCD

ArgoCD reconciles from Git. Secrets should not be in Git. Use one of these patterns:

Option A: External Secrets Operator (simplest) ESO syncs secrets from AWS Secrets Manager into K8s Secrets before ArgoCD applies your app. ArgoCD sees a normal K8s Secret reference.

Option B: Sealed Secrets Encrypt your secrets with a public key. Store the encrypted SealedSecret in Git. The in-cluster controller decrypts it.

bash
# Install Sealed Secrets controller helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets -n kube-system # Seal a secret kubectl create secret generic api-db \ --from-literal=password=supersecret \ --dry-run=client -o yaml | kubeseal -o yaml > k8s/sealed-secret.yaml # Commit the sealed secret - it's safe to store in Git git add k8s/sealed-secret.yaml git commit -m "Add sealed database secret"

Rollback

Rollback is a Git operation. Find the commit with the previous image tag and revert:

bash
# Option 1: Revert the image tag commit in Git git revert <commit-sha> git push # ArgoCD detects the revert and rolls back # Option 2: Rollback in ArgoCD UI or CLI argocd app rollback api --revision 3

ArgoCD maintains a history of syncs with the Git revision used for each. Rollback to any previous sync point in seconds.

What to Monitor

After setting up ArgoCD, watch for:

  • OutOfSync applications - something in the cluster diverged from Git (manual change, failed sync)
  • Degraded applications - pod crashes, image pull failures
  • Failed syncs - Git webhook failures, resource validation errors

ArgoCD exposes Prometheus metrics. Add alerts:

yaml
# Alert when any app is out of sync for more than 5 minutes - alert: ArgocdAppOutOfSync expr: argocd_app_info{sync_status!="Synced"} > 0 for: 5m labels: severity: warning annotations: summary: "ArgoCD app {{ $labels.name }} is out of sync"

The Result

After implementing GitOps with ArgoCD:

  • Every production change has a Git commit behind it
  • Configuration drift is detected and auto-healed within minutes
  • Rollback takes 30 seconds
  • Your CI pipeline does not hold production credentials
  • The ArgoCD UI gives you a real-time map of what is running vs what should be running

Want to migrate from push-based deployments to GitOps? Book a free audit - we will review your current pipeline and design the migration plan.

RK
RKSSH LLP
DevOps Engineer · rkssh.com

I help funded startups fix their CI/CD pipelines and Kubernetes infrastructure. If this post was useful and you want to talk through your specific situation, book a free 30-minute audit.

Related Articles