Argo CD Lesson 40 of 45

Environment Promotion: Dev → Staging → Prod the GitOps Way (PR-Based, No Drift)

Every GitOps team eventually stands in front of the same whiteboard and asks the question that decides whether their platform is trustworthy or terrifying: a change is good in dev — now how does it get to staging, and then to production, without anyone breaking anything? Get the answer right and promotion becomes a boring, auditable, one-line pull request that anybody on the team can review over coffee. Get it wrong and promotion becomes a folklore ritual — someone SSHes to a jump box, runs a helm upgrade from their laptop, clicks Sync in the Argo CD UI, and prays that prod resembles the staging they tested.

This lesson is the whole discipline in one place. We treat promotion as what it actually is under GitOps: a controlled change to an environment’s desired state in Git. Not a command you run against a cluster. Not a button you press. A commit — almost always a pull request — that moves one number forward. We will compare the three repo-structuring patterns honestly (the good, the fallen-out-of-favour, and the “it depends”), destroy config drift with a shared base, wire up a real PR-based pipeline with reviewers and sync windows, fan a promotion out across regions, and look at the purpose-built tools (Argo CD Image Updater’s write-back, and Kargo) that automate the paperwork.

Everything here targets Argo CD 2.13+/3.x on Kubernetes 1.29+, and the mechanics are cloud-neutral — a tag bump is a tag bump on AKS, EKS or GKE alike. The one genuinely cloud-specific edge, per-environment secrets, gets all three clouds covered where it appears. Let’s build the pipeline every reviewer will thank you for.


Why this matters

Promotion is where GitOps either pays off or falls apart, because it is the exact seam between “a change is safe here” and “a change is live for customers”. A platform can have flawless manifests, gorgeous ApplicationSets and a self-healing controller, and still cause a Sev-1 — because the human process for moving a change forward was a Slack message and a hand-run command. The manifests were declarative; the promotion was not.

There are only two ways promotion goes wrong, and almost every production incident born of a deploy is one of them:

The two failure modes What it looks like Why it hurts
You promote the wrong thing A rebuilt image with the same tag; a chart version that moved under you; a config value you forgot was different in prod What you tested is not what shipped. “Works in staging, breaks in prod” is this, every time.
You promote in an uncontrolled way kubectl apply from a laptop; clicking Sync in the UI; prod auto-syncing an unreviewed merge at 2am No review, no record, no gate. The cluster diverges from Git, and nobody can say who changed what, when, or why.

The mental model that dissolves both is a single sentence: an environment is a folder in Git that declares desired state, and promotion is a reviewed edit to that folder. Dev, staging and prod are not three clusters you push to; they are three files (or three directories) you change, and Argo CD’s job is to make each cluster match its file. Promotion never touches a cluster directly. It touches Git, a human (or a policy) approves the change, and the pull-based controller does the rest.

Hold onto three consequences of that model, because the rest of the lesson is their detail:

If you have run kubectl apply and nothing else, this lesson is the leap from “I can deploy” to “I can run a promotion pipeline a bank would sign off on.” Let’s start with the principle everything hangs from.


The core principle: promotion is a Git change, not a kubectl

Under a push-based world (plain CI running kubectl/helm against clusters), promotion is an action: a pipeline stage that reaches into staging, then another that reaches into prod. The environments are destinations you push to, and the record of what’s running lives in the head of whoever ran the last pipeline.

Under GitOps, promotion inverts. The environment’s desired state is a file in Git; a controller (Argo CD) continuously makes the cluster match that file. So to change what’s running, you change the file — and because it’s Git, that change is a commit, which means it can be a pull request, which means it can be reviewed, approved, recorded and reverted. The promotion is the PR.

Push-style promotion (imperative) GitOps promotion (declarative)
The unit of promotion A pipeline run against an environment A commit/PR that edits an environment’s file
Where “what’s live” is recorded In CI history / operator memory In Git — the file is the source of truth
How you review it Read a pipeline log after the fact Review a diff before it merges
How you gate it Pipeline approvals, if any Branch protection + CODEOWNERS + sync windows
How you roll back Re-run an old pipeline (hope it’s reproducible) git revert — the exact prior desired state
Who applies it The CI runner’s credentials, into the cluster Argo CD, pulling from Git into the cluster
Drift visibility Invisible until something breaks Argo CD reports OutOfSync continuously

The declarative column is the entire reason GitOps exists, and promotion is where you feel it most. Because the desired state is a reviewed artifact in Git, the question “what is running in prod, and who approved it?” has a precise answer: the file at HEAD of the prod path, and the approver on the PR that last changed it. No archaeology.

Two anti-patterns are worth naming immediately, because they quietly re-introduce the push model into a GitOps shop:

The full set of pretenders, and their honest replacement:

“Promotion” that isn’t one Why it’s not a promotion Do this instead
Clicking Sync in the UI Reconciles the current Git state; changes no desired state Open a PR that edits the next env’s file
kubectl set image / kubectl edit on prod Mutates live state; Argo reports OutOfSync, self-heal reverts it Bump the tag in the prod overlay via PR
helm upgrade from a laptop Push-model apply with personal creds; no record, no review Let Argo CD pull the reviewed commit
Rebuilding the image for prod A different artifact under the same tag Promote the digest built once in CI

Promotion is a diff in Git. Everything else in this lesson is about making that diff small, legible, correct, and hard to get wrong.


What actually moves — and what stays put

If promotion is “edit the file”, the sharpest question is: edit which line? The answer is the crux of doing this safely. You promote a reference to an immutable artifact. You do not promote the artifact, and you do not promote environment-specific configuration.

An image built by CI is content — a set of layers, addressable by a cryptographic digest (sha256:…). That content is immutable: registry.example.com/web@sha256:9f2c… means exactly one set of bytes, forever. A tag (:2.3.1) is a human-friendly label pointing at a digest. Promotion moves that pointer from environment to environment; the bytes it points at never change. This is why “the same artifact runs everywhere” is achievable at all.

Thing Immutable? Does it move during promotion? Notes
Image digest (@sha256:…) Yes — content-addressed It is what you’re promoting (the reference) The gold standard. Pin to digest and “same artifact” is guaranteed.
Image tag (:2.3.1) No — a mutable label Yes, the tag string is what the PR bumps Fine if your registry enforces tag immutability; otherwise pin the digest too.
Chart version (Helm) Yes, if pinned to a version Yes — bump targetRevision/chart version A moving chart is an un-pinned artifact. Pin it.
Application source code No — code isn’t promoted; the built artifact is You never rebuild per env (see the anti-pattern below).
Env-specific config (replicas, hostnames, resource limits, feature flags, secret store name) No — it stays put in that env’s overlay Prod has 12 replicas; dev has 1. That difference is permanent, not promoted.
Secrets (values) No — never in Git; resolved per-env at runtime Promote the reference to a secret, never the secret.

