A common GitOps layout keeps one folder (or values file) per environment:
shop-config/
apps/shop-api/
base/
overlays/
dev/ image 1.4.4 replicas 1 automated sync
staging/ image 1.4.3 replicas 2 automated sync
prod/ image 1.4.2 replicas 3 manual sync or sync window
Each folder has its own Application (shop-api-dev, shop-api-staging, shop-api-prod). Avoid one branch per environment: long-lived branches drift apart and merges carry unrelated changes along. Folders on main keep the difference between environments visible in one diff.
Promotion
flowchart LR CI["CI builds 1.4.4"] -->|"commit: dev tag"| D["overlays/dev"] D -->|"tests pass → PR"| S["overlays/staging"] S -->|"approval → PR"| P["overlays/prod"]
- CI pushes the image and updates dev automatically (a commit or a bot PR).
- Promotion to staging and prod is a pull request that changes one line: the image tag. Reviews, required checks and CODEOWNERS on
overlays/prod/act as the release gate. - Tools such as Argo CD Image Updater or Kargo can automate the tag bumps; the end result is still a commit.
Rollback
| Way | When |
|---|---|
git revert of the promotion commit |
The normal way: Git and the cluster stay in agreement, and the history shows the rollback. |
argocd app rollback shop-api-prod 2 |
Emergency: redeploy a previous history ID quickly. Only works when automated sync is off, because an automated app would immediately sync back to Git. |
kubectl rollout undo |
Avoid: the next sync (or self-heal) puts the Git version back. |
argocd app history shop-api-prod
argocd app rollback shop-api-prod 2 # then fix Git, or the app stays OutOfSync
After an argocd app rollback the app shows OutOfSync — the cluster runs an older revision than Git says. Follow up with a revert commit so they agree again.
Pin what runs
- Prod should point at an immutable image tag (or digest), never
latest. - Consider
targetRevisionas a release tag for prod (prod-2026-11-07) if your team prefers releasing tags over merging to main. - Keep
argocd app historyshort but useful:spec.revisionHistoryLimit(default 10).
Rule: if you rolled back with a button, you are not done until Git says the same thing.