By the end you'll know
- How to run the same chart as two shops, with a values file and an Application each
- What a promotion is in GitOps: a small pull request copying values from one environment to the next
- Which changes reach one environment, and which reach all of them
- How to check a promotion asks for exactly what staging ran
- Why checking right after a sync can catch the old pods, not the new ones
Before you start
- You need: Lessons 1 to 6 done, with the bookshop running from the chart.
- Get the new files:
git pull. You'll havevalues-production.yaml, a newvalues-staging.yaml,argocd/bookshop-staging.yaml, and a banner in the chart. - Where commands run: your own computer's terminal, in
gitops-practice, logged in withargocd login.
The short version
- Two shops. Production and staging, from one chart. Each has its own values file and its own Application, four lines apart.
- Release 2 (nginx 1.29, version 2 and a delivery banner) went to staging alone, by changing
values-staging.yaml. Production didn't move. - The promotion was a three-line pull request to
values-production.yaml, checked first to ask for exactly what staging ran. - The surprise. A banner added to the chart, off by default, still changed production's page and rolled its pods. Templates reach every shop; only values reach one.
Why staging
So far there's one bookshop, and every merged change goes straight to it. That's fine for a headline. For a new version of nginx, or a new feature, a team wants to see it running somewhere first, somewhere customers aren't. That's staging: a copy of production, from the same chart, where a release lands first. Once it works there, it's promoted to production.
Think of a dress rehearsal. Same script, same set, same costumes, an empty theatre. If something goes wrong, nobody paid to see it. Opening night runs exactly what the rehearsal ran.
Lesson 5 already showed the trick: one chart, a values file for each place it runs. This lesson makes it real, with two Applications.
Production gets a values file
First, production's own settings, pinned explicitly:
# charts/bookshop/values-production.yaml
# Production: what the real shop runs. A release reaches here only after staging.
image:
tag: "1.28"
shop:
version: "1"
These are the same as the defaults, so nothing should change. They're written out because they're the two things a release moves: which nginx, and which version of the shop. The Application gets a second values file, after the first, so it wins where they differ:
helm:
releaseName: bookshop
valueFiles:
- values.yaml
- values-production.yaml
Merged, applied, and the pods compared before and after:
merged: 03e0660 Bookshop: a values file for production (#20)
application.argoproj.io/bookshop configured
bookshop: synced to 03e0660, Healthy, after 1s
pods before: pod/bookshop-78596b5798-dmk8s pod/bookshop-78596b5798-m85gr
pods after: pod/bookshop-78596b5798-dmk8s pod/bookshop-78596b5798-m85gr
The same two pods. The rendered YAML didn't change, so the checksum didn't either, and nothing rolled. A refactor, in Lesson 7 of the Ansible track's sense: moved, not changed.
A staging shop, from the same chart
Staging's values:
# charts/bookshop/values-staging.yaml
# Staging: one copy, a name that says it isn't the real shop, and the next release first.
replicas: 1
image:
tag: "1.28"
shop:
name: The Bookshop (staging)
version: "1"
And its Application, argocd/bookshop-staging.yaml, which is production's with four lines changed:
diff argocd/bookshop.yaml argocd/bookshop-staging.yaml
1c1
< # Production: the real bookshop, from the Helm chart with production's values (Lesson 7).
---
> # Staging: the same chart, with staging's values. Changes land here first (Lesson 7).
5c5
< name: bookshop
---
> name: bookshop-staging
17c17
< - values-production.yaml
---
> - values-staging.yaml
20c20
< namespace: bookshop
---
> namespace: bookshop-staging
A different name, a different values file, a different namespace. Same repository, same chart, same branch.
kubectl apply -f argocd/bookshop-staging.yaml
argocd app list
application.argoproj.io/bookshop-staging created
bookshop-staging: synced to ba5b111, Healthy, after 3s
NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY ... PATH TARGET
argocd/bookshop https://kubernetes.default.svc bookshop default Synced Healthy Auto-Prune ... charts/bookshop main
argocd/bookshop-staging https://kubernetes.default.svc bookshop-staging default Synced Healthy Auto-Prune ... charts/bookshop main
argocd/guestbook https://kubernetes.default.svc guestbook default Synced Healthy Auto-Prune ... guestbook HEAD
Two bookshops, one chart. What each is running:
bookshop: 2 pods, image nginx:1.28,
<title>The Bookshop</title>
Version 1.
(no banner)
bookshop-staging: 1 pods, image nginx:1.28,
<title>The Bookshop (staging)</title>
Version 1.
(no banner)
The same release, with staging's name and one copy. A real team would run staging in a separate cluster, but a separate namespace shows the idea.
A feature, added to the chart
Release 2 will have a banner across the top of the page. That needs the chart to support one, so first a template change: a banner value, empty by default, and the page shows it only if it's set:
{{- if .Values.shop.banner }}
<div class="banner">{{ .Values.shop.banner }}</div>
{{- end }}
<header>
# values.yaml
banner: "" # a line across the top of the page; empty means none
The plan: since the banner is off unless a value sets it, production should render exactly the same, and nothing there should change. Checked before merging, by rendering production with and without the change:
diff /tmp/prod-old.yaml /tmp/prod-new.yaml
75a76,77
> .banner { background: var(--accent); color: #fff; text-align: center; padding: 10px 20px;
> font: 600 15px/1.4 -apple-system, "Segoe UI", sans-serif; }
207c209
< checksum/page: 9b9d592ac00e6f459713741e027d59842f7540d657c057bc139becb0a10cf6d2
---
> checksum/page: e6749f3c2b8cc05708b95b4981c62766f8e5f80f059fd49989c0378e994fd881
Not the same. The banner's text is inside the if, but its styling, the .banner CSS rule, was added to the page's stylesheet for everyone. Production's page gained two lines it will never use, so its checksum changed, so its pods would roll. Merged anyway, to see:
merged: 59da624 Bookshop chart: an optional banner, off unless a value sets it (#22)
bookshop: synced to 59da624, Healthy, after 5s
bookshop-staging: synced to 59da624, Healthy, after 3s
production pods before: pod/bookshop-78596b5798-dmk8s pod/bookshop-78596b5798-m85gr
production pods after: pod/bookshop-55d676b54f-2qjxn pod/bookshop-55d676b54f-m9wmk pod/bookshop-78596b5798-dmk8s pod/bookshop-78596b5798-m85gr
Both shops synced, and production rolled: new pods from a new ReplicaSet, 55d676b54f, with the old ones still on their way out. Harmless here. But it's the most important rule in this lesson: a change to the chart reaches every environment at once. Staging doesn't protect production from a template change; only from a values change. "Off by default" has to mean the output is identical, not just that nothing visible appears. Here, the fix would be to put the CSS inside the same if. The diff before merging is what caught it.
Release 2, to staging
Now the release itself. Everything about it is values, in staging's file only:
git diff
--- a/charts/bookshop/values-staging.yaml
+++ b/charts/bookshop/values-staging.yaml
@@ -1,7 +1,8 @@
# Staging: one copy, a name that says it isn't the real shop, and the next release first.
replicas: 1
image:
- tag: "1.28"
+ tag: "1.29"
shop:
name: The Bookshop (staging)
- version: "1"
+ version: "2"
+ banner: Free delivery on every order this week.
nginx 1.29, checked first with docker buildx imagetools inspect nginx:1.29 to be sure the tag exists; version 2; and the banner. Merged:
merged: 91c4f26 Release 2, to staging: nginx 1.29 and a delivery banner (#23)
bookshop-staging: synced to 91c4f26, Healthy, after 7s
bookshop: synced to 91c4f26, Healthy, after 0s
bookshop-staging: 2 pods, image nginx:1.29,
<title>The Bookshop (staging)</title>
Version 2.
<div class="banner">Free delivery on every order this week.</div>
bookshop: 2 pods, image nginx:1.28,
<title>The Bookshop</title>
Version 1.
(no banner)
Staging is on release 2: nginx 1.29, version 2, the banner. Production is still on release 1. Both Applications synced to the same commit, but only staging's rendered YAML changed, so only staging rolled. (Staging shows 2 pods for a moment: the new one starting, and the old one leaving.) This is the window where a team tries release 2 out.
The promotion
Release 2 works in staging. Promoting it is copying its values into production's file. Before the pull request, a check that production will ask for exactly what staging is running, ignoring the settings that are meant to differ, like the name and the copies:
diff <(git show HEAD:charts/bookshop/values-staging.yaml | grep -vE "^#|replicas|name:") \
<(grep -vE "^#" charts/bookshop/values-production.yaml)
production now asks for exactly what staging runs
And the promotion itself:
--- a/charts/bookshop/values-production.yaml
+++ b/charts/bookshop/values-production.yaml
@@ -1,5 +1,6 @@
# Production: what the real shop runs. A release reaches here only after staging.
image:
- tag: "1.28"
+ tag: "1.29"
shop:
- version: "1"
+ version: "2"
+ banner: Free delivery on every order this week.
That's the whole production deploy: three lines, reviewed like code. Anyone reading the pull request can see what's going to production, that it's what staging ran, and who approved it.
merged: 823683e Release 2, to production: promoted from staging (#24)
bookshop: synced to 823683e, Healthy, after 5s
bookshop: 4 pods, image nginx:1.29,
<title>The Bookshop</title>
Version 1.
(no banner)
4 pods, the new image, and the old page? That check ran while the rollout was still swapping pods, and read the page from one of the old ones. The Deployment already asked for 1.29; two old pods hadn't left yet. It's the same moment as Lesson 6's probe failures, from the other side: right after a sync, some of what you see is still the old version. Checked again once it had settled:
kubectl get pods -n bookshop -l app=bookshop
kubectl exec -n bookshop deploy/bookshop -- sh -c 'grep -o "Version [^.]*\." /usr/share/nginx/html/index.html; grep -o "<div class=\"banner\">[^<]*</div>" /usr/share/nginx/html/index.html'
NAME READY STATUS RESTARTS AGE
bookshop-5fbd67c8c7-6dlf6 1/1 Running 0 9m31s
bookshop-5fbd67c8c7-wxcmf 1/1 Running 0 9m30s
Version 2.
<div class="banner">Free delivery on every order this week.</div>
Two pods, release 2, the banner. Production is running what staging ran. To see both shops side by side, port-forward each, in its own terminal:
kubectl port-forward svc/bookshop -n bookshop 8081:80
kubectl port-forward svc/bookshop -n bookshop-staging 8083:80
Then http://localhost:8081 is production, and http://localhost:8083 is staging.
Every step, a commit
git log --oneline -6
argocd app history bookshop-staging
823683e Release 2, to production: promoted from staging (#24)
91c4f26 Release 2, to staging: nginx 1.29 and a delivery banner (#23)
59da624 Bookshop chart: an optional banner, off unless a value sets it (#22)
ba5b111 Bookshop: a staging copy, from the same chart (#21)
03e0660 Bookshop: a values file for production (#20)
37ebef8 Bookshop: headline B, with the fix (#19)
ID DATE REVISION
0 2026-11-04 12:56:20 -0500 EST main (ba5b111)
1 2026-11-04 12:56:38 -0500 EST main (59da624)
2 2026-11-04 12:56:52 -0500 EST main (91c4f26)
Reading the log from the bottom tells the whole story: production got its own file, staging appeared, the chart gained a banner, release 2 went to staging, then to production. "When did production get nginx 1.29, and who approved it?" is one git show 823683e.
In real systems
- Separate clusters, not just namespaces. Staging usually runs in its own cluster, often its own cloud account, so that a broken staging can't touch production at all. Argo CD can manage both, as the next lessons show.
- Image tags come from CI. Here the release was a person editing a tag. Usually the pipeline builds an image, tags it with the commit, and opens the pull request to staging's values itself. Tools like Argo CD Image Updater can do it too. Promotion to production stays a deliberate, reviewed step.
- Chart versions protect against template surprises. Teams that share a chart across many environments publish it with a version, and each environment pins one. Then a template change, like the banner's CSS, reaches staging first too, and production only when someone bumps its chart version.
- Render the diff for every environment. In CI, render the chart for each environment before and after a change, and post the differences on the pull request. It would have flagged production's changed checksum before anyone merged.
Spot the problem
Work out what's wrong before you read the explanation.
The promotion that skipped a step. A team promotes release 3 by copying values-staging.yaml over values-production.yaml whole, to be quick. Production restarts with one copy instead of four, and its page title says "The Bookshop (staging)". Customers notice before the team does. What went wrong? A promotion copies the release, not the environment. Some values belong to the release, like the image tag, the version and the features; others belong to the environment, like the number of copies and the shop's name, and must never move. Copying the whole file moved both. The habit from this lesson: promote only the release's values, and check with a diff like the one above, which deliberately ignores the environment's own settings, before opening the pull request. Some teams split the files to make it impossible: one for what the environment is, one for what's released into it.
Keep this
| To | Write or run |
|---|---|
| Give an environment its own settings | values-production.yaml, values-staging.yaml in the chart |
| Use them, later files winning | valueFiles: [values.yaml, values-production.yaml] |
| A second environment | A second Application: new name, values file and namespace |
| See every app at once | argocd app list |
| Check an image tag exists | docker buildx imagetools inspect nginx:1.29 |
| See what a chart change does to production | Render production before and after, and diff |
| Show something only when a value is set | {{- if .Values.shop.banner }} ... {{- end }} |
| Promote a release | Copy the release's values into production's file, by pull request |
| Check production asks for what staging ran | diff the two files, ignoring the environment's own settings |
| Check after a rollout has settled | kubectl rollout status, then kubectl exec deploy/bookshop -- ... |
Where you are now
There are two bookshops, from one chart: staging, where a release lands first, and production, where it's promoted with a reviewed, three-line pull request. You've seen that values reach one environment and templates reach all of them, and that what you see right after a sync can still be the old version. You now have three Applications, all applied to the cluster by hand. Lesson 8 puts them in Git too, under one parent Application: app-of-apps.
Check yourself
Release 2 changed only values-staging.yaml. Why didn't production change?
What was the production deploy for release 2?
The banner was off by default. Why did production's pods roll anyway?
Which changes reach every environment at once?
Right after the promotion, the check showed Version 1 on 4 pods. Why?
Why not promote by copying values-staging.yaml over values-production.yaml?
Key terms
- Environment: a place a release runs, like staging or production.
- Staging: a copy of production where a release lands first, before customers see it.
- Promotion: moving a release from one environment to the next, here by copying its values in a pull request.
- Release values: settings that belong to what's being released, like the image tag and version.
- Environment values: settings that belong to the environment, like copies and the shop's name, which never move.
- Chart version pinning: each environment using a fixed version of a shared chart, so template changes are promoted too.
Go deeper
- The Argo CD documentation: Helm value files (opens in a new tab), and Argo CD Image Updater (opens in a new tab).
- Try: move the
.bannerCSS inside the sameifinfiles/index.html, then render production before and after. Is the output identical now? Merge it, and check whether production's pods roll. - Try: release 3, with a new headline in staging only. Promote it with the diff check first. What does
argocd app historyshow for each shop?