Pinning is what makes “same artifact everywhere” a guarantee rather than a hope. Each artifact type has a way to pin it so a promotion is deterministic:

Artifact type How to pin it so promotion is deterministic What breaks if you don’t
Container image Reference by digest (@sha256:…), or a tag your registry marks immutable A re-pushed tag silently changes what prod runs
Helm chart Pin targetRevision to an exact chart version (or a Git SHA for a repo chart) A moving chart deploys code you never reviewed
Config repo revision Prod Application tracks a tag/SHA (or you gate main with branch protection) Any merge to main becomes an unreviewed prod change
Values / overlay Versioned by the Git commit itself — no external mutability Out-of-band edits become silent drift

The rows that trip teams up are the env-specific config and secrets rows — the things that stay put. Beginners imagine promotion as “copy staging’s whole config to prod.” It is the opposite: only the artifact reference crosses the boundary; everything env-specific is a permanent, reviewed difference that lives in that environment and never travels. Prod’s replica count, prod’s ingress hostname, prod’s HPA ceiling, prod’s secret-store name — these are supposed to differ, forever. Promotion must move the tag without dragging staging’s replica count into prod.

That is exactly what a shared base plus per-env overlays gives you, and why the structure you choose (next section) is not a cosmetic preference — it decides whether “move only the tag” is a one-line diff or a merge minefield.

The immutability rule, stated once so you never forget it: the artifact you deploy to prod must be bit-for-bit the artifact you validated in staging, which is the one you first ran in dev. The only correct way to guarantee that is to promote a reference (tag pinned to a digest), and to never rebuild the image for a different environment. A rebuilt image is a different artifact wearing a familiar name.


Repo & branch structuring patterns, compared honestly

Where you put per-environment config decides everything downstream: how a promotion looks as a diff, how badly environments drift, and how big the blast radius of a mistake is. There are three patterns in the wild. Two are good for different reasons; one is a trap that many teams learn about the hard way.

Pattern 1 — Directory-per-environment (the recommended default)

One branch (main), one directory per environment, each pointing at a shared base:

gitops-repo/
  base/                          # ONE definition of the app — shared by all envs
    kustomization.yaml
    deployment.yaml
    service.yaml
  envs/
    dev/
      kustomization.yaml         # base + dev overlay (replicas:1, image tag)
    staging/
      kustomization.yaml         # base + staging overlay (replicas:3, image tag)
    prod/
      kustomization.yaml         # base + prod overlay (replicas:12, image tag, HPA)

Promotion is copying the tag from one env’s file to the next, via a PR against main. The tag that dev proved gets written into envs/staging/kustomization.yaml; later, into envs/prod/kustomization.yaml. Each environment is a directory Argo CD watches with a separate Application. This is the pattern most teams converge on because the promotion diff is a single, obvious line and every environment lives on one branch you can grep in one place.

Pattern 2 — Branch-per-environment (fallen out of favour)

A long-lived branch per environment — dev, staging, prod — and each Argo CD Application tracks its own branch (targetRevision: prod). Promotion is a merge or cherry-pick from staging into prod.

It sounds elegant (“prod is just the prod branch”) and it is how many teams started, but it has three structural problems that compound at scale:

Branch-per-env is not wrong for a tiny single-config app, but for anything with real per-environment differences it turns the reviewed-diff superpower into a liability. This is why the community broadly moved to directory-per-env.

Pattern 3 — Overlay-per-environment (the mechanism, not a rival)

Strictly, “overlay-per-env” is how you express the per-env differences inside Pattern 1 (or even Pattern 2): Kustomize bases + overlays, or Helm with a shared chart and per-environment values files. It is not really a third repo layout so much as the templating engine that keeps directory-per-env DRY. We lean on it heavily in the drift section, and the mechanics live in the dedicated lessons: Kustomize integration and, for the fan-out, ApplicationSets & generators.

Here is the honest comparison — the table to screenshot when your team argues about this:

