cd /news/developer-tools/production-grade-gitops-on-aws-verif… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-110668] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Production-Grade GitOps on AWS: Verified Release Promotion Across EKS Environments with Argo CD and Kargo

A developer detailed a production-grade GitOps architecture on AWS that uses separate EKS clusters per environment, ECR as the registry, OIDC federation instead of static credentials, and Kargo to automate release promotion between environments. The setup includes automated verification gates backed by real metrics before promotion, and emphasizes promoting image digests rather than tags to ensure byte-identical artifacts reach production.

read8 min views2 publishedAug 25, 2026

There's a moment every platform team hits: Argo CD is syncing beautifully, deployments are declarative, and yet moving a release from dev to staging to production still means a human editing a values file and hoping. The deployment problem is solved; the promotion problem isn't.

In this article, I want to walk through how we close that gap on AWS: separate EKS clusters per environment, ECR as the registry, OIDC federation everywhere instead of static credentials, ApplicationSets instead of copy-pasted manifests, and β€” critically β€” automated verification gates backed by real metrics before anything gets promoted.

This is the architecture my team runs variations of in production, and the reasoning behind each decision.

Before touching any tool, it's worth being precise about what "promotion" means in a GitOps world.

Argo CD's job is narrow and it does it extremely well: make the cluster match what Git says. It does not decide when a new version should move from dev to staging to production. That decision β€” historically made by a human editing a values file, or by a brittle CI job running sed

against your repo β€” is the promotion problem.

Kargo exists to own exactly that gap. It watches your artifact sources (ECR, Git, Helm repos), models each environment as a Stage, packages new artifact versions as Freight, and promotes that Freight between stages by writing commits to your GitOps repo. Argo CD then does what it always does: reconcile.

The result is a clean separation of concerns:

Every environment's state is a commit. Every promotion is auditable. Rollback is git revert

. That's the whole philosophy.

The single biggest gap between tutorial GitOps and production GitOps is environment isolation.

Namespaces on a shared cluster do not give you blast-radius isolation. A misbehaving controller, a noisy neighbor exhausting node resources, a cluster upgrade gone wrong β€” all of these take dev and prod down together. In a real AWS setup:

That last point deserves emphasis: the artifact that reaches production must be byte-identical to the one validated in staging. Rebuilding "the same" image per environment silently invalidates everything your pipeline verified. Promote digests, not tags.

If your GitHub Actions workflow authenticates to AWS with an AWS_ACCESS_KEY_ID

stored in repository secrets, that's a standing credential waiting to leak. GitHub's OIDC provider lets each workflow run exchange a short-lived, cryptographically verifiable token for a scoped IAM role session:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::<SHARED_ACCT>:role/gha-ecr-push-frontend
      aws-region: eu-west-1

  - uses: aws-actions/amazon-ecr-login@v2

  - name: Build, tag, push
    run: |
      IMAGE=$ECR_REGISTRY/frontend
      docker build -t $IMAGE:$VERSION .
      docker push $IMAGE:$VERSION

The IAM role's trust policy pins the exact repository and branch (repo:digitalzone/frontend:ref:refs/heads/main

), so a compromised fork or a rogue workflow in another repo simply cannot assume it. Scope one role per service, with push rights to only that service's ECR repository.

Two additions that cost little and pay off in audit season:

Semantic tags (v1.4.2

) remain useful for humans, but your promotion machinery should carry the digest end to end.

I follow a polyrepo model for application code β€” each microservice owns its repo, its CI, and its release cadence β€” with a single GitOps repo as the deployment control plane:

platform-gitops/
β”œβ”€β”€ charts/                  # One Helm chart per service
β”‚   └── frontend/
β”œβ”€β”€ envs/
β”‚   β”œβ”€β”€ dev/frontend/values.yaml
β”‚   β”œβ”€β”€ staging/frontend/values.yaml
β”‚   └── prod/frontend/values.yaml
β”œβ”€β”€ argocd/
β”‚   └── applicationsets/
└── kargo/
    └── frontend/            # Warehouse, Stages, PromotionTasks

A rule I hold firm on: environments are directories, not branches. Long-lived dev

/staging

/prod

branches turn every promotion into a merge with drift and conflict potential. A single main

branch with per-environment values directories means the diff between environments is always visible in one git diff

β€” which is exactly what you want at 2 a.m. during an incident.

