Skip to content

Volume ITechnology

14.4.3 — One App, Three Environments ​

A team runs an online shop on AKS. Developers push code many times a day, and each push should be running somewhere within minutes so they can try it. A testing team checks a candidate release before customers see it. Customers use the real thing, which must never be broken by either of those.

So the same application has to exist three times, as dev, QA and production, and the three must differ in exactly the right ways and no others. If they differ too much, something that worked in QA breaks in production because of a difference nobody noticed. If they differ too little, a developer's experiment uses production's database.

This page builds that setup end to end, using pieces from the whole of Part 14: the checkout-api service from Chapter 14.3.2, a web front end, images in Azure Container Registry, Kustomize overlays (Chapter 14.3.8), the Gateway API (Chapter 14.3.6), and a deploy script that waits for readiness and rolls back on failure. It also corrects five mistakes that are extremely common in real projects, each of which was in the first version of this very setup.

1. The application, and the path of one request ​

Three parts, each a container image:

  • web: the shop's browser application, built into static files and served by nginx, a fast, widely used web server. nginx also passes any request starting with /api/ on to the back end.
  • checkout-api: the TypeScript service from Chapter 14.3.2, which handles the shop's checkout requests.
  • A PostgreSQL database, one per environment, run as Azure's managed database service rather than inside the cluster. Chapter 14.4.2 explains why a database usually lives outside the cluster.

The figure follows one request from a customer's browser to the database and back. Read it left to right.

browsershop.example.comshared gatewayTLS ends heregateway-systemnamespace shop-prodweb × 2nginx: files,proxies /api/checkout-api × 3+TypeScriptHPA 3–10Services: web, checkout-api (ClusterIP)PostgreSQLAzure managedone per env①②③④shop-dev and shop-qa are the same picture, smaller, behind the gateway's internal listener.
Figure 1 — One request through production. Only the gateway is reachable from the internet. Everything inside the namespace talks over ClusterIP Services, which have no outside address at all.

① The browser connects to shop.example.com over HTTPS. That name points at the shared gateway, a Gateway API gateway (Chapter 14.3.6) run by the platform team, which holds the certificate and decrypts the connection. ② An HTTPRoute owned by the shop team tells the gateway "requests for shop.example.com go to the web Service in shop-prod". ③ nginx in a web pod either returns a static file, or, for a path starting with /api/, passes the request to the checkout-api Service. ④ checkout-api queries the production database.

Only one thing in this picture has a public address, and that is deliberate. The first version of this setup gave the back end its own Service of type LoadBalancer, which put it directly on the internet with its own public IP, bypassing the gateway's certificate and every rule attached to it. Mistake one: anything that is not the entry point should be a plain ClusterIP Service, reachable only from inside the cluster.

2. What differs between the environments, and what must not ​

devQAprod
Namespaceshop-devshop-qashop-prod
Imagebuilt from this commitsame digest as devsame digest as QA
checkout-api copies113–10, autoscaled
web copies112
Reached atinternal nameinternal nameshop.example.com, HTTPS
Databasedev serverQA serverprod server
Budgetquotaquotanone, own node pool
Deploysevery mergewhen promotedafter approval

Everything in the table is a deliberate difference. Everything not in the table must be identical: the manifests, the probes, the environment variable names, the nginx configuration, the way secrets arrive. This is the whole reason for the structure in section 4: one set of files that describes the application once, and three small files that hold only the table above.

Read the image row carefully, because it is the most important line on the page. The first version of this setup used tags called dev-latest and qa-latest, rebuilt for each environment. Mistake two: rebuilding per environment means the thing you checked in QA is not the thing you ship. Two builds of the same commit can differ: a base image updated in between, a dependency released in between. So the rule is build once, then promote the same bytes. Each build is identified by its digest, the sha256: fingerprint of the image's contents (Chapter 14.2.2), and that exact digest moves from dev to QA to prod. A tag can be moved to point at different bytes. A digest cannot.

3. Building the images, without leaking a password ​

checkout-api installs packages from the company's private npm registry, so the build needs a password-like token. Mistake three, and the most dangerous one: passing that token as a build argument. Here is what that looked like, and why it leaks:

