GitOps with Argo CD: Structuring Your Repository for Multi-Environment Deployments
You promoted a small Helm value change from staging to production. The diff looked harmless. Two minutes later, prod started serving 502s because the same chart version was used everywhere and a default replica count from a shared file leaked into the production overlay. Rolling back took longer than it should have because dev, staging, and prod all sat in the same folder under the same values.yaml.
If that sounds familiar, the problem is rarely Argo CD itself. It is how the repository is laid out.
This post walks through the repository patterns that actually hold up in production: where to put environment overlays, how to handle promotion between dev, staging, and prod, when to split the app code from the config, and what the Argo CD Application resources should look like for each pattern. Code is copy-pasteable.
TLDR
Use two repos: one for application source code, one for Kubernetes manifests. In the manifests repo, give each environment its own folder and its own Argo CD Application. Pin every environment to a different Git path or branch so a change in dev cannot accidentally hit prod. Use Kustomize overlays or Helm value files per environment, not conditionals based on namespace or labels. Promote by opening a pull request that bumps an image tag in the next environment's folder, never by editing a shared file.
Prerequisites
- A Kubernetes cluster (kind, k3s, or any managed offering works)
- Argo CD installed (
kubectl create namespace argocd && kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml) kubectl,argocdCLI, and eitherkustomizeorhelminstalled locally- A Git provider (GitHub, GitLab, Gitea) where Argo CD can read your config repo
Why repository layout decides your blast radius
Argo CD reconciles whatever Git tells it to reconcile. If two environments read from the same path, they share the same fate. Your repo layout is the actual blast radius boundary, not the namespace or the cluster.
Three rules to keep in mind:
- Every environment maps to its own path or branch.
- Promotion between environments is a Git operation (commit or merge), nothing else.
- Shared bases are fine. Shared overrides are not.
Break any of these and you end up debugging Argo CD when the real bug is a YAML file that someone edited at the wrong level.
Pattern 1: One repo per app vs the monorepo
You have two choices for how many repos to use.
App + config split (recommended):
my-app/ # source code repo
src/
Dockerfile
.github/workflows/
my-app-config/ # GitOps repo, watched by Argo CD
base/
envs/
dev/
staging/
prod/
CI builds the image from my-app, pushes to a registry, then opens a PR in my-app-config that bumps the image tag. Argo CD picks up the change.
Why split: developers can iterate on application code without triggering deploys. Argo CD does not need read access to your source code. You can grant tight permissions on the config repo (only release engineers can merge to prod paths).
Single monorepo: keep src/ and k8s/ in the same repo. Simpler for tiny teams. The downside is every code commit triggers a manifest reconciliation check, and PR reviews mix code changes with deploy changes. Pick the split as soon as you have more than one or two services.
Pattern 2: Folder-per-environment with Kustomize
This is the workhorse pattern. It is what most teams land on after a year or two of running Argo CD.
my-app-config/
base/
deployment.yaml
service.yaml
kustomization.yaml
envs/
dev/
kustomization.yaml
patch-replicas.yaml
values.env
staging/
kustomization.yaml
patch-replicas.yaml
values.env
prod/
kustomization.yaml
patch-replicas.yaml
patch-resources.yaml
values.env
The base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
commonLabels:
app: my-app
A production overlay at envs/prod/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: my-app-prod
resources:
- ../../base
images:
- name: ghcr.io/acme/my-app
newTag: v1.42.0
patches:
- path: patch-replicas.yaml
- path: patch-resources.yaml
The replicas patch:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 6
Render it locally before you commit anything. This is the single most useful habit when working with Kustomize:
kubectl kustomize envs/prod
Expected output (truncated):
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: my-app
name: my-app
namespace: my-app-prod
spec:
replicas: 6
template:
spec:
containers:
- image: ghcr.io/acme/my-app:v1.42.0
name: my-app
resources:
limits:
cpu: "2"
memory: 2Gi
If something looks wrong here, it is wrong. Argo CD will render the same output.
The matching Argo CD Application for prod:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app-prod
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/my-app-config.git
targetRevision: main
path: envs/prod
destination:
server: https://kubernetes.default.svc
namespace: my-app-prod
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Notice the path: envs/prod. Dev and staging get their own Application resources pointing at envs/dev and envs/staging. There is no shared file that can break two environments at once.
Pattern 3: Helm with one values file per environment
If you already publish a Helm chart, use it. Do not rewrite it as Kustomize for the sake of it.
my-app-config/
chart/
Chart.yaml
templates/
values.yaml
envs/
dev/values.yaml
staging/values.yaml
prod/values.yaml
The Argo CD Application for staging:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app-staging
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/my-app-config.git
targetRevision: main
path: chart
helm:
valueFiles:
- ../envs/staging/values.yaml
destination:
server: https://kubernetes.default.svc
namespace: my-app-staging
syncPolicy:
automated:
prune: true
selfHeal: true
A real envs/prod/values.yaml:
image:
repository: ghcr.io/acme/my-app
tag: v1.42.0
replicaCount: 6
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
ingress:
enabled: true
hosts:
- host: my-app.example.com
autoscaling:
enabled: true
minReplicas: 6
maxReplicas: 20
Render locally before you push:
helm template my-app ./chart -f envs/prod/values.yaml
If this fails or outputs the wrong thing, do not commit. Argo CD will fail the same way, but in front of your team.
Pattern 4: App of Apps for fleet-wide changes
Once you pass a handful of services, you do not want to write thirty Application YAMLs by hand. Use the App of Apps pattern: one parent Application that points to a folder full of child Application manifests.
my-platform-config/
apps/
dev/
my-app.yaml
my-other-app.yaml
staging/
my-app.yaml
prod/
my-app.yaml
The parent for the dev environment:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: dev-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/my-platform-config.git
targetRevision: main
path: apps/dev
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Adding a new service to dev is now one PR that drops a single YAML file into apps/dev/. No clicking around in the UI, no argocd app create commands.
For larger setups, look at ApplicationSet, which generates Application resources from a list, a Git directory, or a cluster generator. It is the right tool when you have ten environments times five clusters, not three environments times one cluster.
Promoting between environments
The whole point of GitOps is that promotion is a commit. Here is the flow that works:
- CI builds image
ghcr.io/acme/my-app:v1.42.0and pushes it. - CI opens a PR in
my-app-configthat updatesenvs/dev/kustomization.yamlto setnewTag: v1.42.0. - PR auto-merges if checks pass. Argo CD syncs dev within a minute or two.
- After dev runs the new tag for some agreed time, a human (or an automated job) opens a PR that bumps
envs/staging/kustomization.yamlto the same tag. - Same again for
envs/prod, this time gated on a manual review.
A typical CI step that opens the dev PR:
- name: Bump dev image tag
run: |
git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/acme/my-app-config.git
cd my-app-config
yq -i '(.images[] | select(.name=="ghcr.io/acme/my-app")).newTag = "${{ github.sha }}"' envs/dev/kustomization.yaml
git checkout -b bump-dev-${{ github.sha }}
git commit -am "dev: bump my-app to ${{ github.sha }}"
git push origin bump-dev-${{ github.sha }}
gh pr create --fill --base main
Use argocd-image-updater if you do not want to wire this in CI yourself. It watches the registry and writes the tag bump back to Git. The end result is the same: tags change in Git, never in the cluster.
Common mistakes that break things in production
Sharing a single values file across environments. A values.yaml that uses if eq .Values.env "prod" blocks is a footgun. Separate files, separate paths.
Letting Argo CD watch one path for all environments. If envs/ is a single Argo CD Application, a typo in dev rolls into prod the moment you merge. One Application per environment, always.
Auto-sync without selfHeal: false on prod. During an incident you sometimes need to kubectl edit a deployment to test a fix. With selfHeal: true, Argo CD will revert it within seconds. Either disable self-heal on prod or accept that hotfixes go through Git only.
Storing secrets in the config repo as plain YAML. Use sealed-secrets, external-secrets, or sops with helm-secrets. Plain secrets in Git are a one-way trip you cannot undo.
Using targetRevision: HEAD everywhere. Pin prod to a tag or a commit SHA when you want stricter promotion gates. HEAD is fine for dev.
Concrete next steps
- Pick one service and split it into
<service>and<service>-configrepos this week. Do not boil the ocean. - Create the
base/andenvs/{dev,staging,prod}/layout in the config repo. Runkubectl kustomize envs/devlocally and confirm the output looks right. - Write three
Applicationmanifests, one per environment, and apply them to theargocdnamespace withkubectl apply -f. - Wire up your CI to open a PR against
envs/dev/kustomization.yamlon every successful build. Leave staging and prod as manual PRs for the first two weeks. - Once you have two or three services on this pattern, introduce App of Apps so onboarding the next service is one YAML file, not ten.
The structure you pick on day one is what your team will fight against on day three hundred. Spend the afternoon getting the folders right and the rest of GitOps becomes boring, which is exactly what you want.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
Acronis
The most secure backup
Acronis: the most secure backup solution for your data

QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?