conndeck blog

kubectl Contexts and KUBECONFIG: Taming Multi-Cluster

Two vendors, both shipping a kubeconfig with a context named staging, and a merge order that silently decided which one kubectl meant. That's the whole story of how a staging hotfix ended up applied to a customer's staging cluster one Tuesday. Nobody typed the wrong command. The context resolved differently than the human believed, and nothing in the toolchain pushed back.

kubectl's context system is genuinely well designed -- cluster, user, and namespace as a named tuple is the right model -- but it has sharp edges that only show up once you're past two or three clusters. This is the setup I've converged on after that Tuesday, for staying fast at the keyboard without ever wondering which cluster a command will hit.

Quick answer: Use the KUBECONFIG env var (colon-separated, first file wins conflicts) to compose per-project contexts, name every context with an owner prefix (acme-prod, never bare prod), bake default namespaces into contexts with set-context --current --namespace, and put kubectl config current-context at the top of anything that mutates a cluster. The wrong-cluster apply is the most expensive mistake in kubectl, and it's entirely a naming-and-guardrails problem.

Thirty seconds of anatomy

A kubeconfig is three lists plus a pointer:

apiVersion: v1
kind: Config
clusters:        # endpoints + CA: WHERE clusters are
users:           # credentials: WHO you are there
contexts:        # named tuples: cluster + user + namespace
current-context: acme-prod

Every confusion I've debugged reduces to one of two things: two entries in a list sharing a name, or current-context not meaning what the operator believed. Keep those two failure modes in mind and the rest of this post is just hygiene against them.

KUBECONFIG merging and the first-wins surprise

The KUBECONFIG environment variable takes multiple files, colon-separated like PATH:

export KUBECONFIG=~/.kube/config:~/clients/acme/kubeconfig:~/clients/globex/kubeconfig
kubectl config get-contexts

kubectl stitches them into one virtual config. The rule that matters: on any conflict, the first file wins. Same context name in two files? First one. Different current-context? First file that has one. This is the mechanism behind my Tuesday story: two vendor kubeconfigs, each with a context called staging, and whichever file came first in the colon list quietly owned the name.

The workflow this enables is still worth it -- per-project kubeconfig files, composed per shell rather than one ever-growing ~/.kube/config monolith -- as long as names stay unique. Which brings us to the actual fix.

Naming is the whole ballgame

After the mis-apply, we adopted one rule that has prevented every recurrence since: a context name encodes who owns the cluster. acme-prod, globex-staging-eu, platform-sandbox. Never prod, never staging, never cluster-2.

Why this works: the bare names collide exactly when your merged scope grows, and they're also the names that mean different things to different teammates ("prod" in my terminal and yours may be different clusters today). Prefixed names make collisions structurally impossible and make kubectl config get-contexts output self-describing:

CURRENT   NAME                  CLUSTER           NAMESPACE
*         acme-prod             acme-prod         payments
          acme-staging          acme-staging      default
          globex-prod           globex-prod       default

And namespace defaults belong in the context, not in your muscle memory:

kubectl config set-context acme-prod --namespace=payments --cluster=acme-prod --user=acme-prod-admin
kubectl config use-context acme-prod

kubectl apply without -n now lands in payments on purpose instead of in default by accident. The namespace column in get-contexts is the quickest sanity check before any command: context *and* namespace, both visible, both intentional.

The guardrails I put on top

Naming prevents confusion; these prevent *damage*:

Unset current-context in shared environments. kubectl config unset current-context makes plain kubectl apply fail with "no context chosen," forcing an explicit --context. It feels pedagogic for a week, then it feels like the safety it is.

Guard scripts explicitly. The top of anything that changes cluster state:

CTX=$(kubectl config current-context)
case "$CTX" in
  acme-prod) read -p ">> PRODUCTION ($CTX). Continue? [y/N] " a && [ "$a" = y ] || exit 1;;
  *) echo "refusing: unexpected context $CTX"; exit 1;;
esac

This ten-line stub has eaten more potential disasters than any other tooling I've installed. It defends against the empty-context case and the surprise-switch case at once, and it documents intent better than any wiki page. Pair it with the discipline of reading what apply is about to do server-side -- the kubectl apply lies post covers why the client-side diff isn't enough.

Treat kubeconfigs as credentials. A kubeconfig with client-key-data, a token:, or an exec: plugin block is a live credential file. The exec plugins (aws eks get-token, gke-gcloud-auth-plugin, az aks get-credentials) are the modern path precisely because they keep long-lived secrets out of the file. Anything older than that gets the same handling as a private key: never in git, never in a shared drive. The service account token risks extend to kubeconfig-embedded tokens verbatim.

Small utilities earn their keep. kubectx/kubens are the classic helpers; kubectl config rename-context fixes vendor-shipped names on arrival. And kubectl config view --minify --flatten exports a single context into a clean file for sharing -- with credentials stripped only if you strip them explicitly, so check the output before sending it anywhere.

Where the terminal stops scaling

I still live in kubectl for single-cluster surgery, but the context model has a ceiling that shows up around "several clusters, every day": you can only stand in one context at a time, so any question spanning clusters becomes a for-loop of commands, each of which re-enters the wrong-context risk surface.

That ceiling is the honest origin story of conndeck: load every kubeconfig on the machine, classify each context's health (expired credentials, RBAC-forbidden, unreachable) before you use it, and make fleet-wide reads -- the "which cluster is running the old image?" question -- a single view instead of six commands with six chances to aim at the wrong cluster. The multi-cluster management patterns get easier when the context switch is visual instead of ambient. And when something does go wrong with credentials themselves, that's usually RBAC rather than context plumbing -- see debugging forbidden errors for that layer.

Frequently asked questions

How does the KUBECONFIG environment variable merge multiple files?

List files separated by colons, like a PATH: KUBECONFIG=~/.kube/config:~/clients/acme/kubeconfig. kubectl merges them into one virtual config, and on conflicts the first file wins, including for current-context. That first-wins rule is exactly why merged setups can quietly point an old context name at a different cluster than you expect.

How do I set a default namespace per context?

kubectl config set-context --current --namespace=mynamespace. The context is the tuple of cluster, user, and namespace, so baking the default namespace into the context removes an entire class of kubectl apply -n forgetfulness. Verify with kubectl config get-contexts, which shows the namespace column.

How do I avoid deploying to the wrong cluster?

Name contexts with an owner prefix (acme-prod, not just prod), run kubectl config current-context as a guard at the top of any deploy script, and consider unsetting current-context entirely so kubectl fails fast without an explicit --context. A wrong-cluster apply is recoverable, but only if you notice, and "kubectl get pods looked normal" is not noticing.

Is it safe to share kubeconfig files in git?

The cluster endpoint and names are usually fine, but kubeconfigs frequently embed client certificates, tokens, or exec-plugin configs, which are credentials. If a repo needs a committed kubeconfig, strip credentials and rely on per-user auth, and treat any file with a client-key-data or token field as a secret that never gets committed.

The setup I land on

Per-project kubeconfig files, composed with KUBECONFIG, owner-prefixed context names, namespaces baked into contexts, current-context unset where the stakes are high, and a context guard at the top of every mutating script. None of it is clever; all of it is load-bearing.

The meta-lesson from my Tuesday: the risk wasn't a missing feature, it was an ambiguity nobody had made visible. Context state is exactly the kind of thing that deserves to be glanced at, not remembered -- whether that glance is kubectl config get-contexts or a fleet sidebar is a scale question, but either beats trusting a name.