dockerfile
# checkout-api/Dockerfile — the leaking version
FROM node:24-slim
ARG NPM_TOKEN
WORKDIR /app
COPY package.json package-lock.json ./
RUN echo "//npm.example.com/:_authToken=${NPM_TOKEN}" > .npmrc \
 && npm ci --omit=dev \
 && rm .npmrc

The rm .npmrc at the end looks careful, and it does keep the file out of the image. But the build argument's value is recorded in the image's history. Anyone who can pull the image can run:

bash
docker history --no-trunc shopregistry.azurecr.io/shop/checkout-api:3e1f9a2b7c4d
... RUN |1 NPM_TOKEN=npm_9fK2xQ7vLm4pR8sT1wZ3 /bin/sh -c echo "//npm.example.com/:_authToken=...

The token is right there, in plain text, in every copy of the image, in every registry it was ever pushed to. Docker's own documentation warns that build arguments are not for secrets.

The fix is a BuildKit secret mount (Chapter 14.2.3). The secret is made available as a file for the duration of one RUN instruction, and is never written into any layer or into the history:

dockerfile
# syntax=docker/dockerfile:1
# checkout-api/Dockerfile
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev                                     # (1)

FROM node:24-slim                                         # (2)
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY server.ts ./
USER node
EXPOSE 8080
CMD ["node", "server.ts"]

(1) The secret appears at /root/.npmrc only while this one command runs. npm reads its registry credentials from .npmrc in the home directory, and the build runs as root, so that is where npm looks. After the command, the file is gone, and it was never part of a layer.

(2) A second stage that copies only the installed packages and the code. This is a multi-stage build (Chapter 14.2.3): nothing from the first stage comes along except what is explicitly copied, which keeps the final image small and free of build tools.

The web image is built the same way, in two stages: a Node stage runs the browser application's build, and an nginx:1.30 stage copies in the resulting files plus one configuration file, shown in section 5.

The build script, run by the pipeline once per commit:

bash
#!/usr/bin/env bash
set -euo pipefail                                                    # (1)

REGISTRY="shopregistry.azurecr.io"
TAG="$(git rev-parse --short=12 HEAD)"                               # (2)

az acr login --name shopregistry                                     # (3)

for app in checkout-api web; do
  docker buildx build \
    --platform linux/amd64 \
    --secret id=npmrc,src="$HOME/.npmrc" \
    --tag "$REGISTRY/shop/$app:$TAG" \
    --metadata-file "$app.build.json" \
    --push "./$app"                                                  # (4)
  digest="$(jq -r '."containerimage.digest"' "$app.build.json")"     # (5)
  echo "$app $digest" | tee -a digests.txt
done

(1) Stop at the first error. -e exits when any command fails, -u treats an unset variable as an error instead of silently using an empty string, and -o pipefail makes a pipeline fail if any part of it fails. The first version of this script had lost every $ from its variables during an editing accident, so REGISTRY= and -t ../backend silently built and pushed nothing useful. set -u would have stopped it on the first line that used an empty variable.

(2) The tag is the commit, the first 12 characters of the Git commit ID. Every image can be traced back to the exact source code it came from, and no tag is ever reused.

(3) Sign in to Azure Container Registry using your Azure identity, with no registry password in the script. az acr login asks Azure for a short-lived token and hands it to Docker. In a pipeline, the pipeline's own Azure identity does the same (Chapter 13.7). The first version of this script piped a long-lived registry password into docker login, which then sat in the pipeline's settings for years.

(4) Build for the CPU the cluster runs, pass the secret, tag, and push in one step. --platform linux/amd64 matters when a developer runs this on an Apple Silicon laptop (Chapter 14.2.6). --metadata-file writes a small JSON file describing the build, including the digest the registry assigned.

(5) Read the digest out of that file. It looks like sha256:3f9a0c1d…, and it is what the deploy step uses. The tag is for humans; the digest is what the cluster runs.

4. One description, three small differences: Kustomize ​

The manifests live in a folder laid out like this:

deploy/
├── base/
│   ├── kustomization.yaml
│   ├── checkout-api.yaml      Deployment + Service
│   ├── web.yaml               Deployment + Service
│   └── route.yaml             HTTPRoute
└── overlays/
    ├── dev/   kustomization.yaml, quota.yaml
    ├── qa/    kustomization.yaml, quota.yaml
    └── prod/  kustomization.yaml, availability.yaml