Dimension Directory-per-env (Pattern 1) Branch-per-env (Pattern 2) Overlay engine (used within #1)
Drift risk Low — all envs visible on one branch, base shared High — branches diverge silently over time Low — enforces shared base + explicit deltas
Promotion diff clarity Excellent — one line (newTag) Poor — merge drags unrelated changes Excellent — the delta is the overlay
Blast radius of a bad change Scoped to one env’s directory Can leak across branches on merge Scoped to the overlay you edit
Review clarity (CODEOWNERS) Per-directory ownership is trivial Awkward — ownership is per-branch Per-directory/per-file
Atomic cross-env change Yes — one PR touches all envs if needed No — N merges Yes
“What’s live everywhere?” grep newTag envs/*/ on one branch Diff N branches, carefully One grep
Tooling fit (Kargo, Image Updater) First-class — both write to paths Weaker — branch write-back is clumsier First-class
When it’s the right call Almost always Tiny single-config app; strong branch-based org policy Whenever you have real per-env deltas (i.e., always)

The recommendation the rest of this lesson assumes: directory-per-env on a single branch, with a Kustomize (or Helm) shared base, and per-environment overlays that express only the delta. It gives you the small, legible promotion diff that makes review meaningful, keeps every environment comparable, and is exactly what the promotion tooling expects.

If you are still weighing repo topology at the org level — one monorepo versus many — that is its own decision with its own trade-offs, covered in Monorepo vs Polyrepo GitOps. This lesson is orthogonal to it: directory-per-env works inside a monorepo or a per-app config repo.


Avoiding config drift between environments (the #1 promotion problem)

Ask any platform engineer for their worst deploy story and a shocking fraction reduce to four words: staging and prod diverged. A value someone changed in prod months ago during an incident and never wrote down. A feature flag flipped in staging for a demo. A resource limit bumped in prod but not staging. Individually trivial; together they mean “passed in staging” stops predicting “works in prod”. This silent divergence is config drift between environments, and it is the single biggest reason promotions fail even when the artifact is correct.

The fix is structural, not disciplinary. You cannot solve drift by asking people to be careful; you solve it by making every environment difference explicit, minimal, and reviewed. Concretely: one shared base, and per-environment overlays that contain only the delta.

Drift almost always enters through the same handful of doors — know them so you can shut them:

How environments drift apart Typical trigger The structural defence
An incident hotfix never written back to Git kubectl edit at 3am to stop the bleeding Self-heal reverts it and flags OutOfSync; write the fix back as a PR
A demo flag flipped in staging, left on A manual toggle for a stakeholder Flags live in the overlay; a toggle becomes a reviewed diff
Prod scaled up by hand, never in Git kubectl scale during a traffic spike Replica count is an explicit overlay line; HPA in Git
A value tuned in one env only Copy-paste divergence over months Shared base; overlays hold only the reviewed delta
Prod ahead/behind on the base itself Base changes merged unevenly One base for all envs; a render-diff check in CI

Here is the shared base — one definition of the app, identical for every environment:

# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
commonLabels:
  app.kubernetes.io/name: web
# base/deployment.yaml — the SHARED shape; env-specific numbers are NOT here
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1                     # a safe default; overlays override per env
  selector:
    matchLabels:
      app.kubernetes.io/name: web
  template:
    metadata:
      labels:
        app.kubernetes.io/name: web
    spec:
      containers:
        - name: web
          image: registry.example.com/web   # tag set by the overlay, not here
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 128Mi

Now each environment overlay expresses only what genuinely differs. Dev:

# envs/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-dev
resources:
  - ../../base
images:
  - name: registry.example.com/web
    newTag: "2.3.1"               # the promoted reference — dev has it first
# dev keeps base defaults: replicas 1, small resources

Prod:

# envs/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-prod
resources:
  - ../../base
images:
  - name: registry.example.com/web
    newTag: "2.3.0"               # prod still on the previous version — until promoted
replicas:
  - name: web
    count: 12                     # the ONLY structural prod difference, made explicit
patches:
  - path: hpa.yaml                # prod-only HorizontalPodAutoscaler, visible in Git

The magic is what you cannot do here: you cannot let prod drift, because there is nowhere for an unreviewed difference to hide. If prod needs 12 replicas, that is a line in envs/prod/kustomization.yaml that someone approved. If someone kubectl edits it to 20 during an incident, Argo CD reports OutOfSync and (with self-heal) reverts it — the drift is loud, not silent. The base guarantees the two environments share a shape; the overlay makes every deviation a reviewed artifact.

Use this rule to decide where any given setting belongs:

Setting Belongs in base/ Belongs in an overlay Why
Container ports, probes, labels Identical everywhere — shared shape
The structure of the Deployment/Service One definition, no duplication
Image tag/digest (a default) ✅ per env This is the promoted reference; it differs by env by design
Replica count (a safe default) ✅ if it differs Prod scales up; make it explicit
Resource requests/limits (a default) ✅ if it differs Prod often bigger; a reviewed line
Ingress hostname dev.example.com vs example.com — permanent difference
HPA, PodDisruptionBudget ✅ (often prod-only) Explicit prod hardening
Secret store name / path ✅ per env Points at the per-env secret backend (never the value)
Feature flags ✅ if they differ A flag on in staging but off in prod is drift unless it’s in Git

The anti-drift test: can you run diff <(kustomize build envs/staging) <(kustomize build envs/prod) and explain every line of the output by pointing at an overlay? If yes, you have no drift — only intended, reviewed differences. If a line surprises you, you just found your next incident early. Wire that diff into CI and drift becomes a failing check, not a postmortem.

If an overlay starts to look like a near-complete copy of the base, your base is under-parameterised — pull the shared parts up. The override file is the diff a reviewer approves; keep it small enough to read in ten seconds.


PR-based promotion: the actual flow

Now we assemble the pieces into the flow a real team runs. The shape is always the same, and CI does the building while Git + Argo CD do the promoting — the handoff is the whole art.

Read the pipeline left → right. The image is built once and is immutable; from there, promotion is a sequence of pull requests moving that one reference through gated environments, with Argo CD reconciling each environment’s file onto its cluster.

Promotion pipeline read left to right: an image is built once in CI and pushed to a registry, then a pull request bumps the image tag in the dev environment file which Argo CD auto-syncs, then a review-gated PR promotes the same tag to staging where a canary runs, then an approval-and-sync-window-gated PR promotes it to prod, which Argo CD manual-syncs and fans out across prod-eu and prod-us via an ApplicationSet, all built on one shared no-drift base

The six badges mark where teams win or lose: promote the immutable artifact rather than rebuild it (1); a shared base so environments never drift silently (2); dev auto-syncs and is disposable (3); staging is the review gate where the canary runs (4); prod demands approval and a sync window with manual sync (5); and the fan-out to regions goes one at a time, not all at once (6). Let’s walk each stage.

The flow, step by step

# Stage Who does it What happens Gate
1 Build CI (not Argo CD) Build image, push to registry, tag :2.3.1 (pin a digest) CI tests pass
2 Bump dev CI or a bot Open/auto-merge a PR setting newTag: 2.3.1 in envs/dev Usually auto-merge
3 Sync dev Argo CD Auto-sync + self-heal applies it to the dev cluster in seconds none (dev is disposable)
4 Validate dev Tests / humans Smoke tests, manual poke; dev is the fast feedback loop informal
5 Promote → staging A person (or bot) opens a PR Copy the same tag into envs/staging CODEOWNERS review
6 Sync + canary staging Argo CD + Rollouts Sync staging; run a canary with metric analysis canary analysis gate
7 Promote → prod A person opens a PR Copy the same tag into envs/prod required reviewers + branch protection
8 Sync prod A human triggers argocd app sync Manual sync, inside a sync window; fan out region by region sync window + manual sync

Notice the asymmetry that makes this safe: the gate tightens as you move right. Dev auto-syncs (speed). Staging needs a review (a human confirms the tag). Prod needs a named approver and a maintenance window and a human on the sync button (defence in depth). The same artifact flows through all three; only the ceremony changes.

The promotion PR — what a reviewer actually sees

This is the payoff of directory-per-env plus a shared base. Promoting 2.3.1 from staging to prod is this diff, and nothing else:

# PR: "Promote web 2.3.1 to prod"  (edits envs/prod/kustomization.yaml)
   images:
     - name: registry.example.com/web
-      newTag: "2.3.0"
+      newTag: "2.3.1"

A reviewer approves that in seconds and knows exactly what changes: the prod reference moves from the version prod runs today to the version staging just proved. No replica counts, no hostnames, no surprises — because those live in the overlay and don’t move. Harden it further by pinning the digest, so the tag can never be re-pointed under you:

# envs/prod/kustomization.yaml — digest-pinned promotion (belt and braces)
images:
  - name: registry.example.com/web
    newName: registry.example.com/web
    digest: sha256:9f2c4b8e1d3a6c7f0b2e5d8a1c4f7b0e3d6a9c2f5b8e1d4a7c0f3b6e9d2a5c8f1

The gates, per environment

The controls are not vibes; they are concrete settings you configure once. Here is the matrix:

Control Dev Staging Prod How it’s configured
Argo CD sync mode Automated + self-heal Automated (or manual) Manual Application.spec.syncPolicy
PR merge gate Auto-merge OK Required review Required reviewers Git branch protection
Path ownership none CODEOWNERS: app team CODEOWNERS: platform/SRE .github/CODEOWNERS
Sync window none none allow window only AppProject.spec.syncWindows
Progressive rollout none canary + analysis canary (or blue-green) Argo Rollouts
Who can sync anyone/automated app team platform on-call Argo CD RBAC

Branch protection + CODEOWNERS are your Git-side gate. A CODEOWNERS file makes the right people mandatory reviewers on the right paths:

# .github/CODEOWNERS — path-scoped promotion approval
/base/                 @acme/platform-team
/envs/dev/             @acme/web-developers
/envs/staging/         @acme/web-developers @acme/qa
/envs/prod/            @acme/platform-team  @acme/sre-oncall

Now a PR that edits envs/prod/ cannot merge without a platform-team and an SRE approval — enforced by the forge, not by etiquette. Combine with “require pull request before merging” and “dismiss stale approvals” on the protected branch and the Git gate is airtight.

The branch-protection settings that make CODEOWNERS actually bite (configure once on the protected branch / prod path):

Branch-protection setting Why it matters for promotion
Require a pull request before merging No direct pushes to prod desired state
Require approval from CODEOWNERS The right team (platform/SRE) must sign the prod diff
Required reviewers ≥ 2 for the prod path Two humans see every prod change
Dismiss stale approvals on new commits An approval can’t be reused after the diff changes
Require status checks (render-diff, lint) to pass Drift and invalid manifests are caught before merge
Include administrators / restrict who can push Nobody bypasses the gate — not even admins

Manual sync for prod is simply the absence of an automated block on the prod Application. A merged prod PR then sits as OutOfSync until a human runs argocd app sync web-prod. That deliberate human step is a feature: the merge is reviewed and the apply is intentional.

Sync windows add when. Configured on the AppProject, they restrict the times a sync may happen at all — even a manual one:

# AppProject with a prod change window
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: prod
  namespace: argocd
spec:
  sourceRepos:
    - https://github.com/acme/gitops-repo.git
  destinations:
    - server: https://prod-eu.example.com
      namespace: 'web-prod'
    - server: https://prod-us.example.com
      namespace: 'web-prod'
  syncWindows:
    - kind: allow
      schedule: '0 14 * * 2,4'      # Tue & Thu, 14:00
      duration: 2h                   # a 2-hour change window
      applications:
        - 'web-prod'
      manualSync: true               # humans may sync inside the window
      timeZone: 'Europe/London'
    - kind: deny
      schedule: '0 0 * * *'          # a standing deny...
      duration: 24h                  # ...covering all other times
      applications:
        - 'web-prod'
      manualSync: false              # ...that even blocks manual sync

With that project, an attempt to sync web-prod outside Tuesday/Thursday afternoons is rejected — the change waits for the window. Inside the window, the on-call engineer runs the sync deliberately.

The syncWindows fields, for reference:

syncWindows[] field What it controls Example
kind allow or denydeny always wins over allow allow
schedule Cron expression for when the window opens '0 14 * * 2,4' (Tue & Thu 14:00)
duration How long the window stays open from schedule 2h
applications / namespaces / clusters Which apps/targets the window governs ['web-prod']
manualSync Whether a manual sync is allowed when the window would block true = break-glass allowed
timeZone IANA zone the schedule is evaluated in 'Europe/London'

Automating the paperwork — Image Updater and Kargo

Opening promotion PRs by hand is fine at low volume, but three tools automate the boilerplate. Compare them honestly:

Tool What it does Best for Honest caveat
CI opens the PR (a script/bot) Your pipeline commits the tag bump and opens the promotion PR Any team — no new components You build and maintain the glue yourself
Argo CD Image Updater Watches the registry; on a new matching tag, writes the tag back to Git Auto-advancing dev on every build Registry-triggered, per-image; not a multi-stage promotion engine. See the Image Updater lesson.
Kargo (Akuity) A purpose-built promotion engine: models Warehouses → Freight → Stages and promotes verified Freight stage-to-stage Multi-stage promotion with verification, at scale Newer, extra CRDs/controller to run and learn; evolving API

Argo CD Image Updater is the right tool for the first hop: point it at dev, and every time CI pushes a new tag matching your constraint, it commits that tag into envs/dev (write-back-method git), and Argo CD syncs it. Dev advances with zero human paperwork. It is not a promotion engine, though — it keys off the registry, not off “staging is healthy”, so it does not model dev → staging → prod on its own. Use it to feed the pipeline, not to run it.

Kargo is the emerging, purpose-built answer to exactly the problem this lesson describes. Instead of you scripting PRs, Kargo models the pipeline as first-class objects:

Kargo concept What it is Analogy in this lesson
Warehouse Subscribes to an image repo / Git / chart and produces Freight when something new appears “CI pushed 2.3.1
Freight An immutable snapshot of artifact versions (image digests, chart versions, commits) The exact thing you promote
Stage An environment; requests Freight from a Warehouse or an upstream Stage, and promotes it via configured steps dev, staging, prod
Promotion The act of moving a piece of Freight into a Stage (e.g. writing the tag to that env’s path) Your promotion PR, automated
Verification An AnalysisRun (Argo Rollouts) that must pass before Freight is eligible to move on The staging canary gate

A Kargo Warehouse subscribing to your registry looks roughly like this (schema evolves across Kargo versions — treat as illustrative of the model, and check the docs for your version):

# Illustrative Kargo Warehouse — subscribes to the image, emits Freight on new versions
apiVersion: kargo.akuity.io/v1alpha1
kind: Warehouse
metadata:
  name: web
  namespace: web
spec:
  subscriptions:
    - image:
        repoURL: registry.example.com/web
        semverConstraint: ^2.0.0        # only 2.x tags become Freight
        discoveryLimit: 20

Kargo then promotes that Freight dev → staging → prod, running your verification (a Rollouts AnalysisRun) at each stage and writing the tag into the right env path via Git — the pipeline this lesson builds by hand, expressed declaratively. The honest trade-off: it’s another controller and a new mental model to operate, and the API is still maturing, so adopt it when hand-rolled PR automation becomes the bottleneck, not before.


Progressive promotion: canary in staging, and fan-out across regions

Two refinements turn a competent pipeline into a resilient one: catch bad versions with a canary before prod, and never hit all of prod at once.

Canary in staging before prod

Promotion answers “which artifact”; progressive delivery answers “how carefully do we switch to it”. They compose: promote the tag into staging, then let Argo Rollouts shift traffic to it gradually while watching metrics, so a bad version aborts in staging and never earns its prod PR. The full mechanics are in Argo Rollouts canary & blue-green; here is the shape that matters for promotion:

# envs/staging uses a Rollout instead of a bare Deployment: canary with an analysis gate
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: web
spec:
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: {duration: 5m}
        - analysis:                       # metric gate: abort if bad
            templates:
              - templateName: success-rate
        - setWeight: 50
        - pause: {duration: 5m}
        - setWeight: 100

The promotion-level point: staging is where the canary earns its keep. A version that degrades error rate or latency aborts here — automatically, via the analysis step — and the prod promotion PR is never opened. By the time a change reaches prod, it has been proven both functionally (it works) and progressively (it can be rolled forward without a spike). Prod can then run its own canary or blue-green as a final safety net.

Stage Rollout strategy Why
dev plain Deployment (or fast canary) speed over safety — it’s disposable
staging canary + AnalysisRun the automated quality gate before prod
prod canary (conservative weights) or blue-green final safety net; instant abort on a real user-facing regression

Fan-out across clusters and regions

Production is rarely one cluster. You promote to prod-eu, watch it, then prod-us — never both on the same reconcile tick, or a bad change takes out every region simultaneously. An ApplicationSet with a cluster generator renders one prod Application per region from the same prod overlay (so regions can’t drift from each other), and a RollingSync strategy sequences them:

# One ApplicationSet fans the prod overlay across regions — gated, one at a time
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: web-prod
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  strategy:
    type: RollingSync
    rollingSync:
      steps:
        - matchExpressions:
            - key: region
              operator: In
              values: [eu]          # eu goes first, must be Healthy...
        - matchExpressions:
            - key: region
              operator: In
              values: [us]          # ...before us starts
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod               # every registered prod cluster
  template:
    metadata:
      name: 'web-prod-{{index .metadata.labels "region"}}'
    spec:
      project: prod
      source:
        repoURL: https://github.com/acme/gitops-repo.git
        targetRevision: main
        path: envs/prod             # the SAME overlay for every region — no drift
      destination:
        server: '{{.server}}'
        namespace: web-prod
      syncPolicy: {}                # no automated block → manual sync for prod

Two promotion-critical details: every region reads path: envs/prod, so prod-eu and prod-us are structurally identical by construction — you cannot drift one region from another. And RollingSync means a bad promotion is contained to eu while you watch, before us is touched. This is the fan-out failure mode from the troubleshooting table, defused. (The generator mechanics live in the ApplicationSets lesson.)

The regions promote in a strict order, each gating the next — this is the whole safety property of RollingSync:

Step Clusters selected (region label) Gate before the next step starts
1 euweb-prod-eu prod-eu must reach Healthy
2 usweb-prod-us starts only after step 1 is Healthy
3+ ap, … one region per step never all regions on one reconcile tick

A subtlety worth internalising: a naïve ApplicationSet list generator applies the same template to every element, so if you generate dev/staging/prod from one list, they’d all get the same sync policy — and prod would auto-sync. Either keep prod in a separate Application/ApplicationSet (what we do above — this set is prod-only), or use spec.templatePatch to give prod a different syncPolicy. Fanning one template across tiers without differentiating the gate is exactly how “ApplicationSet hit prod too early” incidents happen.


Secrets & config that differ per environment

This is the one genuinely cloud-specific edge in promotion, so all three clouds get covered. Everything else in this lesson is cloud-neutral — a tag bump is identical on AKS, EKS and GKE. Secrets are not, because the store lives in the cloud.

The rule from the “what moves” section stands: you never promote a secret value, and secret values never live in Git. What differs per environment is which secret store an environment points at — dev reads dev secrets, prod reads prod secrets — and that pointer is a reviewed line in the overlay. The External Secrets Operator (ESO) is the clean way to express it: an ExternalSecret names a per-env store; the store lives in each cloud’s secret manager. (Depth in Secrets: Sealed Secrets, ESO, SOPS & Vault.)

# envs/prod overlay adds an ExternalSecret pointing at the PROD store (name differs per env)
apiVersion: external-secrets.io/v1beta1        # v1 is now GA; v1beta1 remains widely deployed
kind: ExternalSecret
metadata:
  name: web-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: prod-store                            # dev overlay says dev-store; that's the ONLY delta
    kind: ClusterSecretStore
  target:
    name: web-secrets
  data:
    - secretKey: db-password
      remoteRef:
        key: prod/web/db                        # per-env path in the cloud store
        property: password

The per-env difference is secretStoreRef.name (and the remoteRef.key path) — one reviewed line, never a value. The store itself is backed by the cloud, and that is where the three clouds diverge:

Cloud Secret store ESO provider Workload identity to reach it Registry (for the image you promote)
AKS Azure Key Vault azurekv Azure Workload Identity (federated) ACR
EKS AWS Secrets Manager aws (service: SecretsManager) IRSA or EKS Pod Identity ECR
GKE Google Secret Manager gcpsm GKE Workload Identity Artifact Registry

Per-env ClusterSecretStore, one per cloud (paired blocks — the fields genuinely differ):

# AKS — prod store backed by Azure Key Vault (Workload Identity)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: prod-store
spec:
  provider:
    azurekv:
      authType: WorkloadIdentity
      vaultUrl: "https://web-prod-kv.vault.azure.net"
      serviceAccountRef:
        name: eso-prod
        namespace: external-secrets
# EKS — prod store backed by AWS Secrets Manager (IRSA / Pod Identity)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: prod-store
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
      auth:
        jwt:
          serviceAccountRef:
            name: eso-prod
            namespace: external-secrets
# GKE — prod store backed by Google Secret Manager (Workload Identity)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: prod-store
spec:
  provider:
    gcpsm:
      projectID: "acme-web-prod"
      auth:
        workloadIdentity:
          clusterLocation: europe-west1
          clusterName: prod-eu
          serviceAccountRef:
            name: eso-prod
            namespace: external-secrets

The promotion consequence: because each environment’s overlay names its own store, a promoted tag lands in prod and the prod pods resolve prod secrets automatically — no secret ever travelled, no value ever touched Git, and dev’s credentials can never leak into prod because dev’s overlay never referenced the prod store. The image reference you promoted is cloud-neutral; the secret store it lands next to is cloud-specific and per-env. That separation is the whole trick.


Hands-on lab

You will build a complete dev → staging → prod promotion at the config level — a directory-per-env repo, a shared base, three Applications with prod on manual-sync inside a sync window, and then walk an actual promotion as a series of PRs, watching the diff shrink to one line. It runs on a free local kind cluster; nothing here bills. We simulate the three “environments” as three namespaces on one cluster so anyone can follow — the manifests are identical to a real three-cluster setup except the destination.server values.

No real cluster is required to read this and learn the structure. If you do run it, the outputs shown are representative — exact strings vary by version. We do not promote by rebuilding anything; the “artifact” is a stock public image whose tag we move.

Step 0 — Prerequisites

# kind, kubectl, and the Argo CD CLI installed; then a throwaway cluster:
kind create cluster --name promo
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-server   # wait until Available

What just happened: a local cluster with Argo CD installed. This is the control plane that will reconcile our three “environments”.

Step 1 — Lay out the directory-per-env repo

Create this structure and push it to a Git repo Argo CD can read (any public repo works for the lab; substitute your URL below):

gitops-lab/
  base/
    kustomization.yaml
    deployment.yaml
  envs/
    dev/kustomization.yaml
    staging/kustomization.yaml
    prod/kustomization.yaml
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
# base/deployment.yaml — a stock image so the lab needs no CI
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels: {app: web}
  template:
    metadata:
      labels: {app: web}
    spec:
      containers:
        - name: web
          image: hashicorp/http-echo      # tag is set per-env by the overlay
          args: ["-text=hello"]
          ports: [{containerPort: 5678}]
# envs/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-dev
resources: [../../base]
images:
  - name: hashicorp/http-echo
    newTag: "0.2.3"          # dev gets the new version first
# envs/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-staging
resources: [../../base]
images:
  - name: hashicorp/http-echo
    newTag: "0.2.1"          # staging still on the old version
replicas:
  - {name: web, count: 3}    # explicit, reviewed per-env difference
# envs/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-prod
resources: [../../base]
images:
  - name: hashicorp/http-echo
    newTag: "0.2.1"          # prod still on the old version too
replicas:
  - {name: web, count: 5}
kubectl create namespace web-dev
kubectl create namespace web-staging
kubectl create namespace web-prod

What just happened: one base, three overlays. Dev is ahead (0.2.3); staging and prod trail (0.2.1). The per-env deltas (replica counts) are explicit lines. This is the no-drift structure in miniature.

Step 2 — The AppProject with a prod sync window

# project-prod.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: prod
  namespace: argocd
spec:
  sourceRepos: ['*']
  destinations:
    - {server: 'https://kubernetes.default.svc', namespace: 'web-prod'}
  clusterResourceWhitelist: []            # deny cluster-scoped resources (least privilege)
  namespaceResourceWhitelist:
    - {group: '*', kind: '*'}
  syncWindows:
    - kind: allow
      schedule: '0 14 * * 2,4'            # Tue & Thu 14:00, 2h window
      duration: 2h
      applications: ['web-prod']
      manualSync: true
kubectl apply -f project-prod.yaml

What just happened: prod lives in its own AppProject with a sync window — outside Tue/Thu afternoons, even a manual sync is blocked (we’ll see the block, then override it for the lab). (Project boundaries in depth: AppProjects & multi-tenancy.)

Step 3 — Three Applications, three sync policies

The heart of the lab: dev auto-syncs, staging auto-syncs, prod is manual.

# apps.yaml — one file, three Applications
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: web-dev, namespace: argocd}
spec:
  project: default
  source:
    repoURL: https://github.com/YOU/gitops-lab.git
    targetRevision: main
    path: envs/dev
  destination: {server: https://kubernetes.default.svc, namespace: web-dev}
  syncPolicy:
    automated: {prune: true, selfHeal: true}    # dev: fully automated
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: web-staging, namespace: argocd}
spec:
  project: default
  source:
    repoURL: https://github.com/YOU/gitops-lab.git
    targetRevision: main
    path: envs/staging
  destination: {server: https://kubernetes.default.svc, namespace: web-staging}
  syncPolicy:
    automated: {prune: true, selfHeal: true}    # staging: automated (add a canary in real life)
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: web-prod, namespace: argocd}
spec:
  project: prod                                 # the project WITH the sync window
  source:
    repoURL: https://github.com/YOU/gitops-lab.git
    targetRevision: main
    path: envs/prod
  destination: {server: https://kubernetes.default.svc, namespace: web-prod}
  syncPolicy: {}                                # NO automated block → prod is manual-sync
kubectl apply -f apps.yaml
argocd app list           # representative:
# NAME         SYNC STATUS   HEALTH    PROJECT
# web-dev      Synced        Healthy   default
# web-staging  Synced        Healthy   default
# web-prod     OutOfSync     Missing   prod        <- manual: nothing applied yet

What just happened: dev and staging reconciled themselves. Prod shows OutOfSync/Missing because it has no automated block — it will not apply until a human syncs it. That is the manual-sync gate, working.

argocd app sync web-prod        # first prod sync, inside the lab
# (If outside the window you'd see a block — see Step 6.)
argocd app get web-prod         # now Synced/Healthy on 0.2.1

The three Applications differ only in path, project, and sync policy — the gate made concrete:

Application path project syncPolicy Extra gate
web-dev envs/dev default automated + selfHeal none — disposable
web-staging envs/staging default automated (add a canary IRL) CODEOWNERS review on the PR
web-prod envs/prod prod {} — manual required reviewers + sync window

Step 4 — Promote 0.2.3: dev → staging (the review PR)

In a real repo this is a PR; here we make the same edit. Change staging’s tag to match dev:

# PR #1: "Promote web 0.2.3 to staging"  (envs/staging/kustomization.yaml)
   images:
     - name: hashicorp/http-echo
-      newTag: "0.2.1"
+      newTag: "0.2.3"
git commit -am "Promote web 0.2.3 to staging" && git push
# staging auto-syncs (or: argocd app sync web-staging)
argocd app get web-staging      # representative: Synced, image now 0.2.3
kubectl -n web-staging get deploy web -o jsonpath='{..image}{"\n"}'
# hashicorp/http-echo:0.2.3

What just happened: the promotion to staging was a one-line diff copying the exact tag dev proved. In production this PR would require a CODEOWNERS review before merge. Staging now runs 0.2.3; prod still runs 0.2.1.

Step 5 — Promote 0.2.3: staging → prod (the approval PR)

# PR #2: "Promote web 0.2.3 to prod"  (envs/prod/kustomization.yaml)
   images:
     - name: hashicorp/http-echo
-      newTag: "0.2.1"
+      newTag: "0.2.3"
git commit -am "Promote web 0.2.3 to prod" && git push
argocd app get web-prod
# representative: SYNC STATUS: OutOfSync   (merged, but prod is manual — not applied)

What just happened: the prod desired state now says 0.2.3, but the cluster still runs 0.2.1. Prod is OutOfSync and stays that way until a human syncs — the merge is done, the apply is a separate, deliberate act. In real life PR #2 required named reviewers (platform + SRE) to merge at all.

Step 6 — Sync prod, and meet the window

argocd app sync web-prod
# If run OUTSIDE the Tue/Thu 14:00–16:00 window, representative:
# FATA[0000] rpc error: code = FailedPrecondition desc = Cannot sync web-prod:
#            an active sync window blocks this sync

The sync window is doing its job: even a human, with an approved merge, cannot apply to prod outside the change window. To complete the lab regardless of the day, widen the window to “now” (edit the schedule/duration in project-prod.yaml to cover the current time, or temporarily add - {kind: allow, schedule: '* * * * *', duration: 1h, applications: ['web-prod'], manualSync: true}), re-apply the project, then:

argocd app sync web-prod        # inside the window now
argocd app get web-prod         # Synced/Healthy, image 0.2.3
kubectl -n web-prod get deploy web -o jsonpath='{..image}{"\n"}'
# hashicorp/http-echo:0.2.3

What just happened: the same artifact (0.2.3) that started in dev is now in prod, having passed a review gate (staging PR), an approval gate (prod PR), and a timing+manual gate (sync window + human sync). It was never rebuilt and never applied by hand — every hop was a reviewed Git change reconciled by Argo CD.

Step 7 — Prove there’s no drift

# Every env difference is explainable from the overlays — nothing hidden:
diff <(kubectl kustomize envs/staging) <(kubectl kustomize envs/prod)
# representative: only namespace + replica count differ (5 vs 3) — both are reviewed overlay lines

What just happened: the only differences between staging and prod are the ones you wrote in the overlays. There is no silent drift, because there is nowhere for it to hide.

Teardown

kind delete cluster --name promo      # removes everything; nothing was billable

You have built and run a promotion pipeline: directory-per-env, shared base, per-env sync policies, a prod sync window, and a real dev → staging → prod walk where the promotion diff was one line and the artifact never changed.


Common mistakes and troubleshooting

Promotion failures cluster into a predictable set. Keep this table close; each row is a real incident pattern.

Symptom Likely cause Fix
“Works in staging, breaks in prod” Config drift — prod diverged from staging in ways not in Git Shared base + explicit overlays; wire diff <(kustomize build staging) <(kustomize build prod) into CI as a gate
The same tag behaves differently across envs Rebuilt image — CI built a new image per env under the same tag Build once, promote the reference; pin the digest so the tag can’t re-point
Prod deployed an unreviewed change on its own Prod Application has an automated syncPolicy Remove the automated block → prod is manual-sync; require a human argocd app sync
Promotion PR is huge and reviewers rubber-stamp it Branch-per-env merge dragging unrelated diffs Switch to directory-per-env; the promotion becomes a one-line newTag diff
A prod PR merged with no approval No branch protection / CODEOWNERS on envs/prod/ Add path-scoped CODEOWNERS + “require reviewers” branch protection
Dev’s secret showed up resolving in prod Overlay reused the same secretStoreRef across envs Per-env store name (dev-store/prod-store); prod overlay must reference only the prod store
Tag promoted but behaviour still wrong The chart/base moved even though the tag didn’t Pin chart targetRevision to a version/SHA; treat the chart as an artifact to promote too
ApplicationSet rolled a change to all prod at once List/cluster generator applied simultaneously; no RollingSync Add a RollingSync strategy stepping region-by-region; watch eu before us
Prod auto-synced at a bad time No sync window; automated on AppProject.syncWindows allow-window + manual sync; deny outside it
argocd app sync web-prod rejected An active sync window blocks it (working as intended) Sync inside the allow window, or (break-glass) set manualSync: true on the window
Rolled back staging but prod still bad Rollback treated as env-local, not promoted backward Roll back with git revert of the prod promotion PR — desired state returns to the prior tag
Prod overlay grew into a full copy of base Base under-parameterised; drift creeping back in Pull shared parts up into base/; keep the overlay to the delta only

You will watch the Application status move through a small set of states as a promotion travels — read them fluently:

State you’ll see When, during a promotion What it means / what to do
Synced / Healthy After an env applies the promoted tag Desired == live, workloads up — done
OutOfSync Prod after a merged PR, before the manual sync Desired changed; waiting for a human argocd app sync
Missing Prod before its first sync Nothing applied yet (manual env)
Progressing Mid-rollout (canary shifting weight) Rollout in flight; wait or watch the analysis
Degraded A promoted version fails health checks Bad promotion — abort/roll back with git revert
Blocked by sync window argocd app sync attempted outside the window Working as intended; sync inside the window

Three gotchas deserve extra words, because they cause the most damage:

1. Promoting a rebuilt image (the silent artifact swap). The most dangerous mistake because it looks fine: CI has a “build and deploy to prod” job that rebuilds from source. Even from the same commit, a rebuild can pull newer base-image layers, different dependency patch versions, or a differently-resolved lockfile — a different binary wearing tag 2.3.1. Everything you validated in staging is now unverified in prod. The only defence is architectural: build the image exactly once, in one job, and let every environment reference that one artifact by digest. If your prod deploy job contains docker build, you have this bug.

2. Silent config drift. Prod and staging start identical and diverge one incident at a time — a kubectl edit here, a hotfix there, none written back to Git. Six months later “staging passed” predicts nothing. The structural fix is the shared base (so differences must be explicit) plus self-heal on (so an out-of-band kubectl edit is reverted and reported as OutOfSync, making drift loud). The diff-in-CI check turns drift from a postmortem finding into a failed pipeline.

3. Prod auto-syncing an untested change. Convenience is the enemy here. If prod has automated: {selfHeal: true} and tracks main, then any merge that touches the prod path rolls out with no human in the loop — including a mistaken one, at 2am, with no one watching. Prod earns two locks the other environments don’t: manual sync (no automated block) so a human triggers the apply, and a sync window so even that human can only do it in an agreed slot. Yes, it’s more friction. Prod is where you want friction.


Cheat-sheet

The promotion flow, the structuring decision, and the tooling — dense reference.

The promotion flow (what happens, in order):

Step Command / action Result
Build once CI: docker build … && docker push …:2.3.1 Immutable artifact in the registry
Bump dev PR sets newTag: 2.3.1 in envs/dev (auto-merge) Dev auto-syncs
Promote → staging PR copies tag into envs/staging (CODEOWNERS review) Staging syncs; canary runs
Promote → prod PR copies tag into envs/prod (required reviewers) Prod OutOfSync (manual)
Sync prod argocd app sync web-prod (inside window) Prod applies the same artifact
Roll back git revert <promotion-PR> Desired state returns to prior tag

Key commands:

Command What it does
argocd app sync <app> Manually reconcile desired → live (the prod apply step)
argocd app get <app> Sync status, health, current images, sync windows
argocd app diff <app> Show live-vs-Git diff without syncing
argocd app history <app> Prior synced revisions (for rollback context)
argocd app rollback <app> <id> Roll the live app to a prior sync (prefer git revert for desired state)
kubectl kustomize envs/prod Render an overlay locally to inspect the promoted result
diff <(kubectl kustomize envs/staging) <(kubectl kustomize envs/prod) Drift check — every line must be an intended delta
grep -r newTag envs/ What version is each environment on, right now

The env-structuring decision:

If… Use Because
You have real per-env differences (almost always) Directory-per-env + shared base One-line promotion diff; no silent drift
Tiny single-config app, strong branch policy Branch-per-env (reluctantly) Simplicity, if you accept merge/drift risk
Expressing the per-env delta Kustomize overlays or Helm values Keeps directory-per-env DRY
Multi-region prod ApplicationSet + RollingSync Same overlay everywhere; region-by-region gate

Gates, per environment:

Env Sync mode Git gate Timing gate Progressive
dev auto + self-heal auto-merge none none
staging auto (or manual) CODEOWNERS review none canary + analysis
prod manual required reviewers sync window canary/blue-green

Automation note:

Tool Use it for Not for
Image Updater (git write-back) Auto-advancing dev on every new build Multi-stage dev→staging→prod promotion
Kargo A declarative promotion engine (Warehouse→Freight→Stage, with verification) When hand-rolled PR automation still suffices
CI-opens-PR bot Any team; zero new controllers — (you maintain the glue)

Interview and exam questions

Q: In one sentence, what is a promotion under GitOps? A: A controlled, reviewed change to an environment’s desired state in Git — almost always a pull request that moves an immutable artifact’s reference (an image tag/digest) into the next environment’s file — after which the pull-based controller (Argo CD) reconciles the cluster to match. It is never a kubectl command or a UI Sync click.

Q: What actually gets promoted, and what must not move? A: You promote a reference to an immutable artifact — an image tag (ideally pinned to a digest) or a pinned chart version. You do not promote the artifact itself (never rebuild per env) and you do not promote environment-specific config (replicas, hostnames, resource limits, secret-store names) — those are permanent, reviewed differences that live in each environment’s overlay and stay put.

Q: Compare directory-per-env and branch-per-env promotion. Why has branch-per-env fallen out of favour? A: Directory-per-env keeps every environment on one branch with a shared base, so promotion is a one-line newTag diff and environments stay comparable. Branch-per-env uses a long-lived branch per environment and promotes by merge/cherry-pick, which (a) lets branches drift silently, (b) drags unrelated env-specific changes on merge (“merge hell”), and © produces noisy diffs that defeat review. Directory-per-env preserves the reviewed-diff superpower; branch-per-env erodes it.

Q: What is config drift between environments, and how do you prevent it structurally? A: Drift is silent divergence between environments (e.g., a value changed in prod during an incident and never written to Git) so that “passed in staging” stops predicting “works in prod”. You prevent it structurally with a shared base plus per-env overlays that contain only the delta — every difference becomes an explicit, reviewed line — reinforced by self-heal (reverts out-of-band kubectl edits) and a CI diff check between rendered environments.

Q: Why is rebuilding the image per environment an anti-pattern? A: Because a rebuild — even from the same commit — can produce a different binary (newer base layers, different resolved dependencies) under the same tag, so what you validated in staging is not what runs in prod. The immutability guarantee (“same artifact everywhere”) only holds if you build once and promote a digest-pinned reference. A prod deploy job containing docker build has this bug.

Q: How do you make prod refuse to auto-deploy an unreviewed change? A: Two locks. Remove the automated block from the prod Application so it is manual-sync (a human must run argocd app sync), and define sync windows on the prod AppProject so syncs are only permitted in an agreed window (with manualSync controlling break-glass). Add branch protection + CODEOWNERS on envs/prod/ so the merge also requires named approvers. The Git merge is gated and the apply is gated.

Q: What are sync windows and how do they interact with manual sync? A: AppProject.spec.syncWindows are cron-scheduled allow/deny windows restricting when syncs may occur. deny overrides allow; if only allow windows exist and none is active, syncs are blocked. Each window’s manualSync: true|false decides whether manual syncs are permitted when the window would otherwise block — so you can allow break-glass manual syncs while blocking automated ones, or lock both.

Q: How does Argo CD Image Updater fit a promotion pipeline, and what are its limits? A: Image Updater watches a registry and, on a new matching tag, writes that tag back to Git (write-back-method git), which Argo CD then syncs. It’s ideal for auto-advancing dev on every build with no manual PR. Its limit: it keys off the registry, not off “staging is healthy”, so it does not model dev→staging→prod on its own. Use it to feed the pipeline’s first hop, not to run the whole promotion.

Q: What is Kargo, and when would you reach for it over hand-rolled PR automation? A: Kargo (by Akuity) is a purpose-built promotion engine that models the pipeline as first-class objects — Warehouses (subscribe to artifacts, emit immutable Freight), Stages (environments that request and promote Freight, with verification via Rollouts AnalysisRuns), and Promotions. Reach for it when multi-stage promotion with verification outgrows scripted PRs; the trade-off is another controller and an evolving API to operate.

Q: How do you promote safely across multiple prod regions? A: Render every region’s prod Application from the same prod overlay via an ApplicationSet cluster generator (so regions can’t drift from each other), and add a RollingSync strategy that sequences them — prod-eu must be Healthy before prod-us begins — so a bad promotion is contained to one region while you watch, instead of hitting the whole fleet on one reconcile tick.

Q: A prod promotion caused an incident. Walk through the rollback. A: git revert the prod promotion PR — that restores the prior desired-state tag in envs/prod, and the reverted change goes through the same review gate. Then sync prod (inside the window, or break-glass) so the cluster returns to the previous artifact. Because desired state is in Git, rollback is a normal reviewed change, not a scramble; the exact prior state is recoverable byte-for-byte. Avoid kubectl/UI rollbacks, which create drift the controller will fight.

Q: How do per-environment secrets work without promoting secret values? A: You never put values in Git or promote them. Each environment’s overlay references its own secret store (e.g., ESO secretStoreRef: dev-store vs prod-store) — a single reviewed line — and the store is backed by the cloud’s secret manager (Azure Key Vault on AKS, AWS Secrets Manager on EKS, Google Secret Manager on GKE). A promoted image lands next to the prod store and resolves prod secrets at runtime; dev’s overlay never references the prod store, so credentials can’t leak across environments.


Key takeaways

argocdgitopskubernetespromotionenvironmentskustomizeapplicationsetkargoargo-rolloutsimage-updaterexternal-secretsakseksgke
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments