Skip to content
Cloud Native

Multi-environment GitOps: one repo layout that survived dev, staging, and prod

gitops repository structure — docked shipping ship on body of water

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.

# apps/order-execution/base/deployment.yaml
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.

# apps/order-execution/overlays/prod/kustomization.yaml
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.

# bootstrap/root-app.yaml
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.

# bootstrap/apps/dev-order-execution.yaml
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.

#!/usr/bin/env python3
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:

$ python3 scripts/validate_gitops.py
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:

$ argocd app list –port-forward –port-forward-namespace argocd
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:

$ argocd app diff argocd/prd-order-exec –port-forward –port-forward-namespace argocd
====== 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 kubectl interventions 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

  1. Ban Dynamic Floating Tags Everywhere: Relying on tags like :latest, :dev, or :staging in Kustomize introduces non-deterministic deployments where ArgoCD reports Synced even though underlying container byte outputs differ between node pulls. Use explicit SHA digests in production, enforced via CI python validation scripts.
  2. Resist Shared Values Files: Early on, we attempted to create a base/common-config.env that all environments imported. This caused immediate coupling. If staging needs a new parameter that prod isn’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.
  3. Explicitly Limit Auto-Sync Scope: Never enable selfHeal and automated syncing on production trading namespaces without human-in-the-loop diff confirmations. Automated sync belongs in dev and ephemeral branch environments; production requires manual triggers backed by automated pre-sync CI checks.

Join the conversation

Your email address will not be published. Required fields are marked *