base describes the application once, with no environment in it at all. Each folder under overlays takes the base and changes only the rows of the table in section 2. Kustomize, which is built into kubectl (Chapter 14.3.8), combines them: kubectl apply -k overlays/qa renders the base plus the QA changes and applies the result.

The base Deployment for checkout-api is the one from Chapter 14.3.2 with three changes:

yaml
# base/checkout-api.yaml (the container part)
        - name: api
          image: shopregistry.azurecr.io/shop/checkout-api      # (1)
          envFrom:
            - configMapRef:
                name: checkout-config                          # (2)
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: checkout-db                            # (3)
                  key: url

(1) No tag at all. The base does not know which version runs. Each overlay supplies a digest.

(2) Non-secret settings come from a ConfigMap, an object holding plain key-value configuration (Chapter 14.3.7). Each overlay generates its own.

(3) The database connection string comes from a Secret called checkout-db, which exists separately in each namespace with that environment's own value. Section 6 covers where it comes from.

The QA overlay, which is the whole of what makes QA different:

yaml
# overlays/qa/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shop-qa                                        # (1)
resources:
  - ../../base
  - quota.yaml
images:                                                   # (2)
  - name: shopregistry.azurecr.io/shop/checkout-api
    digest: sha256:3f9a0c1d2b7e45a8c6f1e0d9b8a7c6e5f4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9
  - name: shopregistry.azurecr.io/shop/web
    digest: sha256:8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d
configMapGenerator:                                       # (3)
  - name: checkout-config
    literals:
      - APP_ENV=qa
      - LOG_LEVEL=info
patches:                                                  # (4)
  - target:
      kind: HTTPRoute
      name: shop
    patch: |-
      - op: replace
        path: /spec/hostnames/0
        value: qa.shop.internal.example.com

(1) Every object goes into shop-qa. The base files never mention a namespace, so this one line places the whole application.

(2) Which exact images to run, by digest. Kustomize rewrites the base's image: lines to name@digest. Promotion from dev to QA is changing nothing but these two lines to the digests dev is already running.

(3) Generate the ConfigMap, and here is a clever detail. Kustomize names it checkout-config-96hgt9dm45, adding a short fingerprint of its contents, and rewrites the Deployment's reference to match. Change a value and the name changes, and a changed name in the pod template triggers a rolling update. Without this, editing a ConfigMap leaves running pods with the old values until something else happens to restart them, which is one of the most common "I changed the config and nothing happened" surprises in Kubernetes.

(4) A small edit to one field of the base's HTTPRoute: QA is reached at its own internal name.

The production overlay differs in a few more places:

yaml
# overlays/prod/kustomization.yaml (the parts that differ from QA)
namespace: shop-prod
resources:
  - ../../base
  - availability.yaml                                     # (1)
replicas:
  - name: web
    count: 2
patches:
  - target:
      kind: HTTPRoute
      name: shop
    patch: |-
      - op: replace
        path: /spec/hostnames/0
        value: shop.example.com
      - op: replace
        path: /spec/parentRefs/0/sectionName
        value: public-https                               # (2)
  - target:
      kind: Deployment
      name: checkout-api
    patch: |-
      - op: remove
        path: /spec/replicas                              # (3)
      - op: replace
        path: /spec/template/spec/containers/0/resources
        value:
          requests: { cpu: 500m, memory: 512Mi }
          limits: { memory: 512Mi }                       # (4)

(1) Two extra objects only production has, in availability.yaml. A PodDisruptionBudget with minAvailable: 2 stops a node upgrade from taking more than one checkout-api pod away at a time, and a HorizontalPodAutoscaler keeps between 3 and 10 copies, aiming at 70 percent processor use (Chapter 14.3.7).

(2) Attach to the gateway's public, HTTPS listener instead of the internal one. The gateway, its certificate and its listeners belong to the platform team; the shop team only chooses which listener its route attaches to.

(3) Remove the fixed replica count, because the autoscaler owns it now. This is the subtle one. If the manifest said replicas: 3, then every kubectl apply would set the count back to 3, even at peak load when the autoscaler had raised it to 9. Six pods would start shutting down in the middle of the busiest hour, and the autoscaler would then scale back up. Whenever an autoscaler manages a Deployment, the manifest must not set its replicas.

(4) Production's real sizing, with the memory request equal to its limit, so the pod is never the first to be evicted when a node runs short (Chapter 14.3.9).

You can see exactly what will be applied, before applying it, with kubectl kustomize overlays/prod, which prints the eight finished objects. That output, not the overlay files, is what reviewers should look at for a production change.

5. The nginx detail that breaks at startup ​

nginx in the web pods passes /api/ requests to the checkout-api Service. The obvious configuration is proxy_pass http://checkout-api;, and it has a well-known trap. nginx looks up the address of a name written directly in proxy_pass once, when it starts, and never again. If checkout-api is not resolvable at that moment, for instance because the Service has not been created yet in a fresh namespace, nginx refuses to start and the pod crash-loops. And if the Service's address ever changes, nginx keeps sending traffic to the old one.

The fix is to make nginx look the name up when each request arrives. It does that when the address is in a variable, using a name server you give it:

nginx
# web/default.conf.template
server {
    listen 80;
    resolver kube-dns.kube-system.svc.cluster.local valid=30s ipv6=off;   # (1)

    location /api/ {
        set $api http://checkout-api.${POD_NAMESPACE}.svc.cluster.local;  # (2)
        proxy_pass $api;                                                  # (3)
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;      # (4)
    }

    location / {
        root /usr/share/nginx/html;
        try_files $uri /index.html;                                       # (5)
    }
}

(1) Use the cluster's own name server, CoreDNS, which is reached through the Service called kube-dns in kube-system. valid=30s caches each answer for 30 seconds.

(2) The full name, including the namespace. This is the second trap, and it is why the first version of this file worked in testing and failed in a new namespace. nginx's resolver does not use the search list in the pod's /etc/resolv.conf (Chapter 14.3.9), so the short name checkout-api is not expanded and does not resolve. The name must be written out in full. ${POD_NAMESPACE} is filled in when the container starts: the official nginx image processes any file in /etc/nginx/templates/ ending in .template, replacing ${…} with environment variables, and web.yaml sets POD_NAMESPACE to the pod's own namespace using the downward API, Kubernetes' way of telling a container facts about its own pod. So the same image and the same file work in all three namespaces. nginx's own variables, like $api and $host, have no matching environment variable and are left alone.

(3) Because the address is in a variable, nginx resolves it per request, through the resolver on line 1. Without a URI part after the variable, the original request path, /api/orders/42, is passed through unchanged.

(4) Tell the back end who the real client was. Without this, every request appears to come from nginx's own address.

(5) Serve the browser application. Any path that is not a real file returns index.html, so the application's own page routing works when a customer reloads a deep link like /orders/42.

6. Secrets: what a Kubernetes Secret really is ​

Mistake four: the first version of this page described a Kubernetes Secret as encrypted. It is not, at least not in the way that word suggests. A Secret's values are base64-encoded, which is a way of writing any bytes as plain letters and digits, and anyone can reverse it in one command:

bash
kubectl get secret checkout-db -n shop-qa -o jsonpath='{.data.url}' | base64 -d
postgres://checkout:Qa-2026-pw@shop-qa-db.postgres.database.azure.com:5432/checkout?sslmode=require

What actually protects a Secret is access control. Only people and service accounts whose RBAC role allows reading Secrets in that namespace can run that command (Chapter 14.3.8). AKS also encrypts the cluster's stored data on disk, and can add a second layer using a key you hold in Azure Key Vault. But anyone allowed to read the Secret through the API sees the plain value.

So the real design question is where the value comes from, and who can read it. The approach Chapter 14.4.2 builds in full: each environment has its own Azure Key Vault, holding that environment's database password, and the cluster's Key Vault integration presents the value to the pods using a workload identity, so no password is ever typed into a pipeline or committed to Git. Three namespaces, three vaults, three identities, and a dev workload cannot read production's vault even if someone points it there, because its identity has no permission on it. Chapter 14.4.2 goes one step further for the database itself: the pod signs in to PostgreSQL with its workload identity directly, so there is no database password anywhere, and checkout-db holds only the server's address.

7. The deploy script ​

bash
#!/usr/bin/env bash
set -euo pipefail

