A Pragmatic GitOps Setup with ArgoCD: What I Actually Use in Production
I've set up ArgoCD from scratch in eight different organizations. Every single one of them started with the same naive setup that's in every tutorial: one ArgoCD Application pointing at one Helm chart in one Git repository. This setup works fine until it doesn't — and the failure mode is always the same. You add a second service, then a third, and suddenly you're managing Application manifests by hand, drift is happening in ways you can't track, and the promise of GitOps feels more like a constraint than a superpower.
Why the naive setup fails
The typical ArgoCD tutorial shows you this:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/org/my-app
path: helm/my-app
targetRevision: HEAD
destination:
server: https://kubernetes.default.svc
namespace: my-appThis is fine for one app. At ten apps across three environments, you have 30 Application manifests to manage. When you need to change a common sync policy or add a notification hook, you update 30 files. When a new service gets added, someone has to remember to create the Application manifests for dev, staging, and production. The manual overhead defeats the purpose.
The app-of-apps pattern
The solution is the app-of-apps pattern: one ArgoCD Application (the “root app”) that manages all other Applications. The root app syncs a directory of Application manifests from Git. When you add a new service, you add one Application manifest to that directory. ArgoCD picks it up automatically.
# root-app.yaml — this is all you add to ArgoCD manually
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
source:
repoURL: https://github.com/org/gitops-config
path: apps/production
targetRevision: HEAD
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: trueThe apps/production/directory in your config repo contains one Application manifest per service. Each Application manifest points at the service's Helm chart (either in the same config repo or in the application repo).
Repo structure
There are two schools of thought on repo structure: monorepo (application code and Helm charts in the same repo) and separate repos (application code in one repo, Kubernetes config in another). I've used both. My preference:
- Separate config repo for anything serious. It gives you a clean audit trail of all Kubernetes configuration changes separate from application code. GitOps tools get confused when they have to watch a repo where most commits are application code changes, not config changes.
- Monorepo only for small teams where the overhead of maintaining two repos is real. The audit trail gets messy, but the DX is simpler.
In the config repo, I use this structure:
gitops-config/
├── apps/
│ ├── dev/ # Application manifests for dev cluster
│ ├── staging/
│ └── production/
├── charts/ # Shared Helm chart templates
└── values/
├── dev/ # Per-service values for dev
├── staging/
└── production/Sync policies — automated vs manual
The most common mistake I see: setting automated: {}sync on production. I understand the appeal — fully automated GitOps, merge to main and it's in production. But for most teams, production deploys should have a human gate.
My standard policy:
- Dev:
automated: {prune: true, selfHeal: true}— every merge to main auto-deploys to dev - Staging:
automated: {prune: false}— auto-syncs when config changes, but won't prune resources automatically - Production: No automation — manual sync only, triggered via ArgoCD UI or argocd CLI, with a Slack notification via argocd-notifications before and after
The production manual gate isn't about distrust of the automation — it's about having a named human who confirmed the deploy was intentional and was watching when it happened.
Secrets with External Secrets Operator
Never put secrets in your GitOps config repo, even encrypted. The pattern I use: External Secrets Operator (ESO) syncing from AWS Secrets Manager or HashiCorp Vault into Kubernetes Secrets. The config repo only contains ExternalSecret manifests — references to where the secret lives, not the secret value itself.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: database-credentials
data:
- secretKey: DB_PASSWORD
remoteRef:
key: /production/myapp/database
property: passwordThis is safe to commit. The secret value lives in AWS Secrets Manager. ESO syncs it into a Kubernetes Secret on schedule and whenever it changes. Rotation happens automatically.
Image update automation
For dev environments, I use Argo CD Image Updater to automatically update image tags when a new image is pushed to ECR. This removes the step where a developer has to manually bump the image tag in the config repo after CI builds a new image.
For staging and production, image tags are pinned. A human (or a CI step with an explicit PR) bumps the tag. This is intentional — you want to know exactly what changed in each environment and be able to attribute it to a specific decision.
The thing that actually makes this work
Every ArgoCD setup I've built that worked well had one thing in common: the team actually reviewed the ArgoCD sync diffs before approving production syncs. ArgoCD shows you exactly what's going to change — what resources will be added, modified, or deleted. Reviewing that diff is the human layer that catches the misconfigured environment variable, the accidentally removed service, the wrong replica count.
GitOps doesn't remove the need for human judgment — it makes human judgment more efficient by surfacing exactly what needs to be reviewed.
Newsletter
DevOps dispatches, when I have something worth saying.
Occasional long-form on Kubernetes, CI/CD, and infrastructure. No filler, no cadence commitment.
Kamal Hussain
Freelance DevOps & cloud engineer. Kubernetes, CI/CD, AWS, and web development for startups across the US, EU, and MENA.