The Kubernetes :latest Tag Problem: How a Mutable Tag Rolled Our Prod Back for 40 Minutes
Nobody deployed. Nobody ran kubectl apply. At 02:00 the orders API started throwing errors we'd fixed three weeks earlier, and it stayed broken for 40 minutes. The kubernetes latest tag problem is not theoretical. This is the incident that made me delete :latest from every manifest we own.
Symptom
On-call paged. Errors that were fixed weeks ago, live again.
2025-... POST /orders 500 InvalidStateError: order already settled
2025-... POST /orders 500
2025-... POST /orders 500
Deploy history: empty. No release. No apply. No human near the cluster. Running code had changed anyway. First reflex was to blame a rogue actor with cluster creds. kubectl get events and the audit log both said no — nobody had written to the API server in hours. The image had moved without a single Kubernetes write.
Root cause
The Deployment referenced a mutable tag.
containers:
- name: orders
image: orders:latest
Timeline, reconstructed from the kubelet events and registry push log:
- Someone pushed a mid-refactor build and tagged it
latest. Never promoted it. - Hours later a pod OOM'd and restarted.
- On restart the kubelet pulled
latestagain — now pointing at the broken build. - Pod came up on code nobody chose to deploy. Prod changed its own version while we slept.
Three separate properties of latest conspired here, and the Kubernetes image docs warn about all of them:
-
latestis mutable. It points at a different digest tomorrow. You cannot answer "what is running in prod?" -
Re-running
applyis a no-op. Kubernetes rolls out only when the pod template changes. The literal stringimage: orders:latestnever changes, so the cluster sees nothing to do even when the registry digest is completely different. -
A restart re-pulls.
imagePullPolicydefaults toAlwaysforlatest(andIfNotPresentfor a pinned tag). Solatestmaximizes the odds of a surprise pull on every restart. The inverse trap is just as ugly: a pinned tag withIfNotPresentcan stick on a stale local layer and never pull the fix you just pushed. Both are the same disease — the running image and the name you typed have drifted apart.
There's a solid breakdown of the full local-to-CI path that this class of bug forced on us at this writeup on preparing a local setup for deployment and CI. I'll stick to the fixes.
The fix
1. Immutable tags, generated in CI
Rule: every image is tagged by something that resolves to exactly one build. Short SHA, semver, or a digest pin for maximum determinism.
image: ghcr.io/acme/orders@sha256:45b23dee...
Don't hand-write tags. docker/metadata-action emits them from the git context and writes OCI labels like org.opencontainers.image.revision. "What's in prod?" collapses from an investigation to one label read:
docker inspect ghcr.io/acme/orders:sha-abc1234 \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'
2. One base, kill the copy-pasted k8s-prod/
The other sin: a k8s/ folder copied to k8s-prod/ months earlier. Two copies drift. Ours had, and prod's memory limit no longer matched what we'd tested. Configuration drift, cured by a single base with only the diffs layered on top.
Kustomize — built into kubectl, plain YAML, no templating engine to reason about:
# overlays/prod/kustomization.yaml
resources:
- ../../base
namePrefix: prod-
images:
- name: orders
newName: ghcr.io/acme/orders
newTag: sha-abc1234 # swap the image without touching the Deployment
patches:
- path: replicas-patch.yaml
# render and eyeball it before it touches the cluster
kustomize build overlays/prod | kubectl apply -f -
The images field is the quiet win: CI rewrites the tag without editing the Deployment YAML. Helm with a per-env values-prod.yaml lands the same place — just mind -f ordering, last file wins, and swapping the order silently changes your prod replica count.
3. A pipeline that physically cannot ship :latest
Push-based and deliberately dumb: commit → build → push → update manifest to the immutable tag → apply. GitHub Actions skeleton, using Docker's build-push-action:
name: build-and-deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/acme/orders
tags: |
type=sha
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Deploy
run: |
echo "${{ secrets.KUBECONFIG }}" > kubeconfig
export KUBECONFIG=kubeconfig
kubectl set image deployment/orders \
orders=ghcr.io/acme/orders:sha-${GITHUB_SHA::7}
The deploy step sets an immutable tag, never latest. Now the pod template genuinely differs, the rollout is honest, and a restart can't re-pull a mystery image.
Reproducing the outage on k3d first
I didn't ship any of this blind. I reproduced the exact failure on a laptop k3d cluster, then confirmed the fix killed it. The whole loop is a few minutes and costs nothing.
k3d cluster create latest-repro
# push two builds to the same mutable tag
docker build -t k3d-registry:5000/orders:latest ./good && docker push k3d-registry:5000/orders:latest
kubectl apply -k overlays/local # Deployment references :latest
kubectl rollout status deploy/orders
# now poison latest and force a restart, no apply involved
docker build -t k3d-registry:5000/orders:latest ./broken && docker push k3d-registry:5000/orders:latest
kubectl delete pod -l app=orders # simulate the 02:00 crash
kubectl get pod -l app=orders -o jsonpath='{.items[0].spec.containers[0].image}'
The pod came back on the broken image with an empty deploy history — the outage, on demand. Re-run the same script against the SHA-pinned overlay and the delete/restart brings the same image back every time. That's the whole proof. Once it held, I trusted it in prod.
Before / after
| Before | After | |
|---|---|---|
| Image tags | :latest |
immutable sha-… / digest |
| "What's in prod?" | an investigation | one label read |
| Environments | copy-pasted k8s-prod/
|
one base + overlays |
| Drift | silent, unbounded | the diff is the only diff |
| Restart | may pull broken latest
|
pinned, deterministic |
| Rollout on redeploy | sometimes a no-op | always honest |
Guardrail
- Grep CI and manifests for
:latestin a pre-merge check. No merge if it hits. - Most local Kubernetes work carried straight to prod: same Dockerfile, same Kustomize base, same probes. Prod parity is the payoff. Prod only adds — registry, Secrets, TLS, HPA, PodDisruptionBudget.
- Some things must never cross the line:
--reload, file-sync dev accelerators, exposed debug ports. The prod overlay is the mechanism that keeps them out. - Pin a digest, not just a tag, for anything you truly can't afford to move —
@sha256:...is the only reference in Kubernetes that a registry cannot repoint under you.
What I'd do differently
Move off the push pipeline sooner. It has three built-in weaknesses I already know will bite:
- CI holds the kubeconfig. CI access equals prod access.
-
Drift is invisible. Someone runs
kubectl editby hand and CI never notices. - No automatic rollback. Ship broken, revert by hand, at 02:00.
Those three are exactly what GitOps removes. Per the CNCF OpenGitOps principles, Git is the source of truth and an in-cluster agent (Argo CD or Flux) pulls and reconciles drift. CI's job ends at pushing the image and committing the tag. That's the next rung: CI builds an immutable image → a bot bumps the tag in Git → the agent applies it.
Bottom line: the outage wasn't a Kubernetes flaw, it was a mutable tag plus a copy-pasted folder — pin an immutable SHA and collapse onto one base before your own 02:00 page.
Sources
- Kubernetes docs — Images (avoid
:latest; imagePullPolicy defaults) - Kubernetes docs — Declarative management with Kustomize (bases & overlays)
- docker/metadata-action — immutable image tags & OCI labels
- docker/build-push-action — build and push images in CI
- CNCF OpenGitOps — the four GitOps principles
- Local-to-production deployment & CI walkthrough
Top comments (0)