ENV="${1:?usage: deploy.sh <dev|qa|prod> <api-digest> <web-digest>}"  # (1)
API_DIGEST="${2:?api digest required}"
WEB_DIGEST="${3:?web digest required}"
REGISTRY="shopregistry.azurecr.io"
NS="shop-$ENV"

kubectl config current-context                                        # (2)

cd "overlays/$ENV"
kustomize edit set image \
  "$REGISTRY/shop/checkout-api@$API_DIGEST" \
  "$REGISTRY/shop/web@$WEB_DIGEST"                                    # (3)

kubectl apply -k .                                                    # (4)

for d in checkout-api web; do
  if ! kubectl -n "$NS" rollout status "deployment/$d" --timeout=420s; then   # (5)
    echo "rollout of $d failed in $NS, putting the previous version back" >&2
    kubectl -n "$NS" rollout undo "deployment/$d"                     # (6)
    exit 1
  fi
done
echo "$NS now runs checkout-api@$API_DIGEST and web@$WEB_DIGEST"

(1) Three required arguments. ${1:?message} stops the script with that message if the argument is missing, so deploy.sh prod with no digests fails immediately instead of deploying something half-specified.

(2) Print which cluster this is about to change, so it is in the pipeline's log (Chapter 14.3.2 explains why that matters).

(3) Write the digests into the overlay. kustomize edit changes the images: section of that environment's kustomization.yaml. It also reformats the file and drops its comments, which is one reason the overlay files themselves should stay short. The pipeline commits this change back to Git, so the repository always says exactly what each environment runs.

(4) Apply. At this moment the cluster has the new desired state, and nothing has rolled out yet (Chapter 14.3.3).

(5) Wait until the new pods are actually ready, for up to seven minutes. Mistake five: the first version of this script polled until every pod said Running. Running only means the container started. A pod that started and then failed its readiness probe, or crash-looped a few seconds later, still said Running when the script checked, and the script reported success. kubectl rollout status waits for the Deployment's own judgement, which is that every new pod has passed readiness and every old one is gone.

(6) If it did not finish, put the previous version back and fail the pipeline. rollout undo returns the live Deployment to its previous template, which is instant because the old ReplicaSet still exists (Chapter 14.3.2). The Git overlay still names the new digest at this point, so the pipeline's next step on failure is to revert that commit, or the next apply would roll the broken version out again.

8. The pipeline, step by step ​

The full flow, which Chapter 13.7 builds as a real pipeline definition:

  1. A change merges to the main branch. The pipeline runs build.sh: both images are built once and pushed, and their digests are recorded.
  2. Deploy to dev automatically: deploy.sh dev <api> <web>. Developers see the change within minutes.
  3. Promote to QA when the team chooses a candidate: deploy.sh qa with the same two digests that dev is running. Nothing is rebuilt.
  4. QA checks it. This happens outside Kubernetes and is not this page's subject.
  5. An approval gate before production. The first version of this setup asked whoever ran the script to type yes. That stops a slip of the finger, but it records nothing and anyone with the script can type it. Pipelines have a proper version of this: GitHub calls it an environment protection rule, and Azure DevOps calls it an approval check. The production stage pauses until a named person approves, and the approval is recorded with who and when.
  6. Deploy to production: deploy.sh prod with the same two digests again.
  7. The rollout waits for readiness, and rolls back and fails if it does not finish.
  8. The overlay commit is the record. Which digest is running in which environment is a line in Git, with a commit author and a date, and returning to any earlier state is a revert.

9. Namespaces or separate clusters? ​

Everything above put three environments in one cluster, separated by namespaces. That is cheap and simple, and it has real limits, so it is worth deciding on purpose.

What a namespace does separate: names, RBAC permissions, quotas, network policies if you write them (Chapter 14.3.6), and secrets. The quota.yaml in dev and QA caps each at 2 processors and 4 GB of requests, so a runaway dev deployment cannot claim the whole cluster.

