Argo CD and Helm · Lesson 7 of 11 · about 25 minutes

Promoting a release by changing one value

One chart, two shops: production and a staging copy, each with its own values file and its own Application. Release 2 goes to staging first, then is promoted to production with a three-line pull request. And a banner that was meant to be off by default turned out to change production anyway: a chart change reaches every shop at once.

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 have values-production.yaml, a new values-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 with argocd 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:

YAML
# 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:

YAML
    helm:
      releaseName: bookshop
      valueFiles:
        - values.yaml
        - values-production.yaml

Merged, applied, and the pods compared before and after:

OUTPUT
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:

YAML
# 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:

BASH
diff argocd/bookshop.yaml argocd/bookshop-staging.yaml
OUTPUT
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.

BASH
kubectl apply -f argocd/bookshop-staging.yaml
argocd app list
OUTPUT
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:

OUTPUT
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:

HTML
{{- if .Values.shop.banner }}
<div class="banner">{{ .Values.shop.banner }}</div>
{{- end }}
<header>
YAML
# 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:

BASH
diff /tmp/prod-old.yaml /tmp/prod-new.yaml
OUTPUT
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:

OUTPUT
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:

BASH
git diff
OUTPUT
--- 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:

OUTPUT
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:

BASH
diff <(git show HEAD:charts/bookshop/values-staging.yaml | grep -vE "^#|replicas|name:") \
     <(grep -vE "^#" charts/bookshop/values-production.yaml)
OUTPUT
production now asks for exactly what staging runs

And the promotion itself:

OUTPUT
--- 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.

OUTPUT
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:

BASH
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'
OUTPUT
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:

BASH
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

BASH
git log --oneline -6
argocd app history bookshop-staging
OUTPUT
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

ToWrite or run
Give an environment its own settingsvalues-production.yaml, values-staging.yaml in the chart
Use them, later files winningvalueFiles: [values.yaml, values-production.yaml]
A second environmentA second Application: new name, values file and namespace
See every app at onceargocd app list
Check an image tag existsdocker buildx imagetools inspect nginx:1.29
See what a chart change does to productionRender production before and after, and diff
Show something only when a value is set{{- if .Values.shop.banner }} ... {{- end }}
Promote a releaseCopy the release's values into production's file, by pull request
Check production asks for what staging randiff the two files, ignoring the environment's own settings
Check after a rollout has settledkubectl 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

  1. Release 2 changed only values-staging.yaml. Why didn't production change?

  2. What was the production deploy for release 2?

  3. The banner was off by default. Why did production's pods roll anyway?

  4. Which changes reach every environment at once?

  5. Right after the promotion, the check showed Version 1 on 4 pods. Why?

  6. 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 .banner CSS inside the same if in files/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 history show for each shop?