Multi-environment GitOps: one repo layout that survived dev, staging, and prod
Two years ago, I broke production during a high-volatility trading session because our GitOps repository structure lied to us.
We were running a standard monorepo setup using Helm charts and ArgoCD. To deploy a fix to dev, I merged a pull request that bumped a shared parameter in a parent values file. What I didn’t realize was that our Helm dependency graph contained an implicit cross-environment override. Thirty seconds after the merge, ArgoCD auto-synced the change across all clusters simultaneously. The execution engine lost connection to our broker gateway, triggering a cascade of circuit breakers that cost us $42,000 in unhedged inventory risk before we could manually halt the sync.
We tried splitting into separate repositories per environment. That created a nightmare: PRs had to be cherry-picked across three distinct repos (trading-app-dev, trading-app-staging, trading-app-prod), configuration drifted wildly within weeks, and no one actually knew what was running where.
This post details the exact GitOps repository structure I engineered to solve this problem permanently—a single-repo layout powered by Kustomize and ArgoCD that maintains total environment isolation, eliminates configuration duplication, and guarantees deterministic promotions.
The Core Architecture
The architecture relies on a strict directory hierarchy using Kustomize overlays. We explicitly banned Helm template logic inside our application manifests, reserving Helm exclusively for third-party upstream dependencies (like Redis or Kafka operators). For our own microservices, pure Kustomize base/overlay patterns proved far easier to audit, diff, and dry-run.
Here is the flow of configuration changes from local development to production deployment:
flowchart LR dev["Developer PR"] -->|"Merge to main"| base["Base Directory"] base -->|"Kustomize Overlay"| devEnv["Dev Cluster"] devEnv -->|"ArgoCD Auto-Sync"| devApp["Dev Deployment"] base -->|"Image Tag Pin"| stagingEnv["Staging Cluster"] stagingEnv -->|"Manual Sync"| stagingApp["Staging Deployment"] stagingEnv -->|"Diff Validation"| prodEnv["Prod Cluster"] prodEnv -->|"Gated ArgoCD Sync"| prodApp["Prod Deployment"]
Directory Taxonomy
To prevent cross-environment pollution, the repository enforces three hard rules:
1. Bases are immutable per environment release: A base/ directory defines what runs (Deployments, Services, ConfigMap keys), never how it runs (replica counts, CPU limits, external ingress hostnames).
2. Environments are isolated top-level trees: environments/dev, environments/staging, and environments/prod do not reference each other via relative paths. They only reference ../../apps/<app-name>/base.
3. Application manifests and GitOps control manifests live together: ArgoCD Application resources live inside the root bootstrap/ folder, referencing specific paths and commit SSH keys explicitly.
Here is the working directory tree of our layout:
├── apps/
│ └── order-execution/
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── kustomization.yaml
│ └── overlays/
│ ├── dev/
│ │ ├── kustomization.yaml
│ │ ├── configmap-env.yaml
│ │ └── replicas.yaml
│ ├── staging/
│ │ ├── kustomization.yaml
│ │ ├── configmap-env.yaml
│ │ └── resources.yaml
│ └── prod/
│ ├── kustomization.yaml
│ ├── configmap-env.yaml
│ ├── topology-spread.yaml
│ └── patches/
│ └── HPA.yaml
├── bootstrap/
│ ├── root-app.yaml
│ └── apps/
│ ├── dev-order-execution.yaml
│ ├── staging-order-execution.yaml
│ └── prod-order-execution.yaml
└── infrastructure/
├── base/
└── overlays/
├── dev/
├── staging/
└── prod/
Deep Dive: Base & Overlay Configurations
Let’s look at the actual code driving this setup. We will examine the order-execution engine service, which demands low-latency execution and precise environment-specific overrides.
The Base Definition
The base defines the structural skeleton. Notice that there are no hardcoded environment variables, replica counts, or resource limits here.
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-execution
labels:
app.kubernetes.io/name: order-execution
app.kubernetes.io/part-of: trading-platform
spec:
selector:
matchLabels:
app.kubernetes.io/name: order-execution
template:
metadata:
labels:
app.kubernetes.io/name: order-execution
spec:
containers:
– name: engine
image: ghcr.io/firm/order-execution:latest
imagePullPolicy: IfNotPresent
ports:
– name: gRPC
containerPort: 50051
envFrom:
– configMapRef:
name: order-execution-config
readinessProbe:
exec:
command: [“/bin/grpc_health_probe”, “-addr=:50051”]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: [“/bin/grpc_health_probe”, “-addr=:50051”]
initialDelaySeconds: 15
periodSeconds: 20
yaml
# apps/order-execution/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
– deployment.yaml
– service.yaml
The Production Overlay
In production, we lock down image tags using precise SHA digests, scale out replica sets, apply strict resource constraints, and introduce a TopologySpreadConstraint to guarantee multi-AZ availability across our AWS us-east-1 EKS worker nodes.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: trading-prod
resources:
– ../../base
– topology-spread.yaml
images:
– name: ghcr.io/firm/order-execution
newName: ghcr.io/firm/order-execution
digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
– name: order-execution-config
files:
– configmap-env.yaml
patches:
– path: patches/HPA.yaml
– target:
kind: Deployment
name: order-execution
patch: |-
– op: replace
path: /spec/replicas
value: 12
– op: add
path: /spec/template/spec/containers/0/resources
value:
requests:
cpu: “4000m”
memory: “8Gi”
limits:
cpu: “8000m”
memory: “16Gi”
yaml
# apps/order-execution/overlays/prod/configmap-env.yaml
LOG_LEVEL=WARN
FIX_GATEWAY_URL=tcp://fix.prod.firm.internal:9800
ENABLE_REALTIME_MARKETDATA=true
MAX_SLIPPAGE_BPS=5
EXECUTION_MODE=LIVE
yaml
# apps/order-execution/overlays/prod/topology-spread.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-execution
spec:
template:
spec:
topologySpreadConstraints:
– maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: order-execution
ArgoCD Bootstrap via the App-of-Apps Pattern
To tie everything together without touching kubectl on target clusters, we use ArgoCD’s “App-of-Apps” paradigm. The root bootstrapper points to a single directory containing ArgoCD manifest definitions for each environment.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-bootstrap
namespace: argocd
finalizers:
– resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: [email protected]:firm/trading-gitops.git
targetRevision: HEAD
path: bootstrap/apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Now, we define environment-level declarative control policies inside bootstrap/apps/. Notice the fundamental difference in sync strategies: dev is aggressive and automatic, while prod has automated disabled, requiring manual execution via ArgoCD CLI or UI after automated diff checks pass in CI.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: dev-order-execution
namespace: argocd
spec:
project: dev-environment
source:
repoURL: [email protected]:firm/trading-gitops.git
targetRevision: HEAD
path: apps/order-execution/overlays/dev
destination:
server: https://k8s-dev.firm.internal:6443
namespace: trading-dev
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
– CreateNamespace=true
yaml
# bootstrap/apps/prod-order-execution.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: prod-order-execution
namespace: argocd
spec:
project: prod-environment
source:
repoURL: [email protected]:firm/trading-gitops.git
targetRevision: HEAD
path: apps/order-execution/overlays/prod
destination:
server: https://k8s-prod.firm.internal:6443
namespace: trading-prod
syncPolicy:
syncOptions:
– CreateNamespace=true
– ApplyOutOfSyncOnly=true
Automated Validation Pipeline
A massive multi-environment repository easily breaks if developers mess up relative path traversal in Kustomize files or commit malformed YAML. We run strict automated checks on every pull request using a Python validation script integrated into GitHub Actions.
This script ensures that:
1. Every Kustomize overlay builds cleanly without schema errors.
2. Production overlays do not contain mutable latest container tags.
3. Namespace metadata matches the environment folder destination.
import os
import subprocess
import sys
import yaml
ENVIRONMENTS = [“dev”, “staging”, “prod”]
APPS_DIR = “apps”
def run_kustomize_build(overlay_path):
cmd = [“kustomize”, “build”, overlay_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f”ERROR: Kustomize build failed for path: {overlay_path}”)
print(result.stderr)
return None
return result.stdout
def validate_manifests(overlay_path, manifest_stream, env):
documents = list(yaml.safe_load_all(manifest_stream))
errors = []
for doc in documents:
if not doc:
continue
kind = doc.get(“kind”, “”)
metadata = doc.get(“metadata”, {})
name = metadata.get(“name”, “unknown”)
# Rule 1: No ‘latest’ image tags in production overlays
if env == “prod” and kind == “Deployment”:
spec = doc.get(“spec”, {}).get(“template”, {}).get(“spec”, {})
for container in spec.get(“containers”, []):
image = container.get(“image”, “”)
if image.endswith(“:latest”) or “:” not in image:
errors.append(
f”[{overlay_path}] Container ‘{container.get(‘name’)}’ uses forbidden image tag ‘{image}'”
)
if “@sha256:” not in image:
errors.append(
f”[{overlay_path}] Container ‘{container.get(‘name’)}’ must use explicit SHA digest, found ‘{image}'”
)
# Rule 2: Ensure correct namespace assignment
expected_namespace = f”trading-{env}”
actual_namespace = metadata.get(“namespace”)
if actual_namespace and actual_namespace != expected_namespace:
errors.append(
f”[{overlay_path}] Resource ‘{kind}/{name}’ has namespace ‘{actual_namespace}’, expected ‘{expected_namespace}'”
)
return errors
def main():
has_errors = False
for app in os.listdir(APPS_DIR):
app_path = os.path.join(APPS_DIR, app)
if not os.path.isdir(app_path):
continue
overlays_dir = os.path.join(app_path, “overlays”)
if not os.path.exists(overlays_dir):
continue
for env in ENVIRONMENTS:
env_path = os.path.join(overlays_dir, env)
if not os.path.exists(env_path):
print(f”WARNING: Missing ‘{env}’ overlay for app ‘{app}'”)
continue
print(f”Validating: {env_path}”)
manifest_out = run_kustomize_build(env_path)
if manifest_out is None:
has_errors = True
continue
validation_errors = validate_manifests(env_path, manifest_out, env)
for err in validation_errors:
print(f”VALIDATION FAILURE: {err}”)
has_errors = True
if has_errors:
print(“\nCI Pipeline Failed: Fix manifest errors before merging.”)
sys.exit(1)
print(“\nAll GitOps overlays successfully validated!”)
if __name__ == “__main__”:
main()
Verification and Execution Output
When promoting a hotfix or feature from staging to prod, the developer updates the production kustomization.yaml digest hash via PR. CI executes our test runner:
Validating: apps/order-execution/overlays/dev
Validating: apps/order-execution/overlays/staging
Validating: apps/order-execution/overlays/prod
All GitOps overlays successfully validated!
Once merged to main, ArgoCD’s root app evaluates the state across cluster target API endpoints. We verify sync states via the argocd CLI tool directly from our deployment pipeline:
NAME CLUSTER NAMESPACE STATUS HEALTH SYNCPOLICY
argocd/dev-order-exec https://k8s-dev.firm.internal trading-dev Synced Healthy Auto-Prune
argocd/stg-order-exec https://k8s-stg.firm.internal trading-stg Synced Healthy Auto-Prune
argocd/prd-order-exec https://k8s-prod.firm.internal trading-prod OutOfSync Healthy Manual
To sync production safely, we execute a dry-run diff check before sending the final trigger:
====== Deployment trading-prod/order-execution ======
14c14
< image: ghcr.io/firm/order-execution@sha256:a1b2c3d4…
—
> image: ghcr.io/firm/order-execution@sha256:e3b0c442…
$ argocd app sync argocd/prd-order-exec –port-forward –port-forward-namespace argocd
TIMESTAMP GROUP KIND NAMESPACE NAME STATUS HEALTH HOOK MESSAGE
2026-03-28T14:22:01-04:00 apps Deployment trading-prod order-execution OutOfSync Healthy
2026-03-28T14:22:02-04:00 apps Deployment trading-prod order-execution Synced Progressing pruned
2026-03-28T14:22:15-04:00 apps Deployment trading-prod order-execution Synced Healthy Resource is up to date
Results and Measured Impact
After migrating all 24 microservices into this single-repo GitOps layout, we measured operational performance over a nine-month period:
- Deployment-induced Incidents: Dropped from 14 incidents per quarter to 0 across the last three consecutive quarters.
- Production Deployment Duration: Mean-time-to-deploy (MTTD) decreased from 45 minutes (manual cherry-picking, complex Helm overrides, cross-repo synchronization) to 4 minutes (single PR merge and dry-run validation).
- Configuration Drift: Zero instances of drift. ArgoCD flags and alerts on manual
kubectlinterventions within 180 seconds. - Onboarding Overhead: New engineers onboard and ship their first dev-environment service deployment in under 2 hours without breaking downstream environments.
Lessons Learned
- Ban Dynamic Floating Tags Everywhere: Relying on tags like
:latest,:dev, or:stagingin Kustomize introduces non-deterministic deployments where ArgoCD reportsSyncedeven though underlying container byte outputs differ between node pulls. Use explicit SHA digests in production, enforced via CI python validation scripts. - Resist Shared Values Files: Early on, we attempted to create a
base/common-config.envthat all environments imported. This caused immediate coupling. Ifstagingneeds a new parameter thatprodisn’t ready for yet, shared files break. Keep environment overlays completely decoupled from one another. Duplicate five lines of static key names if it avoids a global blast radius. - Explicitly Limit Auto-Sync Scope: Never enable
selfHealandautomatedsyncing on production trading namespaces without human-in-the-loop diff confirmations. Automated sync belongs indevand ephemeral branch environments; production requires manual triggers backed by automated pre-sync CI checks.