What a namespace does not separate:

  • The nodes. Without extra rules, a dev pod and a production pod can run on the same machine, and a dev pod that eats a node's disk can get production pods evicted (Chapter 14.3.9). The usual fix is a separate node pool for production, with a taint that only production pods tolerate (Chapter 14.4.1).
  • The control plane. One API server and one etcd serve all three. A dev script that floods the API server with requests slows down production's deploys too.
  • Upgrades. A cluster upgrade upgrades all three environments at once. You cannot try the new Kubernetes version on dev first if dev is in the same cluster.
  • Mistakes in cluster-wide settings. A wrong cluster-wide RBAC rule, a broken admission policy, or a deleted custom resource definition affects every namespace.

So the common pattern in practice is two clusters: dev and QA share one, and production has its own. Upgrades are tried on the first cluster weeks before the second. The manifests and overlays do not change at all, since an overlay does not care which cluster it is applied to. Only the context the pipeline uses changes.

What the interviewer will push on ​

"How do you manage configuration across dev, staging and production?" One base set of manifests and small per-environment overlays, with Kustomize or Helm values files, so only intended differences exist. The tell of real experience is "build once, promote the digest": the image checked in staging is byte-for-byte the image in production, and per-environment rebuilds are the anti-pattern. The weak answer describes three copies of the YAML.

"How do you get a private package token into a Docker build safely?" A BuildKit secret mount, so the token exists only during one RUN instruction and is never in a layer or the history. Explain why build arguments leak: their values are recorded in docker history. Mention that deleting the file in the same layer does not help with that.

"Is a Kubernetes Secret secure?" It is base64-encoded, not encrypted at the API level; protection comes from RBAC, plus encryption of stored data on disk. In practice, keep the real value in a vault per environment and give each workload an identity that can read only its own. This answer separates people who have read a Secret back out of a cluster from people who have not.

"Why must a Deployment managed by an HPA not set replicas in its manifest?" Because every apply resets the count, fighting the autoscaler and shrinking the service during peak load.

"Would you put production in the same cluster as dev?" Name what namespaces do not isolate: nodes, the control plane, upgrades and cluster-wide objects. Then give the common answer: dev and QA together, production alone, upgrades tried on the first.

One thing to volunteer: mention that a deploy script must wait on kubectl rollout status, not on pods showing Running, because Running says nothing about readiness. It is a one-line difference that decides whether a broken release is caught by the pipeline or by a customer.

Recall

  • Three environments = one base + three overlays. Only the intended differences live in the overlays: namespace, digest, replicas, hostname and listener, config values, budget.
  • Build once, promote the digest from dev to QA to prod. Tag with the commit SHA; deploy by @sha256: digest. Per-environment rebuilds mean QA did not check what ships.
  • Only the entry point is public. Everything else is a ClusterIP Service; a LoadBalancer per service bypasses the gateway.
  • Build secrets: ARG values show in docker history --no-trunc. Use RUN --mount=type=secret plus docker buildx build --secret.
  • Script safety: set -euo pipefail; ${1:?msg} for required arguments; az acr login instead of stored passwords.
  • Kustomize: namespace: places everything; images: swaps in digests; configMapGenerator hashes the name, so a config change triggers a rollout; JSON patches for small edits; kubectl kustomize previews.
  • With an HPA, remove replicas from the manifest, or every apply fights the autoscaler.
  • nginx: a name in proxy_pass resolves once at startup; use a variable plus resolver kube-dns.kube-system.svc.cluster.local, and a fully qualified name, because the resolver ignores the search list. The nginx image's templates/ + downward API POD_NAMESPACE makes one file work everywhere.
  • A Secret is base64, not encryption. RBAC protects it; keep real values in one Key Vault per environment, read by workload identity.
  • Wait with kubectl rollout status --timeout, never on Running; on failure rollout undo, then revert the Git commit.
  • Approval gates belong to the pipeline (environment protection, approval checks), not a typed yes.
  • Namespaces do not isolate nodes, the control plane, upgrades or cluster-wide objects; common pattern: dev + QA together, prod alone.

Self-test: Why is promoting a digest safer than rebuilding for production? · Where does a build argument's value end up? · Why does a hashed ConfigMap name matter? · Why does proxy_pass http://checkout-api fail in a fresh namespace, and why does the short name fail with resolver? · What can a namespace not protect production from?

Next: 14.5 steps back from the commands. You now know containers from the kernel up to a three-environment pipeline. The last page is about explaining all of it to someone else, in thirty seconds, in five minutes, and at a whiteboard.