[{"data":1,"prerenderedAt":590},["ShallowReactive",2],{"blog-\u002Fblog\u002Fmanaging-kubernetes-clusters-for-multiple-customers":3,"blog-surround-\u002Fblog\u002Fmanaging-kubernetes-clusters-for-multiple-customers":584},{"id":4,"title":5,"author":6,"body":7,"description":568,"extension":569,"image":570,"meta":571,"navigation":572,"path":573,"publishedAt":574,"relatedProducts":575,"seo":578,"stem":579,"tags":580,"updatedAt":570,"__hash__":583},"blog\u002Fblog\u002Fmanaging-kubernetes-clusters-for-multiple-customers.md","How to Manage Kubernetes Clusters for Multiple Customers","Biebie",{"type":8,"value":9,"toc":557},"minimark",[10,14,17,20,25,28,93,96,107,110,118,123,166,169,173,180,183,212,219,223,234,254,260,264,271,274,397,400,404,407,413,422,426,432,435,447,451,454,461,508,522,526,529,532,538,542,545,548,553],[11,12,13],"p",{},"Most Kubernetes advice assumes your clusters belong to you. You have a dev cluster, a staging cluster and a production cluster, they share a naming scheme, and the worst thing that can happen is you run a command against staging when you meant dev.",[11,15,16],{},"Consultants, managed service providers and contractors work under a different set of constraints. The clusters belong to different organisations, the naming schemes were decided by people who have never met each other, credentials arrive by whatever channel that customer prefers, and a mistake is not an internal embarrassment but a conversation with someone else's incident process.",[11,18,19],{},"This article covers the patterns that hold up under those constraints.",[21,22,24],"h2",{"id":23},"do-not-merge-every-kubeconfig-into-one-file","Do not merge every kubeconfig into one file",[11,26,27],{},"The instinct when you collect kubeconfigs is to merge them:",[29,30,35],"pre",{"className":31,"code":32,"language":33,"meta":34,"style":34},"language-bash shiki shiki-themes github-dark","KUBECONFIG=~\u002F.kube\u002Fconfig:~\u002FDownloads\u002Fcustomer-a.yaml \\\n  kubectl config view --flatten > ~\u002F.kube\u002Fmerged\nmv ~\u002F.kube\u002Fmerged ~\u002F.kube\u002Fconfig\n","bash","",[36,37,38,59,81],"code",{"__ignoreMap":34},[39,40,43,47,51,55],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"s95oV","KUBECONFIG",[39,48,50],{"class":49},"snl16","=",[39,52,54],{"class":53},"sU2Wk","~\u002F.kube\u002Fconfig:~\u002FDownloads\u002Fcustomer-a.yaml",[39,56,58],{"class":57},"svObZ"," \\\n",[39,60,62,65,68,71,75,78],{"class":41,"line":61},2,[39,63,64],{"class":53},"  kubectl",[39,66,67],{"class":53}," config",[39,69,70],{"class":53}," view",[39,72,74],{"class":73},"sDLfK"," --flatten",[39,76,77],{"class":49}," >",[39,79,80],{"class":53}," ~\u002F.kube\u002Fmerged\n",[39,82,84,87,90],{"class":41,"line":83},3,[39,85,86],{"class":57},"mv",[39,88,89],{"class":53}," ~\u002F.kube\u002Fmerged",[39,91,92],{"class":53}," ~\u002F.kube\u002Fconfig\n",[11,94,95],{},"This works, and it is a trap.",[11,97,98,99,102,103,106],{},"A merged file has one namespace for context names, so two customers who both named their cluster ",[36,100,101],{},"production"," collide, and the loser gets silently renamed or overwritten depending on how you merged. It has one ",[36,104,105],{},"current-context",", so the notion of \"where am I\" is global across every customer you support. And it grows without bound: when an engagement ends, removing that customer's entries from a 2,000-line merged file is error-prone enough that most people simply do not, which means expired credentials for organisations you no longer work with sit in the same file as live ones.",[11,108,109],{},"Keep one file per customer instead:",[29,111,116],{"className":112,"code":114,"language":115},[113],"language-text","~\u002F.kube\u002F\n  config                 # your own clusters, if any\n  customers\u002F\n    acme.yaml\n    globex.yaml\n    initech.yaml\n","text",[36,117,114],{"__ignoreMap":34},[11,119,120,122],{},[36,121,46],{}," accepts a list, so you can still merge at the point of use when you actually need cross-cluster visibility:",[29,124,126],{"className":31,"code":125,"language":33,"meta":34,"style":34},"export KUBECONFIG=~\u002F.kube\u002Fconfig:$(find ~\u002F.kube\u002Fcustomers -name '*.yaml' | paste -sd:)\n",[36,127,128],{"__ignoreMap":34},[39,129,130,133,136,139,142,145,148,151,154,157,160,163],{"class":41,"line":42},[39,131,132],{"class":49},"export",[39,134,135],{"class":45}," KUBECONFIG",[39,137,138],{"class":49},"=~",[39,140,141],{"class":45},"\u002F.kube\u002Fconfig:$(",[39,143,144],{"class":57},"find",[39,146,147],{"class":53}," ~\u002F.kube\u002Fcustomers",[39,149,150],{"class":73}," -name",[39,152,153],{"class":53}," '*.yaml'",[39,155,156],{"class":49}," |",[39,158,159],{"class":57}," paste",[39,161,162],{"class":73}," -sd:",[39,164,165],{"class":45},")\n",[11,167,168],{},"The important part is that the merge is a runtime view, not a stored artefact. Deleting a customer is deleting a file.",[21,170,172],{"id":171},"make-context-names-carry-the-information-you-need","Make context names carry the information you need",[11,174,175,176,179],{},"The default context name from a cloud provider tells you almost nothing useful in a multi-customer setting. EKS gives you the cluster ARN. GKE gives you ",[36,177,178],{},"gke_project_region_cluster",". Neither says whose it is or how much care it deserves.",[11,181,182],{},"Rename contexts to a scheme you control, with the customer first and the environment explicit:",[29,184,186],{"className":31,"code":185,"language":33,"meta":34,"style":34},"kubectl config rename-context \\\n  arn:aws:eks:ap-southeast-1:123456789012:cluster\u002Fprod-main \\\n  acme-prod\n",[36,187,188,200,207],{"__ignoreMap":34},[39,189,190,193,195,198],{"class":41,"line":42},[39,191,192],{"class":57},"kubectl",[39,194,67],{"class":53},[39,196,197],{"class":53}," rename-context",[39,199,58],{"class":73},[39,201,202,205],{"class":41,"line":61},[39,203,204],{"class":53},"  arn:aws:eks:ap-southeast-1:123456789012:cluster\u002Fprod-main",[39,206,58],{"class":73},[39,208,209],{"class":41,"line":83},[39,210,211],{"class":53},"  acme-prod\n",[11,213,214,215,218],{},"A scheme like ",[36,216,217],{},"customer-environment"," sorts sensibly, tab-completes usefully, and puts the two facts you most need — whose cluster, and how dangerous — in the first thing you read. If you use a prompt that shows the current context, this is also the string that will be sitting in front of you when you type a delete command.",[21,220,222],{"id":221},"set-a-default-namespace-per-context","Set a default namespace per context",[11,224,225,226,229,230,233],{},"A context can carry a namespace, and setting it removes a whole class of mistake where a command lands in ",[36,227,228],{},"default"," because you forgot ",[36,231,232],{},"-n",":",[29,235,237],{"className":31,"code":236,"language":33,"meta":34,"style":34},"kubectl config set-context acme-prod --namespace=acme-platform\n",[36,238,239],{"__ignoreMap":34},[39,240,241,243,245,248,251],{"class":41,"line":42},[39,242,192],{"class":57},[39,244,67],{"class":53},[39,246,247],{"class":53}," set-context",[39,249,250],{"class":53}," acme-prod",[39,252,253],{"class":73}," --namespace=acme-platform\n",[11,255,256,257,259],{},"This matters more across customers than within one organisation, because the namespace conventions differ. One customer puts everything in namespaces named after teams, another mirrors their Git repository structure, a third uses ",[36,258,228],{}," for genuinely everything. Encoding each customer's convention into their context means you stop carrying it in your head.",[21,261,263],{"id":262},"separate-credentials-from-cluster-definitions","Separate credentials from cluster definitions",[11,265,266,267,270],{},"Kubeconfig files can embed credentials directly, and for customer clusters they frequently do — a service account token pasted into the ",[36,268,269],{},"users"," section, or a client certificate encoded inline. That means the file itself is a secret, which makes it awkward to back up, sync between machines or share with a colleague taking over the engagement.",[11,272,273],{},"Where the customer's setup allows it, use an exec credential plugin instead, so the file describes how to obtain a credential rather than containing one:",[29,275,279],{"className":276,"code":277,"language":278,"meta":34,"style":34},"language-yaml shiki shiki-themes github-dark","users:\n  - name: acme-prod\n    user:\n      exec:\n        apiVersion: client.authentication.k8s.io\u002Fv1\n        command: aws\n        args:\n          - eks\n          - get-token\n          - --cluster-name\n          - prod-main\n          - --region\n          - ap-southeast-1\n","yaml",[36,280,281,289,303,310,318,329,340,348,357,365,373,381,389],{"__ignoreMap":34},[39,282,283,286],{"class":41,"line":42},[39,284,269],{"class":285},"s4JwU",[39,287,288],{"class":45},":\n",[39,290,291,294,297,300],{"class":41,"line":61},[39,292,293],{"class":45},"  - ",[39,295,296],{"class":285},"name",[39,298,299],{"class":45},": ",[39,301,302],{"class":53},"acme-prod\n",[39,304,305,308],{"class":41,"line":83},[39,306,307],{"class":285},"    user",[39,309,288],{"class":45},[39,311,313,316],{"class":41,"line":312},4,[39,314,315],{"class":285},"      exec",[39,317,288],{"class":45},[39,319,321,324,326],{"class":41,"line":320},5,[39,322,323],{"class":285},"        apiVersion",[39,325,299],{"class":45},[39,327,328],{"class":53},"client.authentication.k8s.io\u002Fv1\n",[39,330,332,335,337],{"class":41,"line":331},6,[39,333,334],{"class":285},"        command",[39,336,299],{"class":45},[39,338,339],{"class":53},"aws\n",[39,341,343,346],{"class":41,"line":342},7,[39,344,345],{"class":285},"        args",[39,347,288],{"class":45},[39,349,351,354],{"class":41,"line":350},8,[39,352,353],{"class":45},"          - ",[39,355,356],{"class":53},"eks\n",[39,358,360,362],{"class":41,"line":359},9,[39,361,353],{"class":45},[39,363,364],{"class":53},"get-token\n",[39,366,368,370],{"class":41,"line":367},10,[39,369,353],{"class":45},[39,371,372],{"class":53},"--cluster-name\n",[39,374,376,378],{"class":41,"line":375},11,[39,377,353],{"class":45},[39,379,380],{"class":53},"prod-main\n",[39,382,384,386],{"class":41,"line":383},12,[39,385,353],{"class":45},[39,387,388],{"class":53},"--region\n",[39,390,392,394],{"class":41,"line":391},13,[39,393,353],{"class":45},[39,395,396],{"class":53},"ap-southeast-1\n",[11,398,399],{},"The kubeconfig becomes a description of how to reach the cluster, and the actual secret stays wherever your cloud CLI or identity provider keeps it. That also means the token is short-lived rather than a long-lived string sitting in a file.",[21,401,403],{"id":402},"treat-the-network-path-as-part-of-the-cluster-definition","Treat the network path as part of the cluster definition",[11,405,406],{},"Customer clusters are usually not reachable from the open internet. Getting to them means a VPN, and each customer has chosen a different one. In practice, \"connect to Acme's cluster\" is a two-part operation: bring up Acme's VPN with the right identity, then use Acme's kubeconfig.",[11,408,409,410,412],{},"The failure mode is subtle. If the VPN is down, your ",[36,411,192],{}," command does not fail with \"you are not connected to the right network\" — it fails with a connection timeout, which looks identical to a cluster that is genuinely unreachable. Time spent debugging an API server that is fine is time wasted.",[11,414,415,416,421],{},"Two things help. First, record which VPN profile and which login identity belong to which cluster, in the same place you record the cluster, so the association is written down rather than remembered. That is the job ",[417,418,420],"a",{"href":419},"\u002Fbiebie-access","Biebie Access"," exists to do. Second, prefer tooling that distinguishes a network-level failure from a TLS failure from an authentication failure, so the error tells you which layer to look at.",[21,423,425],{"id":424},"mark-production-and-make-it-behave-differently","Mark production and make it behave differently",[11,427,428,429,431],{},"Every customer has clusters you can experiment with and clusters you cannot. The difference is invisible in ",[36,430,192],{}," — the same command runs the same way regardless of what is on the other end.",[11,433,434],{},"At minimum, make production visible in your shell. A prompt segment that shows the current context, coloured by environment, is a small change with a large effect, because the information arrives without you asking for it.",[11,436,437,438,442,443,446],{},"Better still, make destructive actions on production require something you cannot do by muscle memory. ",[417,439,441],{"href":440},"\u002Fbiebie-kube","Biebie Kube"," lets you mark a cluster as production and then requires the resource name to be typed out in full before a destructive action runs. Typing ",[36,444,445],{},"payments-api"," deliberately is a different cognitive act from clicking a button you have clicked a hundred times on staging.",[21,448,450],{"id":449},"prefer-optimistic-concurrency-when-editing-live-objects","Prefer optimistic concurrency when editing live objects",[11,452,453],{},"Editing a resource directly in a customer cluster is sometimes unavoidable — an incident, a misconfiguration to correct, a value to check. The risk is not that you make a mistake in the YAML. It is that someone else, or a controller, changed the object between when you read it and when you wrote it, and your write discards their change without either of you noticing.",[11,455,456,457,460],{},"Kubernetes has a mechanism for this. Every object carries a ",[36,458,459],{},"resourceVersion",", and an update that includes the version you read will be rejected with a conflict if the object has moved on:",[29,462,464],{"className":31,"code":463,"language":33,"meta":34,"style":34},"kubectl get deployment payments-api -o yaml > payments.yaml\n# edit payments.yaml, leaving metadata.resourceVersion intact\nkubectl replace -f payments.yaml\n",[36,465,466,490,496],{"__ignoreMap":34},[39,467,468,470,473,476,479,482,485,487],{"class":41,"line":42},[39,469,192],{"class":57},[39,471,472],{"class":53}," get",[39,474,475],{"class":53}," deployment",[39,477,478],{"class":53}," payments-api",[39,480,481],{"class":73}," -o",[39,483,484],{"class":53}," yaml",[39,486,77],{"class":49},[39,488,489],{"class":53}," payments.yaml\n",[39,491,492],{"class":41,"line":61},[39,493,495],{"class":494},"sAwPA","# edit payments.yaml, leaving metadata.resourceVersion intact\n",[39,497,498,500,503,506],{"class":41,"line":83},[39,499,192],{"class":57},[39,501,502],{"class":53}," replace",[39,504,505],{"class":73}," -f",[39,507,489],{"class":53},[11,509,510,511,514,515,518,519,521],{},"If the object changed in between, you get ",[36,512,513],{},"Operation cannot be fulfilled ... the object has been modified"," rather than a silent overwrite. Note that ",[36,516,517],{},"kubectl apply"," does not behave this way by default — it merges, and a merge can absorb someone else's change without a conflict. Any tool that offers a YAML editor should be sending the ",[36,520,459],{}," you loaded; if it does not, it is capable of losing changes you never saw.",[21,523,525],{"id":524},"when-the-engagement-ends-archive-rather-than-delete","When the engagement ends, archive rather than delete",[11,527,528],{},"Finished engagements produce a specific kind of clutter: credentials that no longer work, contexts you will never select again, and the small ongoing risk of selecting one by accident.",[11,530,531],{},"Deleting them is the obvious move, and it is usually wrong. Six months later someone asks what version of an operator that customer was running, or the engagement restarts, and the configuration you deleted has to be requested again from people who may have moved on.",[11,533,534,535,537],{},"Move it out of the active set instead. A separate directory that is not in your ",[36,536,46],{}," path achieves this with plain files. The point is that inactive access should require a deliberate act to become active again, without the information being destroyed.",[21,539,541],{"id":540},"what-this-adds-up-to","What this adds up to",[11,543,544],{},"None of this is exotic. One file per customer, context names that say whose cluster and how dangerous, namespaces encoded in the context, credentials obtained rather than stored, the network path recorded next to the cluster, production visibly marked, edits that fail loudly on conflict, and finished work archived rather than deleted.",[11,546,547],{},"The reason to be deliberate about it is that multi-customer work removes the safety net that single-organisation work has. There is no shared convention to fall back on, no colleague who would have caught it, and no internal blast radius. The structure has to come from you.",[11,549,550,552],{},[417,551,441],{"href":440}," was built around these patterns — clusters grouped by customer, kubeconfigs read where they already are, production clusters marked and guarded, and edits that carry their resource version. It is free for macOS and Windows.",[554,555,556],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":34,"searchDepth":83,"depth":83,"links":558},[559,560,561,562,563,564,565,566,567],{"id":23,"depth":61,"text":24},{"id":171,"depth":61,"text":172},{"id":221,"depth":61,"text":222},{"id":262,"depth":61,"text":263},{"id":402,"depth":61,"text":403},{"id":424,"depth":61,"text":425},{"id":449,"depth":61,"text":450},{"id":524,"depth":61,"text":525},{"id":540,"depth":61,"text":541},"Practical patterns for kubeconfig layout, context naming and production safety when the clusters you work with belong to different organisations.","md",null,{},true,"\u002Fblog\u002Fmanaging-kubernetes-clusters-for-multiple-customers","2026-07-14",[576,577],"biebie-kube","biebie-access",{"title":5,"description":568},"blog\u002Fmanaging-kubernetes-clusters-for-multiple-customers",[581,582],"Kubernetes","Operations","cWQPF0Z85rjJVXUCuPvv6PIdZpWteiYrPw_rGZaV8FY",[585,570],{"title":586,"path":587,"stem":588,"description":589,"children":-1},"Watches vs Polling: Why Kubernetes Tools Feel Slow","\u002Fblog\u002Fkubernetes-watches-versus-polling","blog\u002Fkubernetes-watches-versus-polling","How the Kubernetes list-and-watch protocol works, what informers actually cache, and why a tool that polls the API server will always feel a step behind.",1788424373700]