Instead of hand-maintaining an Argo CD Application per service per environment (that's NΓ—M YAML files that will drift), generate them with an ApplicationSet:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: services
spec:
  generators:
    - matrix:
        generators:
          - git:
              repoURL: https://github.com/digitalzone/platform-gitops
              directories:
                - path: envs/*/*
          - list:
              elements:
                - env: dev
                  cluster: https://dev-cluster-endpoint
                - env: staging
                  cluster: https://staging-cluster-endpoint
                - env: prod
                  cluster: https://prod-cluster-endpoint
  template:
    metadata:
      name: '{{path.basename}}-{{env}}'
    spec:
      project: platform
      source:
        repoURL: https://github.com/digitalzone/platform-gitops
        path: charts/{{path.basename}}
        helm:
          valueFiles:
            - ../../envs/{{env}}/{{path.basename}}/values.yaml
      destination:
        server: '{{cluster}}'
        namespace: '{{path.basename}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

Adding a new service to every environment becomes: add a chart, add three values files, done. No new Application YAML, ever.

One deliberate asymmetry: I keep selfHeal

and prune

fully automated in dev and staging, but in production I pair automated sync with sync windows and require promotion (not sync) to be the gated step. The cluster should always converge on Git β€” the control point is what gets into Git.

Kargo's Warehouse polls ECR for new images. On EKS, don't feed it credentials β€” bind its controller service account to an IAM role via IRSA (or EKS Pod Identity) with read-only ECR permissions:

apiVersion: kargo.akuity.io/v1alpha1
kind: Warehouse
metadata:
  name: frontend
  namespace: kargo-platform
spec:
  subscriptions:
    - image:
        repoURL: <ACCT>.dkr.ecr.eu-west-1.amazonaws.com/frontend
        semverConstraint: ">=1.0.0"
        strictSemvers: true

Each environment is a Stage. Dev subscribes directly to the warehouse; staging subscribes to dev's verified freight; prod subscribes to staging's:

apiVersion: kargo.akuity.io/v1alpha1
kind: Stage
metadata:
  name: staging
  namespace: kargo-platform
spec:
  requestedFreight:
    - origin:
        kind: Warehouse
        name: frontend
      sources:
        stages: [dev]        # Only freight verified in dev is eligible
  promotionTemplate:
    spec:
      steps:
        - uses: git-clone
        - uses: yaml-update
          config:
            path: envs/staging/frontend/values.yaml
            updates:
              - key: image.tag
                value: ${{ imageFrom("...frontend").Tag }}
        - uses: git-commit
        - uses: git-push
        - uses: argocd-update

That sources.stages: [dev]

line is the promotion graph. An image physically cannot reach staging without having passed dev, and cannot reach prod without passing staging. The pipeline topology is declarative and enforced β€” not a convention someone can forget under deadline pressure.

Automated promotion without automated verification is just automated blast radius. This is where most write-ups wave their hands, and where the real engineering lives.

Kargo integrates with Argo Rollouts' AnalysisTemplates, which means each stage can run metric-backed verification against your observability stack before its freight becomes eligible for the next stage. We run the LGTM stack, so verification queries Prometheus directly:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: frontend-health
spec:
  metrics:
    - name: error-rate
      interval: 1m
      count: 10
      failureLimit: 1
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_requests_total{app="frontend",status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total{app="frontend"}[5m]))
      successCondition: result[0] < 0.01
    - name: p99-latency
      interval: 1m
      count: 10
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            histogram_quantile(0.99,
              sum(rate(http_request_duration_seconds_bucket{app="frontend"}[5m])) by (le))
      successCondition: result[0] < 0.5

A release now soaks in dev, gets measured against real SLO-shaped queries for ten minutes, and only then becomes promotable. The same pattern in staging, ideally alongside synthetic traffic or smoke tests, gives you two independent metric-verified gates before production.

For the prod stage itself, pair the promotion with Argo Rollouts canary steps β€” shift 10% of traffic behind the ALB, re-run the same analysis against the canary subset, then progress. Verification and progressive delivery share templates, which keeps your definition of "healthy" in exactly one place.

The final gate β€” staging to prod β€” stays manual by policy in our setup: a human clicks approve in Kargo's UI (or via the CLI, from Slack). Not because the automation isn't trusted, but because production timing is a business decision. During a regional sales event on our ticketing platform, "verified and ready" and "deploy right now" are very different statements.

v1.4.2

to ECR via OIDC.envs/dev/frontend/values.yaml

, Argo CD syncs the dev cluster.envs/prod/frontend/values.yaml

; Argo CD executes a canary rollout with in-flight analysis; full traffic shift on success.Mean time from merge to verified-in-staging: about twenty-five minutes, with zero human involvement. Mean human effort per production release: one approval click.

Promote digests, pin everything. Tags are for humans; digests are for machines. Immutable ECR tags plus digest-based promotion eliminates an entire category of "but it worked in staging" incidents.

Verification queries are product code. Treat AnalysisTemplates with the same review rigor as application code. A query with a subtly wrong label selector is a green light that means nothing.

Keep the emergency path inside GitOps. When you need to ship a hotfix at 3 a.m., the answer is Kargo's manual promotion of a specific freight β€” not kubectl edit

. If your break-glass procedure bypasses Git, your audit trail is fiction precisely when you'll need it most.

Budget for ECR lifecycle policies early. A warehouse polling a repository with ten thousand untagged images is slow and expensive. Expire untagged images aggressively; keep the last N releases per service.

Don't over-gate. Every manual approval you add is a place where releases queue and context evaporates. Gate production. Let metrics gate everything else.

The tooling here β€” Actions, Argo CD, Helm, Kargo β€” is the same stack you'll find in a weekend tutorial. What separates a demo from a platform is everything around it: account-level isolation, federated identity end to end, generated rather than hand-written manifests, and promotion gates that measure reality instead of assuming it.

Git as the source of truth is the principle. Verified, auditable, boring promotions are the payoff.

How is your team handling environment promotion today β€” Git commits, CI scripts, or still a human with kubectl? I'd genuinely like to hear what's working (and what isn't) in the comments.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @aws 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/production-grade-git…] indexed:0 read:8min 2026-08-25 Β· β€”