<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[kotaicode - Medium]]></title>
        <description><![CDATA[web &amp; mobile application development, DevOps, Cloud, AI, Blockchain - Medium]]></description>
        <link>https://medium.com/kotaicode?source=rss----c897b9860971---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>kotaicode - Medium</title>
            <link>https://medium.com/kotaicode?source=rss----c897b9860971---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 00:54:59 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/kotaicode" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Why Kubernetes Namespaces Aren’t a Real Security Boundary]]></title>
            <link>https://medium.com/kotaicode/why-kubernetes-namespaces-arent-a-real-security-boundary-3b559955e1d0?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/3b559955e1d0</guid>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 04:31:04 GMT</pubDate>
            <atom:updated>2026-11-04T04:31:04.067Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Shared kernels, RBAC limitations, resource isolation, and how to build secure multi-tenant Kubernetes clusters</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4d4r7YEnJRJ6eq3eL_zpPg.png" /></figure><p>Kubernetes namespaces were never designed to be a security boundary. They were designed so two teams could both name a Deployment api without colliding. Somewhere between that design and today&#39;s shared platforms, &quot;each team gets a namespace&quot; turned into &quot;each team is isolated,&quot; and those are very different promises.</p><p>A namespace scopes names and API permissions. It doesn’t scope the kernel your containers run on, the node they share, the network they talk over, or the CPU and memory they compete for. If one tenant’s workload is compromised, the namespace alone stops almost nothing.</p><p>This article walks through one scenario. Two teams share a production EKS cluster, one team’s Pod gets compromised, and each section adds the control that closes the next path the attacker would take. By the end, you’ll have a layered model for multi-tenancy and a clear sense of when a namespace is enough and when it isn’t.</p><h3>The Scenario</h3><p>The cluster is eks-prod-shared. The payments team runs payments-api and stores database credentials in a Kubernetes Secret. The growth team runs image-resizer, a service built on a third-party image library. Each team has its own namespace and its own RoleBindings. Leadership believes the teams are isolated.</p><p>On a Tuesday, a remote code execution bug in the image library is disclosed. Before growth patches it, an attacker uploads a crafted image and gets a shell inside an image-resizer Pod. From there, here&#39;s what a namespace-only cluster allows.</p><h3>What Kubernetes Namespaces Actually Isolate</h3><p>Namespaces partition namespaced API objects: Pods, Services, Secrets, ConfigMaps, Roles, and similar. A RoleBinding in growth grants nothing in payments. That part works.</p><p>Everything else is shared by every tenant:</p><pre>$ kubectl api-resources --namespaced=false -o name | head -12<br>apiservices.apiregistration.k8s.io<br>certificatesigningrequests.certificates.k8s.io<br>clusterroles.rbac.authorization.k8s.io<br>customresourcedefinitions.apiextensions.k8s.io<br>ingressclasses.networking.k8s.io<br>mutatingwebhookconfigurations.admissionregistration.k8s.io<br>namespaces<br>nodes<br>persistentvolumes<br>priorityclasses.scheduling.k8s.io<br>storageclasses.storage.k8s.io<br>validatingwebhookconfigurations.admissionregistration.k8s.io</pre><p>Nodes, PersistentVolumes, CRDs, PriorityClasses, and admission webhooks are all cluster-scoped. So are the things that aren’t API objects at all: the kernel on each node, the Pod network, cluster DNS, and the node’s IAM role. Every Service in the cluster also resolves from every namespace. Our attacker can run nslookup payments-api.payments.svc.cluster.local and get a valid IP back on the first try.</p><h3>Why RBAC Alone Isn’t Enough</h3><p>The attacker’s first move is to check what the Pod’s identity can do. Kubernetes mounts a ServiceAccount token into every Pod by default:</p><pre>$ kubectl auth can-i --list --as=system:serviceaccount:growth:image-resizer -n growth<br>Resources                Verbs<br>pods                     [create delete get list patch update watch]<br>pods/exec                [create]<br>deployments.apps         [create delete get list patch update watch]<br>secrets                  [get list watch]<br>...</pre><p>Someone bound image-resizer to the built-in edit ClusterRole months ago, so the service could restart its own workers. RBAC is doing exactly what it was told, and none of these permissions reach payments. The problem is that create pods is far more powerful than it looks.</p><p>Anyone who can create Pods in a namespace can mount every Secret in that namespace and run as every ServiceAccount in it, so they inherit the highest privilege available there. They can also choose what that Pod looks like: privileged, with the host filesystem mounted, scheduled onto whichever node they like. RBAC controls which API calls you can make. It has no opinion on what the Pods you create are allowed to do once they’re running. That’s the job of admission control, covered below.</p><p>The fix starts with not handing out identity by default. Wrong:</p><pre>apiVersion: rbac.authorization.k8s.io/v1<br>kind: RoleBinding<br>metadata:<br>  name: image-resizer-edit<br>  namespace: growth<br>subjects:<br>  - kind: ServiceAccount<br>    name: image-resizer<br>    namespace: growth<br>roleRef:<br>  apiGroup: rbac.authorization.k8s.io<br>  kind: ClusterRole<br>  name: edit</pre><p>Right:</p><pre>apiVersion: v1<br>kind: ServiceAccount<br>metadata:<br>  name: image-resizer<br>  namespace: growth<br>automountServiceAccountToken: false</pre><p>Most application Pods never call the Kubernetes API, so they shouldn’t carry a token at all. The few that do should get a Role with exactly the verbs and resource names they need. Also audit for the RBAC verbs that are effectively cluster-admin in disguise: escalate, bind, impersonate, nodes/proxy, and write access to webhook configurations.</p><h3>The Shared Kernel Problem</h3><p>Suppose RBAC is tight, but the attacker still has a shell. The next target is the node.</p><p>Containers are processes with namespaces and cgroups applied. Every Pod on a node shares that node’s Linux kernel. A kernel bug or a container runtime bug is a path from one tenant’s container to the host, and from the host to every other Pod on it. This isn’t theoretical. CVE-2024–21626 in runc let a crafted container reach the host filesystem through a leaked file descriptor.</p><p>Once on the host, the attacker can read every Secret volume the kubelet has mounted for other Pods on that node, under /var/lib/kubelet/pods/. If a payments-api replica happens to be scheduled next to image-resizer, its database credentials are sitting on that disk. Kubernetes&#39; Node authorizer limits a kubelet to the Secrets of Pods scheduled on its own node, which is exactly the point: whoever owns the node owns everything scheduled on it.</p><p>On EKS there’s one more prize on the node: its IAM role. If a Pod can reach the instance metadata service, it can pull the node role’s credentials, which typically include ECR pull, EC2 describe calls, and whatever else someone attached “temporarily.” Require IMDSv2 with a hop limit of 1 on every node, so Pods can’t reach it. Karpenter’s EC2NodeClass does this by default. Managed node groups and custom launch templates often allow a hop limit of 2, so check them. Use EKS Pod Identity for workloads that need AWS access.</p><p>Two kernel-level controls reduce the blast radius. User namespaces, GA in Kubernetes 1.36, map root inside the container to an unprivileged UID on the host, so a breakout lands as a nobody:</p><pre>spec:<br>  hostUsers: false</pre><p>Seccomp profiles (RuntimeDefault at minimum) cut the number of syscalls an attacker can use to reach kernel bugs. Neither removes the shared kernel. Both make exploiting it much harder.</p><h3>Preventing Privileged Workloads</h3><p>Without a kernel bug, the easiest path to the host is to ask for it. With create pods permission and no admission controls, the attacker can simply submit this:</p><pre>apiVersion: v1<br>kind: Pod<br>metadata:<br>  name: resizer-debug<br>  namespace: growth<br>spec:<br>  hostPID: true<br>  containers:<br>    - name: debug<br>      image: public.ecr.aws/docker/library/busybox:1.36<br>      command: [&quot;sleep&quot;, &quot;infinity&quot;]<br>      securityContext:<br>        privileged: true<br>      volumeMounts:<br>        - name: host<br>          mountPath: /host<br>  volumes:<br>    - name: host<br>      hostPath:<br>        path: /</pre><p>That’s full node access, granted by a namespace-scoped permission. Pod Security Admission blocks it with three labels on the namespace:</p><pre>apiVersion: v1<br>kind: Namespace<br>metadata:<br>  name: growth<br>  labels:<br>    pod-security.kubernetes.io/enforce: restricted<br>    pod-security.kubernetes.io/enforce-version: latest<br>    pod-security.kubernetes.io/warn: restricted</pre><p>The restricted profile rejects privileged containers, host namespaces, hostPath volumes, root users, added capabilities, and missing seccomp profiles. Here&#39;s the sharp edge: PSA only applies to namespaces that carry the label. A tenant who can create namespaces, or a platform template that forgets the label, quietly falls back to no enforcement. Set a cluster-wide default through the AdmissionConfiguration where you control the API server. On EKS, where you don&#39;t, enforce the label itself with a policy that rejects namespaces created without it.</p><p>PSA covers the Pod spec. Tenant-specific rules need a policy engine. ValidatingAdmissionPolicy (GA since 1.30) handles them in CEL without running a webhook, and the next section uses it.</p><h3>Resource Isolation: ResourceQuota and LimitRange</h3><p>The attacker doesn’t need secrets to hurt payments. They only need the node. A Deployment of 200 replicas with no limits, a Pod that fills the node&#39;s disk with temp files, or a fork bomb can starve or evict payments-api Pods sharing the same nodes. Availability is a security property too.</p><p>ResourceQuota caps what a namespace can consume. LimitRange gives every container sensible defaults so the quota actually applies:</p><pre>apiVersion: v1<br>kind: ResourceQuota<br>metadata:<br>  name: growth-quota<br>  namespace: growth<br>spec:<br>  hard:<br>    requests.cpu: &quot;40&quot;<br>    requests.memory: 80Gi<br>    limits.memory: 120Gi<br>    requests.ephemeral-storage: 100Gi<br>    limits.ephemeral-storage: 200Gi<br>    pods: &quot;150&quot;<br>    services.loadbalancers: &quot;2&quot;<br>---<br>apiVersion: v1<br>kind: LimitRange<br>metadata:<br>  name: growth-defaults<br>  namespace: growth<br>spec:<br>  limits:<br>    - type: Container<br>      default:<br>        memory: 512Mi<br>        ephemeral-storage: 2Gi<br>      defaultRequest:<br>        cpu: 100m<br>        memory: 256Mi<br>        ephemeral-storage: 512Mi<br>      max:<br>        memory: 8Gi</pre><p>Watch two gaps. First, PriorityClasses are cluster-scoped. A tenant who sets priorityClassName: system-cluster-critical can preempt other tenants&#39; Pods, so add a quota with a scopeSelector on PriorityClass that limits each namespace to the classes you&#39;ve approved. Second, quotas don&#39;t cover process IDs. Set the kubelet&#39;s podPidsLimit so one Pod&#39;s fork bomb can&#39;t exhaust PIDs for the whole node.</p><h3>Network Isolation Between Namespaces</h3><p>The Kubernetes network is flat by default. From image-resizer, the attacker can reach payments-api, the payments database proxy, and every other Pod IP in the cluster. Namespaces have no effect on routing at all.</p><p>Start every tenant namespace with default deny in both directions, then add back only what’s needed. DNS is the one everyone forgets:</p><pre>apiVersion: networking.k8s.io/v1<br>kind: NetworkPolicy<br>metadata:<br>  name: default-deny<br>  namespace: growth<br>spec:<br>  podSelector: {}<br>  policyTypes: [Ingress, Egress]<br>---<br>apiVersion: networking.k8s.io/v1<br>kind: NetworkPolicy<br>metadata:<br>  name: allow-dns-and-internet<br>  namespace: growth<br>spec:<br>  podSelector: {}<br>  policyTypes: [Egress]<br>  egress:<br>    - to:<br>        - namespaceSelector:<br>            matchLabels:<br>              kubernetes.io/metadata.name: kube-system<br>          podSelector:<br>            matchLabels:<br>              k8s-app: kube-dns<br>      ports:<br>        - { protocol: UDP, port: 53 }<br>        - { protocol: TCP, port: 53 }<br>    - to:<br>        - ipBlock:<br>            cidr: 0.0.0.0/0<br>            except:<br>              - 10.20.0.0/16        # cluster VPC: no lateral movement<br>              - 169.254.169.254/32  # instance metadata<br>      ports:<br>        - { protocol: TCP, port: 443 }</pre><p>The sharp edge is enforcement. NetworkPolicy objects are only enforced if your CNI enforces them. On EKS, the VPC CNI needs network policy enabled explicitly, or you install Cilium or Calico. A cluster without enforcement accepts these objects, stores them, and silently ignores them. Test it: run a Pod in growth and try to curl a Service in payments. If it connects, your policies are decoration.</p><h3>Stronger Isolation Strategies</h3><p>Everything so far still shares nodes between tenants. For tenants with different trust levels, that’s the remaining hole: one kernel exploit beats every control above. There are three ways to stop sharing.</p><p>Dedicated node pools give each tenant its own nodes. With Karpenter, a NodePool per tenant carries a taint and a label:</p><pre>apiVersion: karpenter.sh/v1<br>kind: NodePool<br>metadata:<br>  name: tenant-growth<br>spec:<br>  template:<br>    metadata:<br>      labels:<br>        tenant.payflow.io/team: growth<br>    spec:<br>      taints:<br>        - key: tenant.payflow.io/team<br>          value: growth<br>          effect: NoSchedule<br>      nodeClassRef:<br>        group: karpenter.k8s.aws<br>        kind: EC2NodeClass<br>        name: default</pre><p>Here’s the sharp edge that defeats most “dedicated node” setups: taints aren’t a security control. Any Pod can add a toleration for any taint, and any Pod can set a nodeSelector for any label. A compromised growth workload can schedule itself onto payments nodes. Enforce placement in admission instead:</p><pre>apiVersion: admissionregistration.k8s.io/v1<br>kind: ValidatingAdmissionPolicy<br>metadata:<br>  name: tenant-node-placement<br>spec:<br>  failurePolicy: Fail<br>  matchConstraints:<br>    resourceRules:<br>      - apiGroups: [&quot;&quot;]<br>        apiVersions: [&quot;v1&quot;]<br>        operations: [&quot;CREATE&quot;, &quot;UPDATE&quot;]<br>        resources: [&quot;pods&quot;]<br>  validations:<br>    - expression: &gt;-<br>        has(object.spec.nodeSelector) &amp;&amp;<br>        &#39;tenant.payflow.io/team&#39; in object.spec.nodeSelector &amp;&amp;<br>        object.spec.nodeSelector[&#39;tenant.payflow.io/team&#39;] ==<br>        namespaceObject.metadata.labels[&#39;tenant.payflow.io/team&#39;]<br>      message: &quot;Pods must target their own tenant&#39;s node pool.&quot;<br>---<br>apiVersion: admissionregistration.k8s.io/v1<br>kind: ValidatingAdmissionPolicyBinding<br>metadata:<br>  name: tenant-node-placement<br>spec:<br>  policyName: tenant-node-placement<br>  validationActions: [Deny]<br>  matchResources:<br>    namespaceSelector:<br>      matchExpressions:<br>        - key: tenant.payflow.io/team<br>          operator: Exists</pre><p>Sandboxed runtimes keep shared nodes but give each Pod its own kernel. Kata Containers runs every Pod in a lightweight VM, and gVisor intercepts syscalls in a user-space kernel. Both are selected per Pod with a RuntimeClass:</p><pre>apiVersion: node.k8s.io/v1<br>kind: RuntimeClass<br>metadata:<br>  name: kata<br>handler: kata</pre><p>The cost is real. Kata needs hardware virtualization (on EC2, usually metal instances), and gVisor adds syscall overhead that hurts I/O-heavy workloads. Use sandboxes for untrusted code, like customer-supplied plugins or CI build jobs, not as a default for everything.</p><p>Separate clusters are the only option where the control plane, CRDs, admission webhooks, and cluster-scoped resources aren’t shared at all. On EKS, add separate AWS accounts and the IAM boundary comes along for free.</p><h3>Designing Secure Multi-Tenant Kubernetes</h3><p>Here’s how the three common architectures compare:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J7R_t7XqTldPRqIG0BMSmQ.png" /></figure><p>My default is a hybrid. Internal teams with similar trust share a cluster with namespaces plus every control above. Workloads that handle regulated data get dedicated node pools enforced by admission. Untrusted code runs in a sandboxed runtime. Anything owned by an external customer gets its own cluster. Virtual clusters (vCluster and similar) sit in the middle and give each tenant its own API server and CRDs, but they still share nodes, so the kernel question remains.</p><p>Back to the attacker in image-resizer, with every layer applied:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uxwc9GEfYSnGelgszmegFA.png" /></figure><h3>Multi-tenant cluster checklist</h3><ul><li>Application ServiceAccounts don’t automount tokens</li><li>No tenant binding to edit or admin for workloads; audit escalate, bind, impersonate</li><li>Every tenant namespace enforces Pod Security restricted, and unlabeled namespaces are rejected</li><li>Seccomp RuntimeDefault everywhere; hostUsers: false where the runtime supports it</li><li>IMDSv2 required with hop limit 1 on every node</li><li>ResourceQuota and LimitRange in every tenant namespace, including ephemeral storage</li><li>PriorityClass usage limited per namespace; kubelet podPidsLimit set</li><li>Default-deny NetworkPolicy in both directions, enforcement verified with a real test</li><li>Tenant node placement enforced by admission, not just taints</li><li>Untrusted code runs in a sandboxed runtime or a separate cluster</li></ul><p>A namespace answers “whose is this?” Security questions are about “what can this reach?”, and the answer to that is decided by the kernel, the network, the node, and the admission chain, none of which read namespace boundaries on their own. Multi-tenancy in Kubernetes is a stack of controls you assemble yourself, and the right height for that stack depends on how much you trust the tenant on the other side. Decide that first, and the architecture mostly chooses itself.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3b559955e1d0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/why-kubernetes-namespaces-arent-a-real-security-boundary-3b559955e1d0">Why Kubernetes Namespaces Aren’t a Real Security Boundary</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Hidden Danger of Argo CD’s Prune and Self-Heal Features]]></title>
            <link>https://medium.com/kotaicode/the-hidden-danger-of-argo-cds-prune-and-self-heal-features-abdc24745cf7?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/abdc24745cf7</guid>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[argo-cd]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 04:31:03 GMT</pubDate>
            <atom:updated>2026-11-03T04:31:03.398Z</atom:updated>
            <content:encoded><![CDATA[<p><em>GitOps automation doesn’t know whether your configuration is correct. It only knows whether your cluster matches Git.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LEpZOUAfu2sUY5DknfeXOw.png" /></figure><p>Argo CD has never deleted a production resource by mistake. Every Deployment it pruned and every manual fix it reverted, it did on purpose, because Git said so. That’s the property that makes GitOps reliable, and it’s the same property that turns a one-line mistake into an outage across every cluster you run.</p><p>Most teams turn on automated, prune, and selfHeal together because that&#39;s what the getting-started guides show. The result is a system that applies your intent faster and more consistently than any human could, with no opinion at all on whether that intent is any good.</p><p>This article covers how those three settings behave in production, the specific ways they cause damage, the safeguards Argo CD gives you (including what each one doesn’t protect), and how to recover when automation has already done the damage. Examples assume Argo CD 3.x.</p><h3>How Argo CD Automated Sync Actually Works</h3><p>Argo CD compares two states for every Application: the desired state rendered from Git and the live state in the cluster. It refreshes every three minutes by default, or within seconds if a Git webhook is configured. What happens when the two differ depends on three settings:</p><pre>spec:<br>  syncPolicy:<br>    automated:<br>      prune: true<br>      selfHeal: true<br>      allowEmpty: false</pre><p>automated syncs when Git changes. A new commit lands, Argo CD applies it. Automated sync attempts each commit once: if the sync fails, it won&#39;t retry the same revision unless you&#39;ve configured retry.</p><p>prune deletes resources Argo CD previously managed that no longer appear in Git. Without it, a removed manifest shows up as an orphan that needs pruning, and nothing is deleted.</p><p>selfHeal syncs when the cluster changes. If someone edits a managed resource with kubectl, Argo CD reapplies Git&#39;s version within seconds.</p><p>The key detail is that none of these settings evaluate correctness. Argo CD checks one thing: does live match desired? A Deployment with a typo in its image tag and a Deployment that’s perfect are equally “Synced.” Hold onto that, because every failure below follows from it.</p><h3>When Prune Accidentally Deletes Production Resources</h3><p>Prune deletes whatever Git stops describing. The question is how many ways Git can stop describing something without anyone intending a deletion. Here are the ones I see most.</p><p>Removing a manifest is the obvious one, usually part of a refactor split across two PRs: “remove the old manifests in this PR, add the Helm chart in the next.” The first PR merges and prune runs before the second exists.</p><p>Changing an Application’s source is subtler. Point path from apps/media-service/overlays/production to a new directory that doesn&#39;t yet contain every resource, and everything missing gets pruned. Changing targetRevision to a branch that&#39;s behind main does the same thing.</p><p>ApplicationSet generators are the largest blast radius. Say a cluster generator selects on the label env: production, and someone relabels clusters to environment: production. The generator now produces zero elements. With the default applicationsSync: sync policy, the ApplicationSet deletes every production Application, and the resources-finalizer.argocd.argoproj.io finalizer on each one cascades that deletion to every resource the Application managed. No prune setting is involved at all. The Applications themselves disappeared.</p><h3>Scenario: one Git commit deletes production resources</h3><p>media-service stores user uploads on a PVC. The team is migrating it from plain Kustomize manifests to the shared Helm chart, in two PRs. Here&#39;s PR one:</p><pre># apps/media-service/overlays/production/kustomization.yaml<br> resources:<br>   - ../../base/service.yaml<br>   - ../../base/ingress.yaml<br>   - ../../base/configmap.yaml<br>-  - ../../base/deployment.yaml<br>-  - ../../base/pvc.yaml</pre><p>It’s a two-line deletion with a clear description. A reviewer approves it, CI passes because every remaining manifest is valid, and it merges at 14:02.</p><p>At 14:04, Argo CD refreshes, sees two managed resources missing from Git, and prunes them:</p><pre>$ argocd app get media-service-production --show-operation<br>Operation:          Sync<br>Sync Revision:      8c41d7e<br>Phase:              Succeeded<br>Message:            successfully synced (all tasks run)<br><br>GROUP  KIND                   NAMESPACE  NAME           STATUS     HEALTH   MESSAGE<br>       ConfigMap              media      media-service  Synced              configmap/media-service unchanged<br>       Service                media      media-service  Synced     Healthy  service/media-service unchanged<br>apps   Deployment             media      media-service  Succeeded  Pruned   pruned<br>       PersistentVolumeClaim  media      media-uploads  Succeeded  Pruned   pruned<br>networking.k8s.io  Ingress    media      media-service  Synced     Healthy  ingress.networking.k8s.io/media-service unchanged</pre><p>The Deployment is gone, so the Ingress returns 503s. The PVC enters Terminating, its kubernetes.io/pvc-protection finalizer holds on only until the last Pod using it exits, and since the Pods were deleted with the Deployment, that takes seconds. The StorageClass uses reclaimPolicy: Delete, so the EBS CSI driver deletes the volume.</p><p>At 14:19, someone reverts the commit. Argo CD recreates the Deployment and a PVC named media-uploads, but it&#39;s a brand-new, empty 100Gi volume. The application comes back Synced and Healthy. Every user upload is gone, and Argo CD&#39;s dashboard is entirely green.</p><p>Notice what went right: review happened, CI passed, Argo CD worked exactly as designed. Git was simply wrong for two minutes, and automation made that permanent.</p><h3>When Self-Heal Makes Production Incidents Worse</h3><p>Self-heal assumes the cluster is wrong whenever it disagrees with Git. During an incident, that assumption is often backwards.</p><p>A bad image ships, Pods start crash-looping, and the on-call engineer does what years of muscle memory say:</p><pre>kubectl rollout undo deployment/checkout-api -n checkout</pre><p>The rollback works for about five seconds. Self-heal sees the live image differ from Git and reapplies the broken one. The engineer runs it again and gets reverted again. Each cycle burns a few minutes of confusion before someone realises the deployment tool is the thing fighting them.</p><p>The same thing happens with any manual mitigation: scaling up during a traffic spike (when replicas live in Git), patching an env var to disable a broken feature flag, or bumping a memory limit to stop OOM kills. Self-heal can’t tell an emergency fix from configuration drift. It also has no notion of “this desired state is crash-looping, maybe stop.” It will enforce a broken state indefinitely, quickly and reliably.</p><p>Wrong:</p><pre># Fight the controller<br>kubectl rollout undo deployment/checkout-api -n checkout</pre><p>Right:</p><pre># 1. Stop automation for this Application<br>argocd app set checkout-api-production --sync-policy none<br><br># 2. Mitigate in the cluster (now it sticks)<br>kubectl rollout undo deployment/checkout-api -n checkout<br><br># 3. Make Git match reality, then turn automation back on<br>git revert 4e7b9a2 &amp;&amp; git push origin HEAD:refs/heads/revert-checkout-4e7b9a2</pre><p>The fix for a bad commit is always a commit. Manual cluster changes only buy time, and only if automation is paused first.</p><h3>The Dangerous Combination: Auto-Sync + Prune + Self-Heal</h3><p>Each setting is reasonable alone. Together, across many clusters, they remove every pause where a human could notice something is wrong.</p><p>Here’s a hypothetical. Six production clusters each run payments-gateway from the same Kustomize base, through one ApplicationSet with all three settings enabled. An engineer cleans up the base and makes two mistakes in one commit: they rename a ConfigMap key from UPSTREAM_TIMEOUT_MS to UPSTREAM_TIMEOUT without updating the code, and they drop networkpolicy-allow-ingress.yaml from the resources list, believing it&#39;s unused.</p><p>Within three minutes, all six clusters sync. The renamed key makes the gateway fall back to a 0ms timeout default, so every upstream call fails. The pruned NetworkPolicy means a default-deny policy now blocks ingress traffic in four of the clusters. On-call patches the ConfigMap by hand in the first cluster, and self-heal reverts it. They delete the default-deny policy temporarily, and the next sync of a different Application that manages it puts it back.</p><p>No single feature caused this outage. What caused it was the absence of any stage where a change reaches one cluster, gets observed, and only then reaches the rest. Automation removed the delay that used to give humans a chance to catch the mistake, and nothing replaced it.</p><h3>Protecting Critical Resources from Accidental Deletion</h3><p>Argo CD gives you several safeguards. Each one protects against something specific, and none of them protects against everything.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iKSUm4yFbCir6zFG4HluTQ.png" /></figure><p>That last row deserves care. A finalizer doesn’t stop deletion. It postpones it. Once an object has a deletionTimestamp, nothing can cancel it, and reverting the Git commit makes Argo CD try to apply a resource that&#39;s mid-deletion, which fails. pvc-protection only waits for Pods to let go. If you want a hard stop, use an admission policy that rejects the DELETE call in the first place:</p><pre>apiVersion: kyverno.io/v1<br>kind: ClusterPolicy<br>metadata:<br>  name: block-delete-protected-resources<br>spec:<br>  background: false<br>  rules:<br>    - name: deny-delete-protected<br>      match:<br>        any:<br>          - resources:<br>              kinds: [PersistentVolumeClaim, Namespace, StatefulSet]<br>              selector:<br>                matchLabels:<br>                  protection.payflow.io/level: critical<br>              operations: [DELETE]<br>      exclude:<br>        any:<br>          - subjects:<br>              - kind: Group<br>                name: payflow:break-glass<br>      validate:<br>        failureAction: Enforce<br>        message: &quot;This resource is protected. Remove the protection label in a reviewed PR, or use the break-glass group.&quot;</pre><p>Here’s how the media-service scenario plays out with protections in place. The PVC carries Prune=false and Delete=false, plus the Kyverno label:</p><pre>apiVersion: v1<br>kind: PersistentVolumeClaim<br>metadata:<br>  name: media-uploads<br>  namespace: media<br>  labels:<br>    protection.payflow.io/level: critical<br>  annotations:<br>    argocd.argoproj.io/sync-options: Prune=false,Delete=false</pre><p>The production Application adds Prune=confirm to its syncOptions, so the Deployment removal waits for a human who sees &quot;2 resources pending deletion&quot; instead of a silent success. Even if someone confirms without reading, the PVC is skipped by Argo CD, and Kyverno would reject the delete anyway. The worst case drops from &quot;data loss&quot; to &quot;fifteen minutes of 503s.&quot;</p><h3>Designing Safer GitOps Workflows for Production</h3><p>Safeguards on resources catch the last mile. The workflow has to catch the rest.</p><p>The most effective control I’ve added to GitOps repos is a CI check that fails any PR that deletes a production resource without explicit sign-off. Reviewers read values and manifests, but deletions hide in what’s missing from a diff. Render both branches and compare:</p><pre>#!/usr/bin/env bash<br>set -euo pipefail<br><br>list() { yq -N &#39;.kind + &quot;/&quot; + .metadata.namespace + &quot;/&quot; + .metadata.name&#39; &quot;$1&quot; | sort -u; }<br><br>kustomize build &quot;main/apps/${APP}/overlays/production&quot; &gt; rendered-main.yaml<br>kustomize build &quot;pr/apps/${APP}/overlays/production&quot; &gt; rendered-pr.yaml<br><br>removed=$(comm -23 &lt;(list rendered-main.yaml) &lt;(list rendered-pr.yaml))<br><br>if [[ -n &quot;${removed}&quot; ]] &amp;&amp; \<br>   ! gh pr view &quot;${PR_NUMBER}&quot; --json labels -q &#39;.labels[].name&#39; | grep -qx &#39;approved-deletion&#39;; then<br>  echo &quot;This PR removes production resources:&quot;<br>  echo &quot;${removed}&quot;<br>  echo &quot;Add the &#39;approved-deletion&#39; label after SRE review to proceed.&quot;<br>  exit 1<br>fi</pre><p>The media-service PR would have failed with Deployment/media/media-service and PersistentVolumeClaim/media/media-uploads printed in red. That&#39;s a very different review conversation from a two-line diff.</p><p>Around that, add the controls that limit how far a bad commit reaches. CODEOWNERS with branch protection puts production paths and shared bases behind SRE review. AppProject restrictions limit which clusters and resource kinds each project can touch, and clusterResourceBlacklist keeps Namespaces out of application projects entirely. Sync windows block production syncs during hours when nobody&#39;s watching.</p><p>For multi-cluster rollouts, use ApplicationSet Progressive Syncs, so a change reaches staging, then one production cluster, then the rest:</p><pre>spec:<br>  strategy:<br>    type: RollingSync<br>    rollingSync:<br>      steps:<br>        - matchExpressions:<br>            - key: ring<br>              operator: In<br>              values: [staging]<br>        - matchExpressions:<br>            - key: ring<br>              operator: In<br>              values: [production-canary]<br>        - matchExpressions:<br>            - key: ring<br>              operator: In<br>              values: [production]<br>          maxUpdate: 25%</pre><p>Progressive Syncs has to be enabled on the ApplicationSet controller, and it takes over syncing from each generated Application’s automated policy. That trade is exactly what you want. The payments-gateway commit would have broken one canary cluster instead of six.</p><h3>Recovering from a Bad Automated Sync</h3><p>When automation has already done the damage, the order of operations matters more than speed.</p><p>First, stop automation, or every fix you make gets reverted. For a single Application, set the sync policy to none. For Applications generated by an ApplicationSet, there’s a sharp edge: the ApplicationSet controller reverts manual edits to its Applications too. Either change the ApplicationSet itself, or allow sync policy overrides ahead of time:</p><pre>spec:<br>  ignoreApplicationDifferences:<br>    - jsonPointers:<br>        - /spec/syncPolicy</pre><p>In a true multi-cluster emergency, scaling the argocd-application-controller StatefulSet to zero stops all reconciliation everywhere. That&#39;s a blunt break-glass option, so treat it as one. Note also that argocd app rollback refuses to run while automated sync is enabled, which is one more reason to pause first.</p><p>Second, find out exactly what changed. Argo CD’s sync history shows the revision and the pruned resources. The EKS audit logs show every DELETE made by the application controller’s service account. CloudTrail shows whether a DeleteVolume followed:</p><pre>argocd app history media-service-production<br>argocd app get media-service-production --show-operation<br>kubectl get pv | grep -E &#39;Released|Failed&#39;<br>aws cloudtrail lookup-events \<br>  --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteVolume \<br>  --start-time 2026-11-02T08:30:00Z --region ap-south-1</pre><p>Third, revert in Git and sync manually, watching each resource come back. Reverting restores manifests, not data. If the PV was retained, rebind it by clearing its claimRef before the new PVC creates an empty volume. If it wasn&#39;t retained, restore from a VolumeSnapshot or Velero backup into the PVC before the workload starts.</p><p>Fourth, verify beyond Argo CD’s health status. Healthy means the readiness probe passed. Check the things only your application knows: file counts in the uploads bucket, row counts, a synthetic transaction through the real user path. Re-enable automation only after that, and only with the safeguards from this article in place.</p><h3>Production prune and self-heal checklist</h3><ul><li>Stateful resources carry Prune=false,Delete=false and an admission-level delete block</li><li>Production Applications use Prune=confirm or have prune disabled</li><li>allowEmpty stays false (never set it to true in production)</li><li>ApplicationSets use applicationsSync: create-update for production</li><li>StorageClasses for production data use reclaimPolicy: Retain</li><li>CI fails PRs that remove production resources without an approval label</li><li>Multi-cluster changes roll out through Progressive Syncs, not all at once</li><li>The incident runbook says “pause automation” before any manual fix</li><li>ignoreApplicationDifferences allows sync policy overrides during incidents</li><li>Backups are restorable and tested, because a Git revert never brings data back</li></ul><p>GitOps moved the source of truth into Git, and that’s the part teams celebrate. The less obvious consequence is that Git also became the most powerful delete button in your infrastructure, operated by anyone who can merge. Prune and self-heal aren’t the problem. They do exactly what they promise. The problem is assuming that a reconciler that keeps your cluster consistent also keeps it correct. Consistency is Argo CD’s job. Correctness is still yours, and the safeguards are how you get a chance to check it before automation makes a mistake permanent.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=abdc24745cf7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/the-hidden-danger-of-argo-cds-prune-and-self-heal-features-abdc24745cf7">The Hidden Danger of Argo CD’s Prune and Self-Heal Features</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Manage Dev, Staging, and Production in Kubernetes Without Duplicating YAML]]></title>
            <link>https://medium.com/kotaicode/how-to-manage-dev-staging-and-production-in-kubernetes-without-duplicating-yaml-141ce1b499d9?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/141ce1b499d9</guid>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 04:31:02 GMT</pubDate>
            <atom:updated>2026-11-02T04:31:02.759Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A practical guide to managing dev, staging, and production with Helm, Kustomize, and GitOps</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9L7GvCSO4OYiR8ydxbSvjg.png" /></figure><p>Run this in any repository that keeps one folder of manifests per environment:</p><pre>$ diff -rq k8s/staging k8s/production<br>Files k8s/staging/deployment.yaml and k8s/production/deployment.yaml differ<br>Files k8s/staging/configmap.yaml and k8s/production/configmap.yaml differ<br>Files k8s/staging/ingress.yaml and k8s/production/ingress.yaml differ<br>Only in k8s/staging: pdb.yaml<br>Only in k8s/staging: networkpolicy.yaml</pre><p>Some of those differences are supposed to be there. Production runs more replicas and uses a different hostname. Others are accidents: a PodDisruptionBudget and a NetworkPolicy were added to staging and never copied forward. The output can’t tell you which is which, and neither can anyone reviewing the next PR.</p><p>That’s the real cost of copy-pasted YAML. Duplication isn’t the problem by itself. The problem is that intentional differences and accidental drift look identical. This article shows how to run one application across three environments with a single reusable base and small, explicit overrides, using Helm, Kustomize, and Argo CD. The goal is one application, three environments, and zero copy-pasted manifests.</p><h3>Why Multi-Environment Kubernetes Becomes a YAML Nightmare</h3><p>Copying manifests feels cheap on day one. You have a working deployment.yaml for dev, so you copy it to staging/ and production/, change the replica count, and move on.</p><p>The trouble starts with the second change. Someone fixes a readiness probe in production during an incident and forgets dev. Someone adds a sidecar to staging to test it and never promotes it. Six months later the three files share maybe 80% of their content, and nobody knows which 20% is deliberate. Deployments that pass in staging fail in production, because staging was never running the same thing.</p><p>Here’s the service this article deploys, checkout-api, and the only differences it should have between environments:</p><p>Configuration Development Staging Production Replicas 1 2 3 CPU request 100m 250m 500m Memory request 128Mi 256Mi 512Mi Autoscaling Disabled Enabled Enabled Secrets Dev secret store Staging secret store Production secret store Deployment Auto-sync Auto-sync Controlled sync</p><p>Everything not in that table, including probes, security context, labels, ports, and sidecars, should be identical everywhere. The repository structure exists to enforce that.</p><h3>Designing a Scalable Repository Structure</h3><p>The rule I build everything around is that every value lives in exactly one layer. There are four layers:</p><pre>platform-charts/                     # versioned, owned by the platform team<br>  charts/<br>    service/<br><br>platform-gitops/<br>  apps/<br>    checkout-api/<br>      base/<br>        values.yaml                  # identical in every environment<br>      envs/<br>        dev/values.yaml              # only what differs in dev<br>        staging/values.yaml<br>        production/values.yaml<br>  clusters/<br>    eks-dev/cluster-values.yaml      # domain, ingress class, region<br>    eks-staging/cluster-values.yaml<br>    eks-production/cluster-values.yaml<br>  argocd/<br>    applicationsets/checkout-api.yaml<br>    projects/production.yaml</pre><p>The shared chart defines how a service is shaped. The base defines what’s true for checkout-api everywhere. The env file defines how this service differs per environment. The cluster file defines what&#39;s true for every service in a cluster, like the ingress domain. If you&#39;re about to put a value in two places, it belongs one layer up.</p><p>One structural decision deserves an opinion: use directories for environments, not branches. Branch-per-environment (dev, staging, main) looks clean until you need it to work. Promotion becomes a merge that drags along unrelated commits, hotfixes become cherry-picks, and the difference between staging and production is hidden in Git history instead of sitting in two files you can open side by side. With directories, diff envs/staging/values.yaml envs/production/values.yaml tells you everything.</p><p>The structure also has to hold up at fifty services, not just one. Adding a service should mean adding a folder under apps/, nothing more. Cluster-wide changes, like moving every service in staging to a new ingress domain, become a one-line edit in clusters/eks-staging/cluster-values.yaml instead of fifty PRs. If a change at that scale still requires touching every service folder, a value is living in the wrong layer.</p><h3>Helm vs Kustomize: Choosing the Right Approach</h3><p>Both tools solve the same problem from opposite directions. Helm generates YAML from templates and values. Kustomize starts with plain YAML and patches it.</p><p>Helm wins when many services share one shape. One chart, maintained once, renders fifty services, and each service only supplies values. Here’s the base for checkout-api:</p><pre># apps/checkout-api/base/values.yaml<br>image:<br>  repository: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/checkout-api<br>service:<br>  port: 8080<br>probes:<br>  readinessPath: /ready<br>  livenessPath: /healthz<br>config:<br>  LOG_FORMAT: json<br>  PAYMENT_TIMEOUT_MS: &quot;3000&quot;<br>replicaCount: 1<br>resources:<br>  requests:<br>    cpu: 100m<br>    memory: 128Mi<br>  limits:<br>    memory: 256Mi<br>autoscaling:<br>  enabled: false<br>podDisruptionBudget:<br>  enabled: false<br>externalSecret:<br>  enabled: true<br>  key: checkout-api/runtime</pre><p>And here’s everything production adds:</p><pre># apps/checkout-api/envs/production/values.yaml<br>image:<br>  tag: 4.12.3<br>resources:<br>  requests:<br>    cpu: 500m<br>    memory: 512Mi<br>  limits:<br>    memory: 1Gi<br>autoscaling:<br>  enabled: true<br>  minReplicas: 3<br>  maxReplicas: 12<br>  targetCPUUtilizationPercentage: 65<br>podDisruptionBudget:<br>  enabled: true<br>  minAvailable: 2<br>config:<br>  FEATURE_SPLIT_PAYMENTS: &quot;false&quot;</pre><p>The dev file is six lines: an image tag and nothing else, because the base already holds dev’s values.</p><p>Kustomize wins when you don’t own the YAML or don’t want templating at all. The same service as a Kustomize base and overlay looks like this:</p><pre># overlays/production/kustomization.yaml<br>apiVersion: kustomize.config.k8s.io/v1beta1<br>kind: Kustomization<br>namespace: checkout<br>resources:<br>  - ../../base<br>  - pdb.yaml<br>images:<br>  - name: checkout-api<br>    newName: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/checkout-api<br>    newTag: 4.12.3<br>patches:<br>  - path: resources-patch.yaml<br>  - target:<br>      kind: HorizontalPodAutoscaler<br>      name: checkout-api<br>    patch: |-<br>      - op: replace<br>        path: /spec/minReplicas<br>        value: 3<br>      - op: replace<br>        path: /spec/maxReplicas<br>        value: 12</pre><pre># overlays/dev/kustomization.yaml<br>apiVersion: kustomize.config.k8s.io/v1beta1<br>kind: Kustomization<br>namespace: checkout<br>resources:<br>  - ../../base<br>patches:<br>  - patch: |-<br>      $patch: delete<br>      apiVersion: autoscaling/v2<br>      kind: HorizontalPodAutoscaler<br>      metadata:<br>        name: checkout-api</pre><p>My rule of thumb is simple. Use Helm for your own services, because a shared chart is how you stop fifty teams writing fifty Deployments. Use Kustomize for third-party manifests you want to patch without forking, and as a last-mile layer on top of Helm when one environment needs something the chart doesn’t expose. Argo CD can render a Helm chart through Kustomize’s helmCharts field (with --enable-helm), but if you reach for that combination often, your chart is missing a value.</p><p>The case where combining them makes sense is vendor charts. Say the upstream chart for a tool like the AWS Load Balancer Controller doesn’t expose a topologySpreadConstraints value, and production needs one. Forking the chart means owning every future upgrade. A Kustomize patch over the rendered chart is ten lines, survives upgrades, and lives in the production overlay where reviewers expect environment-specific changes.</p><h3>Managing Environment-Specific Configuration</h3><p>Most of the table maps to values directly. Replicas and autoscaling hide the first sharp edge.</p><p>Production has replicaCount: 3 and an HPA. If the chart always renders spec.replicas, Argo CD and the HPA fight. The HPA scales to 8 during a traffic spike, Argo CD sees drift against Git&#39;s 3, and selfHeal scales it back down. That&#39;s 5 Pods gone at peak load, triggered by your deployment tool. With autoscaling on, the replica count belongs to the HPA, and minReplicas: 3 is how production expresses &quot;three replicas.&quot;</p><p>The second sharp edge is conditional logic. Wrong:</p><pre># templates/deployment.yaml<br>spec:<br>{{- if eq .Values.environment &quot;production&quot; }}<br>  replicas: 3<br>{{- else if eq .Values.environment &quot;staging&quot; }}<br>  replicas: 2<br>{{- else }}<br>  replicas: 1<br>{{- end }}</pre><p>Once a chart knows environment names, every new environment means editing templates, and nobody can tell what production gets without reading Go templates. Right:</p><pre># templates/deployment.yaml<br>spec:<br>{{- if not .Values.autoscaling.enabled }}<br>  replicas: {{ .Values.replicaCount }}<br>{{- end }}<br>  template:<br>    metadata:<br>      annotations:<br>        checksum/config: {{ include (print $.Template.BasePath &quot;/configmap.yaml&quot;) . | sha256sum }}</pre><p>The chart asks about capabilities (autoscaling.enabled, podDisruptionBudget.enabled), never about which environment it&#39;s in. The values files decide which capabilities each environment turns on. The checksum/config annotation solves a quieter problem: changing a ConfigMap value doesn&#39;t restart Pods on its own, so without it, a config change reaches production in Git while the Pods keep running the old config. Kustomize avoids this with configMapGenerator, which hashes the ConfigMap name.</p><p>Hostnames come from the cluster layer, not the app:</p><pre># clusters/eks-production/cluster-values.yaml<br>ingress:<br>  className: alb<br>  domain: payflow.io<br>region: ap-south-1</pre><p>The chart builds checkout.payflow.io from the service name and domain, so no service file ever hardcodes a hostname. Feature flags work for a handful of on/off settings in config. Once you&#39;re toggling them per customer or rolling out gradually, move them to a feature-flag service, because a Git commit plus a rollout is a slow kill switch.</p><h3>GitOps for Multi-Environment Deployments</h3><p>One ApplicationSet deploys all three environments, layering the values files in order from least to most specific (later files win):</p><pre>apiVersion: argoproj.io/v1alpha1<br>kind: ApplicationSet<br>metadata:<br>  name: checkout-api<br>  namespace: argocd<br>spec:<br>  goTemplate: true<br>  goTemplateOptions: [&quot;missingkey=error&quot;]<br>  generators:<br>    - list:<br>        elements:<br>          - env: dev<br>            cluster: eks-dev<br>            chartVersion: service-chart-2.5.0<br>            autoSync: &quot;true&quot;<br>          - env: staging<br>            cluster: eks-staging<br>            chartVersion: service-chart-2.4.1<br>            autoSync: &quot;true&quot;<br>          - env: production<br>            cluster: eks-production<br>            chartVersion: service-chart-2.4.1<br>            autoSync: &quot;false&quot;<br>  template:<br>    metadata:<br>      name: &#39;checkout-api-{{.env}}&#39;<br>    spec:<br>      project: &#39;{{.env}}&#39;<br>      sources:<br>        - repoURL: https://github.com/payflow-eng/platform-charts.git<br>          targetRevision: &#39;{{.chartVersion}}&#39;<br>          path: charts/service<br>          helm:<br>            valueFiles:<br>              - $values/apps/checkout-api/base/values.yaml<br>              - $values/clusters/{{.cluster}}/cluster-values.yaml<br>              - $values/apps/checkout-api/envs/{{.env}}/values.yaml<br>        - repoURL: https://github.com/payflow-eng/platform-gitops.git<br>          targetRevision: main<br>          ref: values<br>      destination:<br>        name: &#39;{{.cluster}}&#39;<br>        namespace: checkout<br>      syncPolicy:<br>        syncOptions:<br>          - CreateNamespace=true<br>  templatePatch: |<br>    {{- if eq .autoSync &quot;true&quot; }}<br>    spec:<br>      syncPolicy:<br>        automated:<br>          prune: true<br>          selfHeal: true<br>    {{- end }}</pre><p>Dev and staging sync automatically. Production shows OutOfSync after a merge and waits for someone to press Sync. The chart version is pinned per environment, so a new chart release goes through dev first, exactly like an application release.</p><p>Promotion moves an artifact, not a rebuild. CI builds checkout-api once, tags the image with an immutable version, and commits that tag to envs/dev/values.yaml. Promoting is a PR that copies the tag forward:</p><pre>TAG=$(yq &#39;.image.tag&#39; apps/checkout-api/envs/staging/values.yaml)<br>yq -i &quot;.image.tag = \&quot;${TAG}\&quot;&quot; apps/checkout-api/envs/production/values.yaml<br>git switch -c &quot;promote/checkout-api-${TAG}&quot;<br>git commit -am &quot;Promote checkout-api ${TAG} to production&quot;<br>gh pr create \<br>  --title &quot;Promote checkout-api ${TAG} to production&quot; \<br>  --body &quot;Image verified in staging. Diff limited to image.tag.&quot; \<br>  --reviewer payflow-eng/sre</pre><p>Rollback uses the same mechanism in reverse. Revert the promotion PR, and production shows OutOfSync against the previous tag, ready to sync. There&#39;s no separate rollback procedure to forget during an incident, and the revert sits in Git history next to the change it undid.</p><p>Now for preventing accidental production deployments. CODEOWNERS with branch protection requires SRE approval for anything under apps/*/envs/production/ and clusters/eks-production/. The production AppProject only allows destinations in eks-production, so a mistyped cluster name in a dev element can&#39;t land there. A sync window blocks production syncs over the weekend:</p><pre>apiVersion: argoproj.io/v1alpha1<br>kind: AppProject<br>metadata:<br>  name: production<br>  namespace: argocd<br>spec:<br>  sourceRepos:<br>    - https://github.com/payflow-eng/platform-charts.git<br>    - https://github.com/payflow-eng/platform-gitops.git<br>  destinations:<br>    - name: eks-production<br>      namespace: checkout<br>  syncWindows:<br>    - kind: deny<br>      schedule: &quot;0 18 * * 5&quot;<br>      duration: 62h<br>      timeZone: Asia/Kolkata<br>      applications: [&quot;*&quot;]</pre><p>The sharp edge that bites layered setups is the base file. Promotion copies an image tag, but a change to base/values.yaml reaches every environment in the same merge. Someone bumps PAYMENT_TIMEOUT_MS &quot;for dev,&quot; and production gets it on the next sync along with whatever was actually being promoted. Treat base edits as production changes: put apps/*/base/ under the same CODEOWNERS rule, and when a change needs to be staged, make it in the env files first and move it into base once every environment agrees.</p><h3>Managing Secrets Across Environments</h3><p>No secret values go in Git, encrypted or otherwise. The chart renders an ExternalSecret, and External Secrets Operator pulls the value from AWS Secrets Manager:</p><pre>apiVersion: external-secrets.io/v1<br>kind: ExternalSecret<br>metadata:<br>  name: checkout-api<br>  namespace: checkout<br>spec:<br>  refreshInterval: 1h<br>  secretStoreRef:<br>    kind: ClusterSecretStore<br>    name: aws-secrets<br>  target:<br>    name: checkout-api-secrets<br>  dataFrom:<br>    - extract:<br>        key: checkout-api/runtime</pre><p>Notice the store is called aws-secrets in every environment, and the key carries no environment prefix. Each cluster has its own ClusterSecretStore with that name, and the ESO controller in each cluster authenticates through EKS Pod Identity to a different AWS account (one per environment):</p><pre>apiVersion: external-secrets.io/v1<br>kind: ClusterSecretStore<br>metadata:<br>  name: aws-secrets<br>spec:<br>  provider:<br>    aws:<br>      service: SecretsManager<br>      region: ap-south-1</pre><p>That’s how the “Dev / Staging / Production secret store” rows in the table get enforced: through IAM and account boundaries, not naming. The alternative I often see is one shared account with keys like staging/checkout-api/runtime and production/checkout-api/runtime. In that setup, a one-word typo in a staging values file hands production database credentials to a staging Pod, and nothing stops it. With separate accounts, the staging cluster&#39;s role can&#39;t read production secrets, whatever the YAML says. Vault works the same way, with a separate auth role and policy path per cluster.</p><p>There’s one more sharp edge, and it shows up during the first credential rotation. ESO refreshes the Kubernetes Secret on its refreshInterval, but Pods that read the secret as environment variables keep the old value until they restart. The database rotates its password, the Secret updates, and every running checkout-api Pod keeps using a password that no longer works. Either mount secrets as files and have the application re-read them, or use a controller like Stakater Reloader (secret.reloader.stakater.com/reload: &quot;checkout-api-secrets&quot; on the Deployment) to roll Pods when the Secret changes. Test rotation in staging before production does it for you.</p><h3>Preventing Configuration Drift and Deployment Failures</h3><p>Layered values have one real weakness: reviewers read the input, but the cluster runs the output. A five-line values diff can produce a 200-line manifest change, or silently produce nothing because of a typo in a key name. So CI renders every environment on every PR and validates what comes out:</p><pre>#!/usr/bin/env bash<br>set -euo pipefail<br><br>for env in dev staging production; do<br>  helm template checkout-api ./platform-charts/charts/service \<br>    -f apps/checkout-api/base/values.yaml \<br>    -f &quot;clusters/eks-${env}/cluster-values.yaml&quot; \<br>    -f &quot;apps/checkout-api/envs/${env}/values.yaml&quot; \<br>    &gt; &quot;rendered/${env}.yaml&quot;<br><br>  kubeconform -strict -summary \<br>    -kubernetes-version 1.34.0 \<br>    -schema-location default \<br>    -schema-location &#39;https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json&#39; \<br>    &quot;rendered/${env}.yaml&quot;<br><br>  kyverno apply ./policies --resource &quot;rendered/${env}.yaml&quot;<br>done</pre><p>The same job renders the target branch and posts the diff of rendered manifests per environment as a PR comment. That diff is the thing reviewers should read. A shared values.schema.json in the chart catches the typo case: with additionalProperties: false, autoscalling.enabled: true fails helm template instead of being silently ignored.</p><p>Kyverno policies run twice, in CI against rendered output and at admission time in the cluster, so a kubectl apply from someone&#39;s laptop gets the same rules as a PR.</p><p>Drift detection is where Argo CD earns its place. In dev and staging, selfHeal reverts manual changes within minutes. In production, where sync is manual, drift shows up as OutOfSync, so alert on it before it becomes the new normal:</p><pre>- alert: ProductionAppOutOfSync<br>  expr: argocd_app_info{project=&quot;production&quot;, sync_status=&quot;OutOfSync&quot;} == 1<br>  for: 2h<br>  labels:<br>    severity: warning<br>  annotations:<br>    summary: &quot;{{ $labels.name }} has been out of sync for 2h: unsynced merge or manual change in the cluster&quot;</pre><h3>Multi-environment checklist</h3><ul><li>One base values file per service; env files contain only differences</li><li>Environments are directories, not branches</li><li>Hostnames, domains, and regions live in cluster-level values</li><li>Charts check capabilities (autoscaling.enabled), never environment names</li><li>spec.replicas is omitted whenever an HPA is enabled</li><li>Config changes trigger rollouts through a checksum annotation or configMapGenerator</li><li>Images are built once and promoted by tag</li><li>Chart versions are pinned per environment</li><li>CODEOWNERS covers production env files and base files</li><li>Production syncs manually inside allowed sync windows</li><li>Each environment reads secrets from its own account or store</li><li>CI renders, validates, and diffs every environment on every PR</li><li>Production OutOfSync state alerts after a set time</li></ul><p>Fewer files is a side effect. The real outcome is that every difference between your environments becomes a decision someone made on purpose, in a file small enough to read in ten seconds. When production breaks and staging didn’t, the investigation should start and mostly end with diff envs/staging/values.yaml envs/production/values.yaml. If that command can&#39;t explain the difference, your structure is still hiding something.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=141ce1b499d9" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/how-to-manage-dev-staging-and-production-in-kubernetes-without-duplicating-yaml-141ce1b499d9">How to Manage Dev, Staging, and Production in Kubernetes Without Duplicating YAML</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building an Internal Developer Platform on Top of Kubernetes]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://medium.com/kotaicode/building-an-internal-developer-platform-on-top-of-kubernetes-f6850df98bcb?source=rss----c897b9860971---4"><img src="https://cdn-images-1.medium.com/max/1774/1*41_P50TSTPlNcw-cj9zVfQ.png" width="1774"></a></p><p class="medium-feed-snippet">How to turn Kubernetes into a self-service platform where developers ship services, databases, and dashboards without writing YAML</p><p class="medium-feed-link"><a href="https://medium.com/kotaicode/building-an-internal-developer-platform-on-top-of-kubernetes-f6850df98bcb?source=rss----c897b9860971---4">Continue reading on kotaicode »</a></p></div>]]></description>
            <link>https://medium.com/kotaicode/building-an-internal-developer-platform-on-top-of-kubernetes-f6850df98bcb?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/f6850df98bcb</guid>
            <category><![CDATA[platform-engineering]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 08:31:01 GMT</pubDate>
            <atom:updated>2026-10-30T08:31:01.633Z</atom:updated>
        </item>
        <item>
            <title><![CDATA[HPA vs VPA vs KEDA: Choosing the Right Kubernetes Autoscaling Strategy]]></title>
            <link>https://medium.com/kotaicode/hpa-vs-vpa-vs-keda-choosing-the-right-kubernetes-autoscaling-strategy-c2053023fd2d?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/c2053023fd2d</guid>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 09:01:04 GMT</pubDate>
            <atom:updated>2026-10-28T09:01:03.254Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Why CPU-based autoscaling breaks down for modern workloads, and a practical framework for picking the right scaler</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8K5K7ynpO-2iSvukuKBBEw.png" /></figure><p>Almost every Kubernetes cluster I review has autoscaling configured. Very few have it configured for the right signal. The pattern is nearly always the same: a Deployment, an HPA targeting 70% CPU, and an assumption that the problem is solved.</p><p>That assumption holds for a stateless API whose CPU climbs with traffic. It falls apart once the real demand lives somewhere the kubelet can’t see: a queue, a Kafka topic, a backlog of jobs. This article compares HPA, VPA, and KEDA by what each one measures, how fast it reacts, and where it breaks in production. Installation steps are left out on purpose.</p><p>Here’s the situation that makes the case better than any diagram:</p><pre>$ kubectl get hpa invoice-worker -n billing<br>NAME             REFERENCE                   TARGETS       MINPODS   MAXPODS   REPLICAS   AGE<br>invoice-worker   Deployment/invoice-worker   cpu: 9%/70%   2         30        2          41d<br><br>$ aws sqs get-queue-attributes \<br>    --queue-url https://sqs.ap-south-1.amazonaws.com/123456789012/invoice-generation \<br>    --attribute-names ApproximateNumberOfMessages<br>{<br>    &quot;Attributes&quot;: {<br>        &quot;ApproximateNumberOfMessages&quot;: &quot;38412&quot;<br>    }<br>}</pre><p>Two workers. 9% CPU. Over 38,000 invoices waiting.</p><p>The HPA is working exactly as designed. Each worker spends most of its time waiting on I/O: fetching line items from Postgres, rendering a PDF through another service, uploading to S3. None of that burns CPU. The HPA sees idle pods and concludes there’s nothing to do, while customers wait hours for their invoices.</p><p>CPU tells you how hard your pods are working. It tells you nothing about how much work is waiting for them. That gap is the central argument of this article, and it’s the reason Kubernetes ends up with three autoscalers instead of one.</p><h3>Why Kubernetes Autoscaling Isn’t One-Size-Fits-All</h3><p>Autoscaling answers two different questions. Horizontal scaling asks whether you need more copies of a pod. Vertical scaling asks whether each copy is sized correctly. HPA and KEDA answer the first question. VPA answers the second.</p><p>A second split matters more in practice: resource utilization versus workload demand. Utilization (CPU, memory) is an indirect signal that assumes more work produces more resource usage. For a CPU-bound API, that’s a decent proxy. For I/O-bound workers, stream consumers, and anything that spends its life waiting on a network call, the proxy breaks. Demand signals like queue depth, consumer lag, and requests per second measure the work itself.</p><p>Pick the wrong signal and you pay one of two ways. You overprovision to compensate for an autoscaler that reacts late, or you underprovision and your users absorb the latency. One shows up on the AWS bill. The other shows up in the incident channel.</p><h3>Horizontal Pod Autoscaler (HPA)</h3><p>HPA adjusts the replica count of a Deployment or StatefulSet. Every 15 seconds the controller computes:</p><pre>desiredReplicas = ceil(currentReplicas * currentMetricValue / targetMetricValue)</pre><p>For CPU, the current value is average usage divided by the pod’s CPU request. Not the limit, and not the node’s capacity. Keep that in mind, because it comes back twice later.</p><p>Here’s a production-shaped HPA for an API with explicit scaling behavior:</p><pre>apiVersion: autoscaling/v2<br>kind: HorizontalPodAutoscaler<br>metadata:<br>  name: payment-api<br>  namespace: payments<br>spec:<br>  scaleTargetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: payment-api<br>  minReplicas: 3<br>  maxReplicas: 40<br>  metrics:<br>    - type: Resource<br>      resource:<br>        name: cpu<br>        target:<br>          type: Utilization<br>          averageUtilization: 65<br>    - type: Pods<br>      pods:<br>        metric:<br>          name: http_requests_per_second<br>        target:<br>          type: AverageValue<br>          averageValue: &quot;120&quot;<br>  behavior:<br>    scaleUp:<br>      stabilizationWindowSeconds: 0<br>      policies:<br>        - type: Percent<br>          value: 100<br>          periodSeconds: 30<br>    scaleDown:<br>      stabilizationWindowSeconds: 300<br>      policies:<br>        - type: Pods<br>          value: 2<br>          periodSeconds: 60</pre><p>With multiple metrics, HPA computes a replica count for each one and uses the highest. The http_requests_per_second metric comes from Prometheus through Prometheus Adapter, which exposes it on the custom metrics API. That second metric is what makes this HPA useful. Request rate rises before CPU does, so the API scales before latency degrades.</p><p>The behavior block is where most real HPA tuning happens. This one scales up aggressively (it can double every 30 seconds) and scales down conservatively (two pods per minute, after five minutes of sustained low load). The 300-second scale-down window is the default, but I set it explicitly so nobody reading the manifest has to guess.</p><h3>The sharp edge: HPA silently does nothing without requests</h3><pre>$ kubectl get hpa report-api -n analytics<br>NAME         REFERENCE               TARGETS              MINPODS   MAXPODS   REPLICAS<br>report-api   Deployment/report-api   cpu: &lt;unknown&gt;/70%   2         20        2</pre><p>If any container in the pod lacks a CPU request, HPA can’t compute utilization. It records a FailedGetResourceMetric event and holds the current replica count. No alert fires. The workload just never scales. This is the most common HPA failure I run into, and it&#39;s usually caused by a sidecar someone added without requests.</p><p>HPA has other limits worth knowing. It can’t go below one replica in a standard cluster (the HPAScaleToZero feature gate is still alpha). It acts on metrics that are often 30 to 60 seconds old. And it can&#39;t fix pods that are badly sized: scaling out ten oversized pods gives you ten times the waste.</p><h3>Vertical Pod Autoscaler (VPA)</h3><p>VPA changes the CPU and memory requests of your pods instead of how many there are. It has three components. The recommender watches usage history and computes targets. The updater applies changes to running pods. The admission controller sets requests on new pods as they’re created.</p><p>The update mode decides how aggressive it is. Off only produces recommendations. Initial applies them when pods are created. Recreate evicts pods to apply new values. InPlaceOrRecreate patches running pods through the resize subresource and falls back to eviction when it can&#39;t. The old Auto mode is deprecated and now behaves like Recreate. With in-place pod resize GA since Kubernetes 1.35, the restart penalty that made VPA scary is mostly gone.</p><p>I still start every VPA in Off mode. Here&#39;s one for a memory-heavy search indexer:</p><pre>apiVersion: autoscaling.k8s.io/v1<br>kind: VerticalPodAutoscaler<br>metadata:<br>  name: search-indexer<br>  namespace: search<br>spec:<br>  targetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: search-indexer<br>  updatePolicy:<br>    updateMode: &quot;Off&quot;<br>  resourcePolicy:<br>    containerPolicies:<br>      - containerName: indexer<br>        minAllowed:<br>          cpu: 250m<br>          memory: 1Gi<br>        maxAllowed:<br>          cpu: &quot;4&quot;<br>          memory: 12Gi<br>        controlledValues: RequestsOnly</pre><pre>$ kubectl get vpa search-indexer -n search<br>NAME             MODE   CPU    MEM         PROVIDED   AGE<br>search-indexer   Off    820m   5832103Ki   True       9d</pre><p>The Deployment was requesting 2 CPU and 8Gi per pod. VPA recommends 820m and roughly 5.6Gi. Across 12 replicas, that’s about 14 CPU and 29Gi of reserved capacity freed up, and I got the number without touching a running pod.</p><h3>The sharp edge: VPA doesn’t understand your runtime</h3><p>VPA sees container memory, not what’s happening inside the process. A JVM started with -Xmx6g will never use much more than 6Gi of heap, whatever request VPA sets. Meanwhile, a runtime that grows into available memory will expand to fill a bigger request, and VPA reads that expansion as real need. If your runtime manages its own memory ceiling, tie the ceiling to the container (for the JVM, -XX:MaxRAMPercentage=75 instead of a fixed -Xmx) so VPA&#39;s changes mean something.</p><p>Disruption is the other concern. In Recreate mode, a two-replica Deployment can lose half its capacity while a pod reschedules. Pair every active VPA with a PodDisruptionBudget. InPlaceOrRecreate avoids the restart in most cases, but when the node has no room to grow the pod, it still evicts.</p><p>Vertical scaling is the better choice for workloads that can’t scale out: singletons, leader-elected controllers, stateful services, and memory-bound apps where the per-pod footprint is the actual problem.</p><h3>KEDA: Event-Driven Autoscaling</h3><p>KEDA scales on the work that’s waiting to be processed. It ships with scalers for SQS, Kafka, RabbitMQ, Prometheus, Redis, cron, and dozens of other sources. It also doesn’t replace HPA. When you create a ScaledObject, KEDA creates and manages an HPA for you and feeds it the external metric through its own metrics server. KEDA handles the transition between zero and one replica itself, and HPA handles everything from one to N.</p><p>Here’s the fix for the invoice worker from the opening:</p><pre>apiVersion: keda.sh/v1alpha1<br>kind: TriggerAuthentication<br>metadata:<br>  name: aws-pod-identity<br>  namespace: billing<br>spec:<br>  podIdentity:<br>    provider: aws<br>---<br>apiVersion: keda.sh/v1alpha1<br>kind: ScaledObject<br>metadata:<br>  name: invoice-worker<br>  namespace: billing<br>spec:<br>  scaleTargetRef:<br>    name: invoice-worker<br>  minReplicaCount: 0<br>  maxReplicaCount: 40<br>  pollingInterval: 15<br>  cooldownPeriod: 300<br>  triggers:<br>    - type: aws-sqs-queue<br>      authenticationRef:<br>        name: aws-pod-identity<br>      metadata:<br>        queueURL: https://sqs.ap-south-1.amazonaws.com/123456789012/invoice-generation<br>        queueLength: &quot;50&quot;<br>        awsRegion: ap-south-1</pre><p>queueLength: &quot;50&quot; means one replica per 50 messages. With 38,412 messages, KEDA asks for 769 replicas and the HPA caps it at 40. Once the queue drains and stays empty for the cooldown period, the Deployment scales to zero and costs nothing.</p><p>Kafka consumers work the same way, scaling on consumer group lag:</p><pre>triggers:<br>    - type: kafka<br>      metadata:<br>        bootstrapServers: b-1.orders-msk.x7k2qp.c3.kafka.ap-south-1.amazonaws.com:9092<br>        consumerGroup: order-enrichment<br>        topic: orders.created<br>        lagThreshold: &quot;500&quot;</pre><p>For work that has to run to completion, like video transcoding or report generation, use a ScaledJob. It creates a Kubernetes Job per batch of events instead of scaling a long-running Deployment. A pod that gets scaled down mid-transcode loses its work. A Job runs until it finishes.</p><h3>The sharp edges</h3><p>Kafka first. By default, KEDA won’t scale consumers beyond the topic’s partition count, because extra consumers in a group sit idle. If orders.created has 12 partitions, your real maximum is 12 regardless of maxReplicaCount. Partition count is a scaling decision, so make it before you need it.</p><p>SQS has two. The scaler counts in-flight messages by default (scaleOnInFlight), which keeps workers alive while they finish their current batch. That part works in your favor. The dangerous part is scale-down: a pod receives SIGTERM while it may still be holding messages. If terminationGracePeriodSeconds is shorter than your processing time, those messages reappear after the visibility timeout and get processed twice. Handle SIGTERM properly and make your workers idempotent.</p><p>Scale-to-zero also means cold starts. The first message after an idle period waits for the polling interval, pod scheduling, image pull, and application startup. For an internal batch queue, that’s fine. For anything a user is actively waiting on, keep minReplicaCount: 1.</p><h3>HPA vs VPA vs KEDA: Side-by-Side Comparison</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uBcYFcoEa3rp3J7J97EIGg.png" /></figure><p>And here’s how that maps to real workloads:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oo_Sk4QxYOFt2F1G_4jMtg.png" /></figure><h3>Can You Combine HPA, VPA, and KEDA?</h3><p>Yes, with one rule: never let two autoscalers act on the same signal.</p><p>The classic conflict is an active VPA plus an HPA on CPU. HPA calculates utilization as usage divided by request. VPA raises the request, utilization drops, and HPA scales in. Fewer pods means more load per pod, so VPA raises requests again. The two controllers chase each other until you’re running a handful of oversized pods.</p><p>Wrong:</p><pre># HPA already scales payment-api on CPU utilization,<br># and this VPA rewrites CPU requests on the same pods<br>spec:<br>  targetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: payment-api<br>  updatePolicy:<br>    updateMode: &quot;InPlaceOrRecreate&quot;<br>  resourcePolicy:<br>    containerPolicies:<br>      - containerName: payment-api<br>        controlledResources: [&quot;cpu&quot;, &quot;memory&quot;]</pre><p>Right:</p><pre># HPA owns replica count, VPA only recommends<br>spec:<br>  targetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: payment-api<br>  updatePolicy:<br>    updateMode: &quot;Off&quot;</pre><p>If you want VPA to act automatically, restrict it to the resource HPA doesn’t scale on, for example controlledResources: [&quot;memory&quot;] while HPA scales on CPU or request rate. VPA in Off mode next to HPA or KEDA is the combination I use most. Replicas follow demand, and I review the recommendations when tuning requests each month.</p><p>KEDA plus a hand-written HPA on the same Deployment is a hard no. KEDA already creates an HPA, so adding your own puts two controllers in a fight over spec.replicas. If you need CPU alongside queue depth, add a cpu trigger to the ScaledObject instead.</p><p>None of this matters if new pods have nowhere to land. Pod autoscalers create Pending pods. Cluster Autoscaler or Karpenter turns those Pending pods into nodes. Karpenter usually brings a node up in under a minute, which still means your real scale-up latency is HPA reaction plus node provisioning plus image pull. For bursty workloads, run a small buffer of low-priority placeholder pods that get preempted the moment real pods need the space.</p><h3>Production Autoscaling Mistakes and Best Practices</h3><p><strong>Missing resource requests.</strong> HPA shows &lt;unknown&gt; and freezes, as covered earlier. Enforce requests at admission time with Kyverno or a LimitRange default so a new sidecar can&#39;t quietly break scaling.</p><p><strong>CPU throttling.</strong> A pod with a tight CPU limit gets throttled by the CFS scheduler during bursts. Measured CPU flattens at the limit, so the HPA sees moderate utilization while p99 latency triples. For latency-sensitive services, drop CPU limits or scale on request rate or latency instead.</p><p><strong>Scaling oscillations.</strong> Replicas bouncing between 4 and 12 every few minutes means scale-down is too eager. Use the scale-down stabilization window and a rate-limiting policy, as in the payment-api example.</p><p><strong>Metric delays.</strong> metrics-server, the HPA sync loop, and Prometheus scrape intervals all add latency. A traffic spike can be a minute old before a single pod is requested. For predictable peaks like a 9 AM batch or a scheduled sale, scale ahead of time with a KEDA cron trigger.</p><p><strong>Insufficient node capacity.</strong> maxReplicas: 50 means nothing if your node group tops out at six nodes. Check that Karpenter NodePool limits or your ASG maximum can fit the max replicas of every scaled workload at once.</p><p><strong>Scaling faster than downstream services.</strong> This is the one that causes real outages. Workers with a 10-connection pool, scaled to 60 replicas, open 600 connections against a Postgres instance configured for 400. The autoscaler did its job and took the database down with it. Derive maxReplicaCount from downstream capacity, not from what the cluster can hold, and put RDS Proxy or PgBouncer in front of the database.</p><h3>Production autoscaling checklist</h3><ul><li>Every container, sidecars included, has CPU and memory requests</li><li>HPA scale-down has an explicit stabilization window</li><li>Queue and stream workers scale on backlog, not CPU</li><li>No Deployment has both a KEDA ScaledObject and a hand-written HPA</li><li>VPA runs in Off mode, or controls only resources HPA doesn&#39;t scale on</li><li>PodDisruptionBudgets exist for every workload an active VPA can evict</li><li>Max replicas are derived from downstream limits (database connections, third-party rate limits)</li><li>Node autoscaler limits can fit max replicas across all scaled workloads</li><li>Workers handle SIGTERM and process messages idempotently</li></ul><h3>A Practical Decision Framework</h3><p>When I’m picking an autoscaler for a new workload, it comes down to three questions.</p><p>Need more replicas when load rises, and that load shows up in CPU or request rate? → HPA.</p><p>Need better CPU and memory sizing, or a workload that can’t scale horizontally? → VPA, starting in Off mode.</p><p>Need to scale on work waiting outside the pod, or scale to zero when there’s nothing to do? → KEDA.</p><p>Most real systems end up using two of them. The API gets HPA, the workers behind it get KEDA, and VPA quietly produces recommendations for both.</p><p>Nearly every autoscaling incident I’ve debugged traced back to a mismatch between the signal and the workload. The controllers did exactly what they were told. So before choosing between HPA, VPA, and KEDA, figure out what your users are actually waiting on. If it’s CPU cycles, HPA has you covered. If it’s a message sitting in a queue, CPU will never tell you, and the right autoscaler is the one that can see the queue.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c2053023fd2d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/hpa-vs-vpa-vs-keda-choosing-the-right-kubernetes-autoscaling-strategy-c2053023fd2d">HPA vs VPA vs KEDA: Choosing the Right Kubernetes Autoscaling Strategy</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How Attackers Exploit Over-Permissive Kubernetes ServiceAccounts]]></title>
            <link>https://medium.com/kotaicode/how-attackers-exploit-over-permissive-kubernetes-serviceaccounts-ea2f0094c7e6?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/ea2f0094c7e6</guid>
            <category><![CDATA[cloud-security]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Tue, 27 Oct 2026 07:31:01 GMT</pubDate>
            <atom:updated>2026-10-27T07:31:01.676Z</atom:updated>
            <content:encoded><![CDATA[<h4>From one vulnerable pod to cluster-admin: the attack path most teams leave wide open</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TO8rI-K5yvT5O64MyfVNbA.png" /></figure><p>A container compromise is not a cluster compromise. Not automatically. The distance between those two outcomes is determined almost entirely by what the compromised pod’s ServiceAccount is allowed to do.</p><p>Most Kubernetes clusters have at least one workload running with more permissions than it needs. Sometimes it’s a leftover from a debugging session. Sometimes it’s a ServiceAccount that was granted wildcard access because a developer needed something to work quickly and never revisited it. Either way, the result is the same: an attacker who gains code execution in that pod inherits every permission that ServiceAccount holds.</p><p>This article follows the attacker’s path from a single compromised application pod to cluster-wide access, and covers exactly what stops that path at each stage.</p><h3>Why Kubernetes ServiceAccounts Are a Security Risk</h3><p>Every pod in Kubernetes runs as a ServiceAccount. If you don’t specify one, the pod uses the default ServiceAccount in its namespace. The ServiceAccount&#39;s credentials are automatically mounted into the pod at /var/run/secrets/kubernetes.io/serviceaccount/token.</p><p>That token is a signed JWT. Any process running inside the pod can read it with a single command:</p><pre>cat /var/run/secrets/kubernetes.io/serviceaccount/token</pre><p>The token authenticates to the Kubernetes API server. What the API server then allows depends on the RBAC permissions bound to that ServiceAccount. This is the critical point: the token is not just a pod identity. It’s a key to the Kubernetes API, scoped to whatever permissions the ServiceAccount has been granted.</p><p>Kubernetes 1.22 introduced projected service account tokens with bounded lifetime and audience restrictions, which is an improvement over the old static tokens. But projected tokens are still automatically mounted into pods by default and still authenticate to the API server. The risk is in the permissions, not the token format.</p><h3>How Attackers Gain Access to ServiceAccount Tokens</h3><p>The most common path to token theft is not a sophisticated exploit. It’s application vulnerability.</p><p>A remote code execution vulnerability in a web application. A server-side request forgery that reads local files. A dependency with a known CVE that an attacker can reach. Any of these can give an attacker command execution inside the container, and from there the token is a single file read away.</p><pre># From inside a compromised container:<br>TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)<br>NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)<br>CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt<br><br># Test API access<br>curl --cacert $CACERT \<br>  -H &quot;Authorization: Bearer $TOKEN&quot; \<br>  https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods</pre><p>The Kubernetes API is reachable from inside every pod via the kubernetes.default.svc DNS name. No special network access required. No firewall to bypass. The API endpoint is designed to be reachable from within the cluster.</p><p>Other token theft vectors include: misconfigured logging that captures environment variables, application code that reads and logs the token file, backup processes that snapshot the filesystem, and CI/CD pipelines that run containers with elevated permissions.</p><h3>From a Compromised Pod to Kubernetes API Access</h3><p>Once an attacker has the token, they have authenticated API access as that ServiceAccount. What they can do next is entirely determined by RBAC.</p><p>The attacker’s first move is reconnaissance: discover what permissions the compromised ServiceAccount has.</p><pre># List all permissions for the current ServiceAccount<br>kubectl auth can-i --list --namespace=production<br># Or using the token directly from inside the pod:<br>curl --cacert $CACERT \<br>  -H &quot;Authorization: Bearer $TOKEN&quot; \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;kind&quot;:&quot;SelfSubjectRulesReview&quot;,&quot;apiVersion&quot;:&quot;authorization.k8s.io/v1&quot;,&quot;spec&quot;:{&quot;namespace&quot;:&quot;production&quot;}}&#39; \<br>  https://kubernetes.default.svc/apis/authorization.k8s.io/v1/selfsubjectrulesreviews</pre><p>This call returns every resource and verb the ServiceAccount can access. It’s the same call kubectl auth can-i --list makes under the hood. It&#39;s not audited as a suspicious operation by default in most clusters.</p><p>With a minimal-permission ServiceAccount, the attacker sees a short list of read-only operations. With an over-permissive one, they see a path forward.</p><h3>Privilege Escalation Through Misconfigured RBAC</h3><p>Certain permissions are significantly more dangerous than they appear in a YAML file.</p><p><strong>create pods</strong> lets an attacker schedule a new pod with any ServiceAccount in the namespace, including the most privileged one:</p><pre># Attacker-controlled pod spec, submitted via the API<br>apiVersion: v1<br>kind: Pod<br>metadata:<br>  name: attacker-pod<br>  namespace: production<br>spec:<br>  serviceAccountName: payments-admin  # highest-privilege SA in the namespace<br>  containers:<br>    - name: shell<br>      image: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/busybox:latest<br>      command: [&quot;sleep&quot;, &quot;infinity&quot;]</pre><p>Once this pod runs, the attacker execs into it and now has the payments-admin token. One permission (create pods) escalated to full namespace admin access.</p><p><strong>get secrets</strong> exposes every Secret in scope. Database passwords. API keys. TLS certificates. Other ServiceAccount tokens. The attacker doesn&#39;t need to escalate to cluster-admin if the secrets give them what they need directly.</p><p><strong>bind and </strong><strong>escalate</strong> are the most dangerous verbs almost nobody explicitly audits. bind lets the holder create RoleBindings and ClusterRoleBindings, meaning they can grant any permission to any subject, including themselves. escalate lets the holder update Roles and ClusterRoles to add permissions. Both are paths to cluster-admin without touching any admin credential.</p><p><strong>Wildcard access</strong> (verbs: [&quot;*&quot;], resources: [&quot;*&quot;]) is the permission that makes cluster takeover trivial. This pattern appears more than it should, usually in internal tooling ServiceAccounts or operators that were given broad access during development.</p><pre># What you should never see on a production ServiceAccount<br>rules:<br>  - apiGroups: [&quot;*&quot;]<br>    resources: [&quot;*&quot;]<br>    verbs: [&quot;*&quot;]</pre><h3>Lateral Movement and Cluster Takeover</h3><p>With list pods and exec permissions, an attacker can move laterally across the namespace. With list secrets, they can harvest credentials from other workloads. With cross-namespace ClusterRoleBindings, they can reach beyond the namespace they started in.</p><p>The cluster-admin path typically looks like this:</p><p>A compromised pod has a ServiceAccount with create pods in the kube-system namespace, or the ability to bind ClusterRoles. The attacker creates a pod in kube-system mounted with the cluster-admin ServiceAccount, or creates a ClusterRoleBinding that grants cluster-admin to the compromised ServiceAccount. From that point, the attacker can read every Secret in every namespace, modify any resource, delete any workload, and add their own backdoor ServiceAccount.</p><pre># From inside a pod with &#39;bind&#39; permission:<br># Create a ClusterRoleBinding granting cluster-admin to the compromised SA<br>curl --cacert $CACERT \<br>  -H &quot;Authorization: Bearer $TOKEN&quot; \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -X POST \<br>  -d &#39;{<br>    &quot;apiVersion&quot;: &quot;rbac.authorization.k8s.io/v1&quot;,<br>    &quot;kind&quot;: &quot;ClusterRoleBinding&quot;,<br>    &quot;metadata&quot;: {&quot;name&quot;: &quot;attacker-admin&quot;},<br>    &quot;subjects&quot;: [{&quot;kind&quot;: &quot;ServiceAccount&quot;, &quot;name&quot;: &quot;payment-worker&quot;, &quot;namespace&quot;: &quot;production&quot;}],<br>    &quot;roleRef&quot;: {&quot;kind&quot;: &quot;ClusterRole&quot;, &quot;name&quot;: &quot;cluster-admin&quot;, &quot;apiGroup&quot;: &quot;rbac.authorization.k8s.io&quot;}<br>  }&#39; \<br>  https://kubernetes.default.svc/apis/rbac.authorization.k8s.io/v1/clusterrolebindings</pre><p>This is why the bind verb requires the same level of scrutiny as cluster-admin itself.</p><h3>Real-World Attack Scenario</h3><p>A payment processing service runs in the production namespace. The application has a dependency with a known path traversal vulnerability. The ServiceAccount it runs as was given broad permissions during a debugging incident three months ago and never tightened.</p><p>The attacker sends a crafted request that exploits the path traversal to read /var/run/secrets/kubernetes.io/serviceaccount/token. With that token, they check permissions and find get secrets, create pods, and list on everything in the namespace.</p><p>They read the database Secret directly:</p><pre>curl --cacert $CACERT \<br>  -H &quot;Authorization: Bearer $TOKEN&quot; \<br>  https://kubernetes.default.svc/api/v1/namespaces/production/secrets/postgres-credentials</pre><p>They create a new pod using a privileged ServiceAccount:</p><pre># Create pod with the ops-admin ServiceAccount<br>curl --cacert $CACERT -H &quot;Authorization: Bearer $TOKEN&quot; \<br>  -H &quot;Content-Type: application/json&quot; -X POST \<br>  -d &#39;{&quot;apiVersion&quot;:&quot;v1&quot;,&quot;kind&quot;:&quot;Pod&quot;,&quot;metadata&quot;:{&quot;name&quot;:&quot;ops-shell&quot;,&quot;namespace&quot;:&quot;production&quot;},&quot;spec&quot;:{&quot;serviceAccountName&quot;:&quot;ops-admin&quot;,&quot;containers&quot;:[{&quot;name&quot;:&quot;shell&quot;,&quot;image&quot;:&quot;123456789012.dkr.ecr.ap-south-1.amazonaws.com/busybox:latest&quot;,&quot;command&quot;:[&quot;sleep&quot;,&quot;infinity&quot;]}]}}&#39; \<br>  https://kubernetes.default.svc/api/v1/namespaces/production/pods</pre><p>They exec into the new pod (using the escalated token from the first pod to call the exec API), and now have ops-admin permissions. From there, if ops-admin has bind or cluster-scoped access, the path to cluster-admin is open.</p><p><strong>What stops this path:</strong> a ServiceAccount that can only read ConfigMaps in its own namespace can’t do any of this. The application gets compromised the same way, but the blast radius is the contents of those ConfigMaps, not the entire cluster.</p><h3>Detecting and Preventing ServiceAccount Abuse</h3><p><strong>Disable automatic token mounting on workloads that don’t need Kubernetes API access:</strong></p><pre>apiVersion: v1<br>kind: ServiceAccount<br>metadata:<br>  name: payment-service<br>  namespace: production<br>automountServiceAccountToken: false</pre><p>Most application workloads don’t call the Kubernetes API. They connect to databases and external services. They have no legitimate reason to have a Kubernetes API token mounted. Disabling it means there’s nothing to steal.</p><p><strong>Create a dedicated ServiceAccount per workload:</strong></p><pre>apiVersion: v1<br>kind: ServiceAccount<br>metadata:<br>  name: payment-service<br>  namespace: production<br>---<br>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: payment-service<br>spec:<br>  template:<br>    spec:<br>      serviceAccountName: payment-service</pre><p>Never share ServiceAccounts across workloads. A shared ServiceAccount means a compromise of any of those workloads exposes the permissions of all of them.</p><p><strong>Apply least-privilege RBAC with namespace scope:</strong></p><pre>apiVersion: rbac.authorization.k8s.io/v1<br>kind: Role<br>metadata:<br>  name: payment-service-role<br>  namespace: production<br>rules:<br>  - apiGroups: [&quot;&quot;]<br>    resources: [&quot;configmaps&quot;]<br>    verbs: [&quot;get&quot;, &quot;list&quot;]<br>    resourceNames: [&quot;payment-service-config&quot;]</pre><p>Scope to specific resourceNames where possible. A ServiceAccount that can only get one specific ConfigMap by name cannot be used to enumerate other ConfigMaps or read Secrets.</p><p><strong>Audit current permissions regularly:</strong></p><pre># Check what every ServiceAccount in a namespace can do<br>kubectl auth can-i --list --as=system:serviceaccount:production:payment-service -n production<br><br># Install kubectl-who-can for reverse lookups<br>kubectl who-can get secrets -n production<br>kubectl who-can create pods -n production<br><br># Find ServiceAccounts with dangerous permissions<br>kubectl get clusterrolebindings -o json | \<br>  jq &#39;.items[] | select(.roleRef.name==&quot;cluster-admin&quot;) | .subjects[]&#39;</pre><p><strong>Enable and monitor Kubernetes audit logs.</strong> Suspicious patterns to alert on: selfsubjectrulesreviews calls (RBAC enumeration), unexpected get secrets calls, pod creation from non-CI namespaces, ClusterRoleBinding creation outside GitOps workflows.</p><h3>Configuration Comparison</h3><p>Configuration Over-permissive Least privilege RBAC permissions Wildcard * Explicit resources and verbs Scope Cluster-wide Namespace-scoped Token mounting Automatically mounted Disabled unless required ServiceAccount Shared across workloads Dedicated per workload Security impact Potential cluster-wide compromise Contained blast radius</p><p>A container compromise is a bad day. An over-permissive ServiceAccount turns it into a cluster incident. The RBAC configuration you set when a workload is first deployed determines which outcome you get, usually months before anyone realizes it matters. Review ServiceAccount permissions the same way you review firewall rules: with the assumption that something will eventually get through, and with the goal of ensuring that when it does, the damage stops there.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ea2f0094c7e6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/how-attackers-exploit-over-permissive-kubernetes-serviceaccounts-ea2f0094c7e6">How Attackers Exploit Over-Permissive Kubernetes ServiceAccounts</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your Docker Image Works on Your Mac. CI Says Otherwise.]]></title>
            <link>https://medium.com/kotaicode/your-docker-image-works-on-your-mac-ci-says-otherwise-de478737ac67?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/de478737ac67</guid>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[containers]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 10:11:01 GMT</pubDate>
            <atom:updated>2026-10-26T10:11:01.782Z</atom:updated>
            <content:encoded><![CDATA[<h4>The image format is portable. The binaries inside it are not.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GgYtEqXKYdYGuHXw9dU-FA.png" /></figure><p>It built on your M2. It ran on your M2. Every test passed on your M2. Then the integration job on GitHub Actions pulls the same tag and dies before printing a single log line:</p><pre>exec /usr/local/bin/payment-service: exec format error</pre><p>Nothing is wrong with the code. The image contains a binary compiled for ARM64, and the CI runner is an AMD64 machine that can’t execute it. Docker images are portable as a packaging format, but the binaries inside them are tied to one CPU architecture, and Apple Silicon has made that gap easy to fall into. This article covers how Docker picks an architecture, why your Mac hides the problem, how to build multi-architecture images with Buildx, QEMU versus native builds, a working GitHub Actions setup, and the mistakes that still break multi-arch images after you think you’ve fixed them.</p><h3>Why Docker Works Locally but Fails in CI</h3><p>An M1, M2, or M3 Mac is an ARM64 machine. When you run docker build without flags, Docker builds for the platform it&#39;s running on, so the image you push is linux/arm64. GitHub&#39;s standard ubuntu-24.04 runners, most EKS node groups, and most EC2 fleets are linux/amd64.</p><p>Here’s the full failure, starting on the Mac:</p><pre>$ uname -m<br>arm64<br><br>$ docker build -t 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.0 .<br>$ docker image inspect --format &#39;{{.Os}}/{{.Architecture}}&#39; \<br>    123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.0<br>linux/arm64<br><br>$ docker push 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.0</pre><p>And on the AMD64 runner:</p><pre>$ docker run --rm 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.0 --version<br>WARNING: The requested image&#39;s platform (linux/arm64) does not match the detected host platform (linux/amd64/v3) and no specific platform was requested<br>exec /usr/local/bin/payment-service: exec format error</pre><p>Docker even warns you, but in a busy CI log the warning scrolls past and only the error gets read. Depending on your Docker version and image store, the pull can fail earlier instead, with no matching manifest for linux/amd64 in the manifest list entries. Different message, same cause.</p><h3>Understanding Docker Image Architectures</h3><p>A single-platform image has one manifest: a config plus a list of layers, built for one OS and CPU architecture. A multi-platform image adds an index (also called a manifest list) on top. The index is a small JSON document that points to one manifest per platform.</p><p>When a client pulls a tag, the registry returns whatever the tag points to. If it’s an index, Docker picks the entry matching the host’s platform, linux/amd64 or linux/arm64, and pulls only that image&#39;s layers. If it&#39;s a single manifest, Docker pulls it regardless of architecture and finds out at runtime.</p><p>You can see which kind you have without pulling anything:</p><pre>docker buildx imagetools inspect node:20-bookworm-slim</pre><p>Official images like node, python, and golang publish indexes with a dozen platforms. Your own images publish whatever you built, which on a Mac is usually only linux/arm64.</p><h3>How Apple Silicon Hides Architecture Problems</h3><p>Docker Desktop on Apple Silicon can run AMD64 containers through Rosetta 2 or QEMU emulation. That’s convenient, and it’s also why architecture bugs survive so long. If a base image or vendor image you depend on only ships linux/amd64, it still runs on your Mac, just more slowly, and you never notice it has no ARM64 build.</p><p>The reverse is the case that hurts in CI. Your Mac builds ARM64 by default, and an AMD64 Linux runner has no emulation set up unless you install it. So the Mac runs both architectures, while CI runs only one.</p><p>The sharp edge here is a “fix” that spreads through teams: adding export DOCKER_DEFAULT_PLATFORM=linux/amd64 to everyone&#39;s shell profile. Builds now produce AMD64 images that work in CI. They also run every container on the Mac under emulation, make local builds several times slower, and hide whether your stack works on ARM64 at all, which matters the day someone suggests moving to Graviton.</p><h3>Building Cross-Platform Images with Docker Buildx</h3><p>Buildx is Docker’s BuildKit-based build CLI, and --platform tells it which architectures to target. To build more than one at a time, you need a builder that supports it. The docker-container driver always does:</p><pre>docker buildx create --name multiarch --driver docker-container --use<br>docker buildx inspect --bootstrap</pre><p>The interesting part is the Dockerfile. BuildKit gives every build a set of automatic arguments. BUILDPLATFORM is the machine doing the build. TARGETPLATFORM is the platform the image is for, with TARGETOS and TARGETARCH as its parts. For compiled languages, the fastest pattern is to run the compiler natively on the build platform and cross-compile for the target:</p><pre># syntax=docker/dockerfile:1.7<br><br>FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS build<br>ARG TARGETOS TARGETARCH<br>WORKDIR /src<br>COPY go.mod go.sum ./<br>RUN --mount=type=cache,target=/go/pkg/mod go mod download<br>COPY . .<br>RUN --mount=type=cache,target=/go/pkg/mod \<br>    --mount=type=cache,target=/root/.cache/go-build \<br>    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \<br>    go build -ldflags=&quot;-s -w&quot; -o /out/payment-service ./cmd/payment-service<br><br>FROM gcr.io/distroless/static-debian12:nonroot<br>ARG TARGETARCH<br>ADD --chmod=755 \<br>    https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.4.28/grpc_health_probe-linux-${TARGETARCH} \<br>    /usr/local/bin/grpc_health_probe<br>COPY --from=build /out/payment-service /usr/local/bin/payment-service<br>EXPOSE 8080 9090<br>ENTRYPOINT [&quot;/usr/local/bin/payment-service&quot;]</pre><p>The build stage always runs on your machine’s own architecture, so Go compiles at full speed. The final stage has no RUN steps, so nothing in it ever needs to execute during the build. And the health probe download uses TARGETARCH instead of a hardcoded architecture, which is the detail most Dockerfiles get wrong (more on that below).</p><h3>QEMU Emulation vs Native Builds</h3><p>When a build stage has to run commands for the target architecture, such as apt-get install, npm ci with native modules, or pip install with C extensions, BuildKit runs them under QEMU emulation. QEMU translates every instruction of the foreign architecture, so it works for almost anything, but compile-heavy steps can run many times slower than they do natively.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*blDwW7KWaTxFYoWpXa3QRg.png" /><figcaption>Comparing cross-compilation, QEMU emulation, and native runners for multi-arch builds</figcaption></figure><p>My rule is to cross-compile whenever the toolchain supports it (Go, Rust with the right targets, Java, plain Node.js without native modules), use QEMU for small amounts of target-platform work, and switch to native runners when emulated steps start dominating build time. GitHub now offers hosted ARM64 Linux runners (ubuntu-24.04-arm), so native builds no longer require managing your own ARM machines.</p><h3>Creating Multi-Architecture Docker Images</h3><p>With the Dockerfile above, one command builds both architectures and pushes a single tag that works everywhere:</p><pre>docker buildx build \<br>  --builder multiarch \<br>  --platform linux/amd64,linux/arm64 \<br>  -t 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1 \<br>  --push .</pre><p>Then confirm what the registry actually holds:</p><pre>$ docker buildx imagetools inspect 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1<br>Name:      123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1<br>MediaType: application/vnd.oci.image.index.v1+json<br>Digest:    sha256:9b2e41c7d0a35f8e6b1c4a2d7e90f3b8c5a1d6e2f4b7c9a0e3d5f1b8c2a4e6d9<br><br>Manifests:<br>  Name:      123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1@sha256:3c7f...<br>  MediaType: application/vnd.oci.image.manifest.v1+json<br>  Platform:  linux/amd64<br><br>  Name:      123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1@sha256:a81d...<br>  MediaType: application/vnd.oci.image.manifest.v1+json<br>  Platform:  linux/arm64<br><br>  Name:      123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service:1.8.1@sha256:5e02...<br>  MediaType: application/vnd.oci.image.manifest.v1+json<br>  Platform:  unknown/unknown</pre><p>The unknown/unknown entry is a provenance attestation that Buildx attaches by default, so it&#39;s expected.</p><p>The sharp edge is --load. People replace --push with --load to test locally and get docker exporter does not currently support exporting manifest lists. The classic Docker image store can only hold one platform per tag. Either enable the containerd image store in Docker Desktop, which can store multi-platform images, or load one platform at a time with --platform linux/arm64 --load.</p><h3>Fixing Architecture Issues in CI/CD</h3><p>Here’s a GitHub Actions workflow that builds both architectures on a standard runner, pushes to ECR with a registry cache, and smoke-tests both images before anything deploys:</p><pre># .github/workflows/build-payment-service.yml<br>name: build-payment-service<br>on:<br>  push:<br>    tags: [&quot;v*&quot;]<br><br>permissions:<br>  id-token: write<br>  contents: read<br><br>env:<br>  IMAGE: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service<br><br>jobs:<br>  build:<br>    runs-on: ubuntu-24.04<br>    steps:<br>      - uses: actions/checkout@v4<br><br>      - uses: aws-actions/configure-aws-credentials@v4<br>        with:<br>          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push<br>          aws-region: ap-south-1<br><br>      - uses: aws-actions/amazon-ecr-login@v2<br><br>      - uses: docker/setup-qemu-action@v3<br><br>      - uses: docker/setup-buildx-action@v3<br><br>      - uses: docker/build-push-action@v6<br>        with:<br>          context: .<br>          platforms: linux/amd64,linux/arm64<br>          push: true<br>          tags: ${{ env.IMAGE }}:${{ github.ref_name }}<br>          cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache<br>          cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true<br><br>      - name: Smoke test every architecture<br>        run: |<br>          for platform in linux/amd64 linux/arm64; do<br>            echo &quot;Testing $platform&quot;<br>            docker run --rm --platform &quot;$platform&quot; \<br>              &quot;${IMAGE}:${GITHUB_REF_NAME}&quot; --version<br>            docker run --rm --platform &quot;$platform&quot; \<br>              --entrypoint /usr/local/bin/grpc_health_probe \<br>              &quot;${IMAGE}:${GITHUB_REF_NAME}&quot; -version<br>          done</pre><p>Because the Go build cross-compiles, QEMU isn’t used to build anything here. It only exists so the runner can execute the ARM64 image during the smoke test. That test is the part most pipelines skip, and it’s the only step that proves the ARM64 image runs. The second docker run checks the health probe separately, because a wrong-architecture helper binary won&#39;t show up when you start the main process.</p><p>When emulated steps make builds too slow, split the work across native runners and stitch the results together:</p><pre># .github/workflows/build-payment-service-native.yml<br>name: build-payment-service-native<br>on:<br>  push:<br>    tags: [&quot;v*&quot;]<br><br>permissions:<br>  id-token: write<br>  contents: read<br><br>env:<br>  IMAGE: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment-service<br><br>jobs:<br>  build:<br>    strategy:<br>      matrix:<br>        include:<br>          - runner: ubuntu-24.04<br>            arch: amd64<br>          - runner: ubuntu-24.04-arm<br>            arch: arm64<br>    runs-on: ${{ matrix.runner }}<br>    steps:<br>      - uses: actions/checkout@v4<br>      - uses: aws-actions/configure-aws-credentials@v4<br>        with:<br>          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push<br>          aws-region: ap-south-1<br>      - uses: aws-actions/amazon-ecr-login@v2<br>      - uses: docker/setup-buildx-action@v3<br>      - uses: docker/build-push-action@v6<br>        with:<br>          context: .<br>          platforms: linux/${{ matrix.arch }}<br>          push: true<br>          provenance: false<br>          tags: ${{ env.IMAGE }}:${{ github.ref_name }}-${{ matrix.arch }}<br>      - name: Smoke test natively<br>        run: docker run --rm &quot;${IMAGE}:${GITHUB_REF_NAME}-${{ matrix.arch }}&quot; --version<br><br>  manifest:<br>    needs: build<br>    runs-on: ubuntu-24.04<br>    steps:<br>      - uses: aws-actions/configure-aws-credentials@v4<br>        with:<br>          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push<br>          aws-region: ap-south-1<br>      - uses: aws-actions/amazon-ecr-login@v2<br>      - uses: docker/setup-buildx-action@v3<br>      - name: Create multi-arch tag<br>        run: |<br>          docker buildx imagetools create \<br>            -t &quot;${IMAGE}:${GITHUB_REF_NAME}&quot; \<br>            &quot;${IMAGE}:${GITHUB_REF_NAME}-amd64&quot; \<br>            &quot;${IMAGE}:${GITHUB_REF_NAME}-arm64&quot;</pre><p>Each architecture is built and tested on real hardware, and the final job only writes a new index pointing at the two images. No layers are copied, so it finishes in seconds.</p><h3>Common Multi-Architecture Build Mistakes</h3><p>Hardcoded architectures in downloads are the most common one, and they’re why the key point of this article matters. A Dockerfile with curl -L .../kubectl/linux/arm64/kubectl builds a perfectly valid multi-arch image whose AMD64 variant contains an ARM64 binary. The manifest says linux/amd64, every layer pulls, the main process may even start, and the failure only appears when a probe, sidecar script, or init step runs that binary. Always build download URLs from TARGETARCH.</p><p>Name mismatches cause the subtle variant of the same bug. uname -m returns x86_64 and aarch64, while Docker&#39;s TARGETARCH uses amd64 and arm64, and release pages pick one convention or the other. Map them explicitly instead of assuming:</p><pre>ARG TARGETARCH<br>RUN case &quot;$TARGETARCH&quot; in \<br>      amd64) ARCH=x86_64 ;; \<br>      arm64) ARCH=aarch64 ;; \<br>      *) echo &quot;unsupported arch: $TARGETARCH&quot; &amp;&amp; exit 1 ;; \<br>    esac &amp;&amp; \<br>    curl -fsSL -o /tmp/awscli.zip &quot;https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip&quot;</pre><p>Misusing BUILDPLATFORM is next. Putting FROM --platform=$BUILDPLATFORM on the final stage gives every variant the build machine&#39;s base image. Putting it on a stage that runs npm ci or pip install compiles native modules like bcrypt or sharp for the build machine, then copies them into the target image, where they fail to load at runtime. Use BUILDPLATFORM only for stages that cross-compile, and let stages that install native dependencies run as the target platform.</p><p>Pinning a base image by the wrong digest is the last one. FROM node:20-bookworm-slim@sha256:... is good practice, but if you copied the digest from docker image inspect on your Mac, you pinned the ARM64 manifest, not the index. Every platform now builds on an ARM64 base. Take the index digest from docker buildx imagetools inspect instead.</p><h3>Production Checklist</h3><ul><li>Every image built for deployment targets each platform it runs on with --platform</li><li>Compiled stages use FROM --platform=$BUILDPLATFORM and cross-compile with TARGETOS/TARGETARCH</li><li>Stages that install native dependencies run on the target platform</li><li>Downloaded binaries use TARGETARCH, with explicit mapping where naming differs</li><li>Base images are pinned by index digest, not a platform manifest digest</li><li>docker buildx imagetools inspect confirms each platform after every push</li><li>CI smoke-tests every architecture, including helper binaries and probes</li><li>Nobody relies on DOCKER_DEFAULT_PLATFORM in their shell profile</li><li>Build caching works across platforms with a registry cache in mode=max</li><li>Native runners replace QEMU once emulated steps dominate build time</li></ul><h3>The Format Is Portable. The Contents Aren’t.</h3><p>Docker’s promise was “build once, run anywhere,” and the image format keeps that promise. A manifest index can describe any number of platforms, and every registry and runtime knows how to pick the right one. What the format can’t do is check that the binary inside each variant matches the label on it.</p><p>That check is your job. The image that runs on your laptop has proven exactly one thing: it runs on your laptop. A pipeline that builds and executes every target architecture is how “it works on my machine” finally turns into “it works on the machines we deploy to.”</p><p>If this was useful, I share more production DevOps notes on <a href="https://x.com/TomJosetj">X</a> and <a href="https://www.linkedin.com/in/tomjose-tomjosetj31">LinkedIn</a>.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=de478737ac67" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/your-docker-image-works-on-your-mac-ci-says-otherwise-de478737ac67">Your Docker Image Works on Your Mac. CI Says Otherwise.</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Hidden Cost of Moving Data Between AWS Services]]></title>
            <link>https://medium.com/kotaicode/the-hidden-cost-of-moving-data-between-aws-services-921876db95b6?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/921876db95b6</guid>
            <category><![CDATA[finops]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Fri, 23 Oct 2026 04:36:01 GMT</pubDate>
            <atom:updated>2026-10-23T04:36:01.586Z</atom:updated>
            <content:encoded><![CDATA[<h4>How $357 of compute ended up with a $2,404 network bill</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ziku5Elw6vBN8Vxp76xLQg.png" /></figure><p>Six m7g.large worker nodes in us-east-1 cost about $357 a month. In the example cluster this article walks through, the network charges around those nodes come to $2,404 a month, almost seven times the compute. Nothing in that bill is a misconfiguration AWS would flag. It&#39;s the default result of a normal Multi-AZ design with a NAT gateway, an RDS database, and some S3 traffic.</p><p>Data transfer is easy to miss because it never shows up as a resource. You can’t click on it in the console, it has no tags by default, and it’s spread across a dozen line items with names like USE1-DataTransfer-Regional-Bytes. This article covers how AWS prices each traffic path, where the expensive paths hide (NAT gateways, cross-AZ calls, load balancers, S3), a before-and-after cost comparison of the same application, and how to find these charges in your own bill.</p><p>All prices here are us-east-1 on-demand rates at the time of writing. Other regions cost more, so the patterns matter more than the exact cents.</p><h3>Understanding AWS Data Transfer Pricing</h3><p>The rules fit in a short list, and almost every surprise on a bill comes from one of them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MkzJXTONdm1QWCoW9U6B6g.png" /></figure><p>Two details make cross-AZ traffic more expensive than it looks. The $0.01 per GB is charged in each direction, so the sending side pays $0.01 and the receiving side pays another $0.01. And a request almost always has a response, so a busy API call pays on both legs. In practice, cross-AZ traffic costs $0.02 per GB moved.</p><p>Inbound data from the internet is free, which is why bills for upload-heavy systems look reasonable at first. The costs show up once that data starts moving around inside your VPC.</p><h3>Cross-AZ Data Transfer Costs</h3><p>A Multi-AZ design spreads instances across three zones for availability. Nothing in that design keeps traffic inside a zone. When a pod in us-east-1a calls a service with replicas in all three zones, the default load balancing sends roughly two out of three requests to another zone.</p><p>That applies everywhere. EC2 talking to RDS pays cross-AZ charges whenever the instance and the database primary sit in different zones, which in a three-zone setup is two-thirds of your application. ECS tasks calling each other through a Service Connect or Cloud Map endpoint pay it. EKS pods calling a ClusterIP Service pay it, because kube-proxy picks endpoints without looking at zones.</p><p>Some traffic is exempt. RDS Multi-AZ replication between the primary and standby isn’t billed to you, and neither is the traffic from an ALB to its targets in other zones. Application-level replication you run yourself (Kafka, Elasticsearch, a CloudNativePG cluster) is billed like any other cross-AZ traffic, and for chatty clusters it’s often the single biggest line item.</p><h3>NAT Gateway: The Hidden Cost Multiplier</h3><p>A NAT gateway costs $0.045 per hour, about $33 a month, which looks cheap. The expensive part is the $0.045 for every GB it processes, in both directions, on top of whatever the traffic itself costs.</p><p>Anything in a private subnet that reaches an AWS public endpoint goes through the NAT gateway unless you give it another path. That includes S3 uploads, DynamoDB calls, ECR image pulls, CloudWatch Logs, Secrets Manager lookups, and STS calls from the AWS SDK. A pod that streams 20 TB a month to S3 through a NAT gateway pays $900 a month in processing for traffic that could have been free.</p><p>The sharp edge is the common “cost saving” move of running one NAT gateway for all three zones. It saves about $66 a month in hourly charges. But traffic from the other two zones now crosses an AZ boundary to reach the NAT gateway and crosses back for the response, adding $0.02 per GB to two-thirds of your outbound traffic.</p><pre># Wrong: one shared NAT gateway for every private subnet<br>resource &quot;aws_route&quot; &quot;private_default&quot; {<br>  count                  = 3<br>  route_table_id         = aws_route_table.private[count.index].id<br>  destination_cidr_block = &quot;0.0.0.0/0&quot;<br>  nat_gateway_id         = aws_nat_gateway.shared.id<br>}<br><br># Right: one NAT gateway per AZ, each private subnet routes to its own zone&#39;s gateway<br>resource &quot;aws_nat_gateway&quot; &quot;per_az&quot; {<br>  count         = 3<br>  allocation_id = aws_eip.nat[count.index].id<br>  subnet_id     = aws_subnet.public[count.index].id<br><br>  tags = {<br>    Name = &quot;prod-nat-${count.index}&quot;<br>  }<br>}<br><br>resource &quot;aws_route&quot; &quot;private_default&quot; {<br>  count                  = 3<br>  route_table_id         = aws_route_table.private[count.index].id<br>  destination_cidr_block = &quot;0.0.0.0/0&quot;<br>  nat_gateway_id         = aws_nat_gateway.per_az[count.index].id<br>}</pre><p>Per-AZ NAT gateways also remove a single point of failure. A zone outage that takes out your only NAT gateway cuts outbound access for all three zones.</p><h3>VPC Endpoints vs NAT Gateways</h3><p>VPC endpoints give private subnets a direct path to AWS services without the NAT gateway. There are two kinds, and they’re priced very differently.</p><p>Gateway endpoints exist only for S3 and DynamoDB, and they’re free. No hourly charge, no per-GB charge. They work by adding routes to your route tables, so there’s no reason not to have them in every VPC.</p><pre>resource &quot;aws_vpc_endpoint&quot; &quot;s3&quot; {<br>  vpc_id            = aws_vpc.main.id<br>  service_name      = &quot;com.amazonaws.us-east-1.s3&quot;<br>  vpc_endpoint_type = &quot;Gateway&quot;<br>  route_table_ids   = aws_route_table.private[*].id<br>}<br><br>resource &quot;aws_vpc_endpoint&quot; &quot;dynamodb&quot; {<br>  vpc_id            = aws_vpc.main.id<br>  service_name      = &quot;com.amazonaws.us-east-1.dynamodb&quot;<br>  vpc_endpoint_type = &quot;Gateway&quot;<br>  route_table_ids   = aws_route_table.private[*].id<br>}</pre><p>Interface endpoints cover most other services (ECR, CloudWatch Logs, Secrets Manager, STS, SQS) and cost $0.01 per hour per AZ plus $0.01 per GB. One endpoint across three zones is about $22 a month before any traffic. Against NAT processing at $0.045 per GB, each GB through the endpoint saves $0.035, so a three-zone interface endpoint pays for itself at around 630 GB a month. Below that, the NAT gateway is cheaper.</p><p>That math matters, because I’ve seen teams add fifteen interface endpoints “for cost reasons” and end up spending more. Add an interface endpoint when a service moves real volume, and keep the NAT gateway for the long tail.</p><pre>resource &quot;aws_vpc_endpoint&quot; &quot;ecr&quot; {<br>  for_each            = toset([&quot;ecr.api&quot;, &quot;ecr.dkr&quot;])<br>  vpc_id              = aws_vpc.main.id<br>  service_name        = &quot;com.amazonaws.us-east-1.${each.key}&quot;<br>  vpc_endpoint_type   = &quot;Interface&quot;<br>  subnet_ids          = aws_subnet.private[*].id<br>  security_group_ids  = [aws_security_group.vpc_endpoints.id]<br>  private_dns_enabled = true<br>}</pre><p>Two sharp edges here. ECR stores image layers in S3, so the ECR interface endpoints alone only cover the API calls and manifests. Without the S3 gateway endpoint, the layers, which are most of the bytes, still go through the NAT gateway. And gateway endpoints only cover buckets in the same region. A pod reading a bucket in eu-west-1 still goes through the NAT gateway, and pays inter-region transfer on top.</p><h3>Load Balancers and Kubernetes Networking Costs</h3><p>Load balancers add their own charges. An ALB bills in Load Balancer Capacity Units (LCUs) at $0.008 per LCU-hour, and for EC2 and IP targets one LCU covers 1 GB processed per hour. For bandwidth-heavy services, that works out to roughly $0.008 per GB. ALB cross-zone load balancing is always on, and AWS doesn’t charge for the cross-AZ traffic between an ALB and its targets.</p><p>NLB is different. Cross-zone load balancing is off by default, and when you turn it on, traffic from the NLB to targets in other zones is billed as regular cross-AZ transfer. Turning it on to fix uneven load is often the right call, as long as you know it has a price.</p><p>The sharp edge I find most often in EKS clusters is hairpinning. A service calls another internal service through its public hostname, say https://api.cartwheel.in/inventory. DNS resolves to the public ALB, so the request leaves through the NAT gateway, crosses the internet edge, and comes back in through the ALB. You pay NAT processing on traffic that never needed to leave the cluster. Call inventory-api.catalog.svc.cluster.local instead.</p><p>For pod-to-pod traffic, Kubernetes can prefer endpoints in the caller’s own zone. On recent Kubernetes versions, set trafficDistribution on the Service:</p><pre>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: inventory-api<br>  namespace: catalog<br>spec:<br>  selector:<br>    app.kubernetes.io/name: inventory-api<br>  ports:<br>    - name: http<br>      port: 80<br>      targetPort: 8080<br>  trafficDistribution: PreferClose</pre><p>PreferClose sends traffic to same-zone endpoints whenever any exist. That&#39;s also its sharp edge. If us-east-1b has one inventory-api pod and the other zones have four each, every client in us-east-1b hits that single pod. Pair it with a topologySpreadConstraint on topology.kubernetes.io/zone, so each zone has enough replicas to carry its own traffic. Older clusters can use the service.kubernetes.io/topology-mode: Auto annotation, which applies a similar idea with stricter conditions.</p><h3>S3 and Cross-Region Data Transfer</h3><p>S3 to EC2 in the same region is free, as long as the traffic doesn’t go through a NAT gateway. The charges start when data changes regions or leaves AWS.</p><p>Cross-region replication pays inter-region transfer of $0.02 per GB for every byte copied, plus replication requests. S3 Replication Time Control adds $0.015 per GB on top of that. Replicating a 50 TB bucket that changes 10 TB a month costs about $200 a month in transfer alone, and the initial copy of the existing 50 TB is another $1,000.</p><p>Storage classes add retrieval charges. Standard-IA charges $0.01 per GB retrieved, and Glacier classes charge more. An analytics job that scans an IA bucket every night can turn a storage saving into a retrieval bill. And anything served from S3 straight to the internet pays $0.09 per GB, which is usually the moment to put CloudFront in front of it.</p><h3>The Comparison: Same Application, Two Network Designs</h3><p>Here’s the example from the opening. Cartwheel’s orders platform runs on EKS in us-east-1 with six m7g.large nodes spread across three zones, an RDS PostgreSQL Multi-AZ primary, and S3 for invoice PDFs and product images. Monthly traffic, modeled for this example:</p><p>Service-to-service traffic inside the cluster totals 40 TB. The app moves 15 TB to and from RDS. Pods read and write 20 TB to S3. Image pulls from ECR come to 3 TB, because Karpenter replaces nodes often. Calls to third-party payment and shipping APIs come to 2 TB.</p><p>The unoptimized design is common: a single NAT gateway, no VPC endpoints, and default Kubernetes routing. The optimized design changes only the network: per-AZ NAT gateways, gateway endpoints for S3 and DynamoDB, ECR interface endpoints, and PreferClose on internal Services.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_uKfpRhwMNeNyaR3J2ezYA.png" /><figcaption>Monthly data transfer costs before and after optimizing the network design</figcaption></figure><p>The total drops from $2,404 to $694 a month, about 71%, with no change to compute, storage, or application code beyond Service manifests. The biggest win, by far, is S3 traffic leaving the NAT gateway.</p><p>Database traffic is the one line that doesn’t move. The RDS primary lives in one zone, so two-thirds of the application will always cross an AZ to reach it. You can reduce that with caching or same-zone read replicas for read-heavy paths, but each option costs something of its own. I’d accept that line and spend the effort elsewhere.</p><h3>Finding Hidden Data Transfer Charges</h3><p>Start in Cost Explorer. Group by Usage Type and filter on usage types containing DataTransfer and NatGateway. The names tell you the path: USE1-DataTransfer-Regional-Bytes is cross-AZ traffic, USE1-NatGateway-Bytes is NAT processing, USE1-DataTransfer-Out-Bytes is internet egress, and USE1-EUW1-AWS-Out-Bytes is traffic from us-east-1 to eu-west-1.</p><p>Cost Explorer tells you how much, but not which resource. The Cost and Usage Report does, down to the resource ID. With CUR in Athena:</p><pre>SELECT line_item_usage_type,<br>       line_item_resource_id,<br>       ROUND(SUM(line_item_usage_amount), 0)   AS gb,<br>       ROUND(SUM(line_item_unblended_cost), 2) AS cost_usd<br>FROM cartwheel_cur.cur_2_0<br>WHERE line_item_usage_start_date &gt;= TIMESTAMP &#39;2026-09-01 00:00:00&#39;<br>  AND line_item_usage_start_date &lt;  TIMESTAMP &#39;2026-10-01 00:00:00&#39;<br>  AND (line_item_usage_type LIKE &#39;%DataTransfer%&#39;<br>       OR line_item_usage_type LIKE &#39;%NatGateway-Bytes%&#39;)<br>GROUP BY 1, 2<br>ORDER BY cost_usd DESC<br>LIMIT 20;</pre><p>For the NAT gateway, the resource ID tells you which gateway, not which workload. VPC Flow Logs fill that gap. A custom format that includes pkt-dst-aws-service shows which AWS service the traffic was headed to, which is usually enough to spot the S3 or ECR traffic that belongs on an endpoint:</p><pre>aws ec2 create-flow-logs \<br>  --resource-type NetworkInterface \<br>  --resource-ids eni-0d4f1a2b3c4e5f607 \<br>  --traffic-type ALL \<br>  --log-destination-type s3 \<br>  --log-destination arn:aws:s3:::cartwheel-vpc-flow-logs/nat-gateway/ \<br>  --log-format &#39;${start} ${srcaddr} ${dstaddr} ${bytes} ${az-id} ${pkt-srcaddr} ${pkt-dstaddr} ${pkt-dst-aws-service} ${flow-direction}&#39; \<br>  --max-aggregation-interval 600 \<br>  --region us-east-1</pre><p>Flow logs on a busy VPC aren’t free either, which is why this example targets only the NAT gateway’s network interface. Run it for a few days, find the top talkers, and turn it off. Cost allocation tags then let you track the result: tag NAT gateways, load balancers, and endpoints with an owning team so the next spike has a name attached.</p><h3>Best Practices to Reduce Data Transfer Costs</h3><p>Most of the savings come from four habits. Keep traffic in its zone with per-AZ NAT gateways, zone-aware Service routing, and replicas spread evenly across zones. Give AWS-bound traffic a private path, starting with the free gateway endpoints and adding interface endpoints only where volume justifies them. Keep cross-region traffic deliberate, with replication scoped to the prefixes that need it rather than whole buckets. And watch the bill by usage type, with a monthly alert on NAT gateway bytes and cross-AZ transfer, so a new chatty service shows up in weeks rather than at quarter end.</p><h3>Production Checklist</h3><ul><li>Every private subnet routes to a NAT gateway in its own AZ</li><li>S3 and DynamoDB gateway endpoints exist in every VPC</li><li>ECR interface endpoints are paired with the S3 gateway endpoint</li><li>Interface endpoints are added only where traffic exceeds the break-even volume</li><li>Internal services call each other by cluster DNS, never through public hostnames</li><li>Internal Services use trafficDistribution: PreferClose with zone-balanced replicas</li><li>NLB cross-zone load balancing is a deliberate choice, with its cost known</li><li>S3 replication is scoped to the prefixes that need it</li><li>Public S3 content is served through CloudFront</li><li>Cost Explorer is grouped by usage type for DataTransfer and NatGateway monthly</li><li>NAT gateways, load balancers, and endpoints carry cost allocation tags</li><li>Billing alerts exist for NAT gateway bytes and cross-AZ transfer</li></ul><h3>Every Arrow Has a Price</h3><p>Architecture diagrams draw compute as boxes and traffic as arrows, and teams spend their cost reviews on the boxes. The boxes are where AWS shows you a price up front. The arrows are where it doesn’t, and in a busy Multi-AZ system they can quietly cost several times more.</p><p>The cheapest time to fix data transfer is when you draw the diagram. Before you add an arrow, ask which zone it starts in, which zone it ends in, and whether it passes through a NAT gateway on the way. That one question, asked early, saves more than any amount of instance right-sizing later.</p><p>If this was useful, I share more production DevOps notes on <a href="https://x.com/TomJosetj">X</a> and <a href="https://www.linkedin.com/in/tomjose-tomjosetj31">LinkedIn</a>.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=921876db95b6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/the-hidden-cost-of-moving-data-between-aws-services-921876db95b6">The Hidden Cost of Moving Data Between AWS Services</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[10 Kubernetes Problems Every DevOps Engineer Should Know How to Debug]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://medium.com/kotaicode/10-kubernetes-problems-every-devops-engineer-should-know-how-to-debug-eef5415b24ec?source=rss----c897b9860971---4"><img src="https://cdn-images-1.medium.com/max/1774/1*ecfgkV2m65eqsiJseEGY-Q.png" width="1774"></a></p><p class="medium-feed-snippet">Pod status is one signal. Here&#x2019;s how to read the rest of the chain.</p><p class="medium-feed-link"><a href="https://medium.com/kotaicode/10-kubernetes-problems-every-devops-engineer-should-know-how-to-debug-eef5415b24ec?source=rss----c897b9860971---4">Continue reading on kotaicode »</a></p></div>]]></description>
            <link>https://medium.com/kotaicode/10-kubernetes-problems-every-devops-engineer-should-know-how-to-debug-eef5415b24ec?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/eef5415b24ec</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Thu, 22 Oct 2026 09:31:02 GMT</pubDate>
            <atom:updated>2026-10-22T09:31:02.196Z</atom:updated>
        </item>
        <item>
            <title><![CDATA[Multi-Region Disaster Recovery on AWS: A Production Guide]]></title>
            <link>https://medium.com/kotaicode/multi-region-disaster-recovery-on-aws-a-production-guide-47a8512a5733?source=rss----c897b9860971---4</link>
            <guid isPermaLink="false">https://medium.com/p/47a8512a5733</guid>
            <category><![CDATA[cloud-architecture]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Tom Jose | DevOps]]></dc:creator>
            <pubDate>Wed, 21 Oct 2026 08:31:01 GMT</pubDate>
            <atom:updated>2026-10-21T08:31:01.541Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>RTO and RPO are business decisions. The architecture that meets them is an engineering one. Here’s both.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j392tz8YQ7hRifXf9ld5bQ.png" /></figure><p>A regional AWS outage is rare. When it happens, it’s typically a multi-hour event affecting every service in the region simultaneously. Organizations without a multi-region DR strategy discover their RTO during the outage, which is not when you want to discover it.</p><p>This article covers a production multi-region DR implementation on AWS. Not theory: the actual infrastructure decisions, replication mechanisms, and failover procedures that determine whether your recovery time matches your business requirement.</p><h3>Setting RTO and RPO before designing anything</h3><p>Recovery Time Objective (RTO) is how long the business can tolerate downtime. Recovery Point Objective (RPO) is how much data loss is acceptable, measured in time.</p><p>These are business decisions that must precede architecture decisions. An engineering team that targets 15-minute RTO because it sounds achievable, without consulting the business, may be over-engineering for a product where hours of downtime is acceptable: or under-engineering for one where minutes of downtime costs millions.</p><p>Get these numbers in writing from stakeholders before building anything:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ay7NFov6uKORxODxt0RDyQ.png" /></figure><p>Then match the DR strategy to each workload based on cost and RTO requirement:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jxQ-gxUHUWQ7-7zbNktrtA.png" /></figure><h3>Data replication: the critical layer</h3><p>Compute is stateless and fast to provision. Data is stateful and slow to replicate. The data layer determines your actual RPO.</p><p><strong>RDS cross-region read replica:</strong></p><pre>resource &quot;aws_db_instance&quot; &quot;primary&quot; {<br>  provider = aws.primary_region<br><br>  identifier     = &quot;production-db&quot;<br>  engine         = &quot;postgres&quot;<br>  instance_class = &quot;db.t3.medium&quot;<br>  multi_az       = true<br>  # ... other config<br>}<br><br>resource &quot;aws_db_instance&quot; &quot;dr_replica&quot; {<br>  provider = aws.dr_region<br><br>  identifier          = &quot;production-db-dr&quot;<br>  replicate_source_db = aws_db_instance.primary.arn<br>  instance_class      = &quot;db.t3.medium&quot;<br><br>  # Replicas don&#39;t need their own backups<br>  backup_retention_period = 0<br>  skip_final_snapshot     = true<br><br>  # Can be promoted to primary during failover<br>  deletion_protection = false<br>}</pre><p>The read replica replicates asynchronously. Replication lag, the delay between a write to the primary and that write appearing on the replica, is typically under a second for well-resourced instances. Under heavy write load or network issues, lag can grow. Monitor it:</p><pre>resource &quot;aws_cloudwatch_metric_alarm&quot; &quot;replication_lag&quot; {<br>  provider = aws.primary_region<br><br>  alarm_name          = &quot;rds-replication-lag&quot;<br>  comparison_operator = &quot;GreaterThanThreshold&quot;<br>  evaluation_periods  = 3<br>  metric_name         = &quot;ReplicaLag&quot;<br>  namespace           = &quot;AWS/RDS&quot;<br>  period              = 60<br>  statistic           = &quot;Average&quot;<br>  threshold           = 30  # alert if lag exceeds 30 seconds<br><br>  dimensions = {<br>    DBInstanceIdentifier = aws_db_instance.dr_replica.id<br>  }<br><br>  alarm_actions = [aws_sns_topic.dr_alerts.arn]<br>}</pre><p><strong>Aurora Global Database</strong> for lower RPO:</p><pre>resource &quot;aws_rds_global_cluster&quot; &quot;main&quot; {<br>  global_cluster_identifier = &quot;production-global&quot;<br>  engine                    = &quot;aurora-postgresql&quot;<br>  engine_version            = &quot;15.4&quot;<br>}<br><br>resource &quot;aws_rds_cluster&quot; &quot;primary&quot; {<br>  provider = aws.primary_region<br><br>  cluster_identifier        = &quot;production-primary&quot;<br>  global_cluster_identifier = aws_rds_global_cluster.main.id<br>  engine                    = &quot;aurora-postgresql&quot;<br>  # ...<br>}<br><br>resource &quot;aws_rds_cluster&quot; &quot;dr&quot; {<br>  provider = aws.dr_region<br><br>  cluster_identifier        = &quot;production-dr&quot;<br>  global_cluster_identifier = aws_rds_global_cluster.main.id<br>  engine                    = &quot;aurora-postgresql&quot;<br>  # ...<br>}</pre><p>Aurora Global Database replicates with sub-second lag and can promote the secondary region to primary in under 1 minute: significantly faster than RDS read replica promotion.</p><p><strong>S3 cross-region replication:</strong></p><pre>resource &quot;aws_s3_bucket_replication_configuration&quot; &quot;main&quot; {<br>  provider = aws.primary_region<br>  bucket   = aws_s3_bucket.primary.id<br>  role     = aws_iam_role.replication.arn<br><br>  rule {<br>    id     = &quot;replicate-all&quot;<br>    status = &quot;Enabled&quot;<br><br>    destination {<br>      bucket        = aws_s3_bucket.dr.arn<br>      storage_class = &quot;STANDARD_IA&quot;<br><br>      replication_time {<br>        status = &quot;Enabled&quot;<br>        time {<br>          minutes = 15<br>        }<br>      }<br><br>      metrics {<br>        status = &quot;Enabled&quot;<br>      }<br>    }<br>  }<br>}</pre><p>replication_time with 15-minute SLA enables S3 Replication Time Control (RTC). AWS guarantees 99.99% of objects replicate within 15 minutes. Without RTC, replication is best-effort: usually fast, but no SLA.</p><h3>Infrastructure in the DR region</h3><p>The DR region needs Terraform configuration ready to deploy on demand (Pilot Light) or pre-deployed at reduced capacity (Warm Standby).</p><pre># terraform/dr-region/main.tf<br><br>variable &quot;mode&quot; {<br>  type    = string<br>  default = &quot;pilot_light&quot;  # or &quot;warm_standby&quot; or &quot;active_active&quot;<br>}<br><br>locals {<br>  compute_desired = {<br>    pilot_light  = 0<br>    warm_standby = 2<br>    active_active = 5<br>  }[var.mode]<br>}<br><br>resource &quot;aws_ecs_service&quot; &quot;app&quot; {<br>  provider      = aws.dr_region<br>  name          = &quot;app-dr&quot;<br>  cluster       = aws_ecs_cluster.dr.id<br>  desired_count = local.compute_desired<br><br>  # ... service config pointing to dr_replica database<br>}</pre><p>For Pilot Light: desired_count = 0. The infrastructure exists but no tasks run. Scaling to capacity on failover takes 5–10 minutes.</p><p>For Warm Standby: desired_count = 2. A minimal number of tasks run continuously, confirming the environment is functional and reducing time-to-full-capacity.</p><h3>Traffic failover with Route 53</h3><p>Route 53 health checks monitor your primary endpoint and automatically switch traffic to the DR region:</p><pre>resource &quot;aws_route53_health_check&quot; &quot;primary&quot; {<br>  fqdn              = &quot;api.example.com&quot;<br>  port              = 443<br>  type              = &quot;HTTPS&quot;<br>  resource_path     = &quot;/health&quot;<br>  failure_threshold = 3<br>  request_interval  = 10<br><br>  tags = {<br>    Name = &quot;primary-health-check&quot;<br>  }<br>}<br><br>resource &quot;aws_route53_record&quot; &quot;primary&quot; {<br>  zone_id = aws_route53_zone.main.zone_id<br>  name    = &quot;api.example.com&quot;<br>  type    = &quot;A&quot;<br><br>  failover_routing_policy {<br>    type = &quot;PRIMARY&quot;<br>  }<br><br>  set_identifier  = &quot;primary&quot;<br>  health_check_id = aws_route53_health_check.primary.id<br>  ttl             = 60<br><br>  records = [aws_eip.primary_alb.public_ip]<br>}<br><br>resource &quot;aws_route53_record&quot; &quot;secondary&quot; {<br>  zone_id = aws_route53_zone.main.zone_id<br>  name    = &quot;api.example.com&quot;<br>  type    = &quot;A&quot;<br><br>  failover_routing_policy {<br>    type = &quot;SECONDARY&quot;<br>  }<br><br>  set_identifier = &quot;secondary&quot;<br>  ttl            = 60<br><br>  records = [aws_eip.dr_alb.public_ip]<br>}</pre><p>Route 53 checks the primary endpoint every 10 seconds. Three consecutive failures (30 seconds) trigger failover. The secondary record takes over. With TTL of 60 seconds, clients pick up the DNS change within 60–90 seconds.</p><p>Route 53 checks run from multiple AWS edge locations: a health check failure requires the endpoint to be unreachable from multiple locations, not just one. This prevents false positive failovers from transient network issues.</p><h3>The failover procedure</h3><p>Automatic DNS failover handles traffic routing. But there are additional steps that must happen for a complete failover:</p><pre># Failover runbook<br><br># 1. Verify the regional failure is real<br>aws ec2 describe-availability-zones --region ap-south-1<br># Check AWS Status: https://status.aws.amazon.com<br><br># 2. Promote the RDS read replica (or Aurora secondary) to primary<br>aws rds promote-read-replica \<br>  --db-instance-identifier production-db-dr \<br>  --region us-east-1<br><br># Wait for promotion to complete<br>aws rds wait db-instance-available \<br>  --db-instance-identifier production-db-dr \<br>  --region us-east-1<br><br># 3. Update application configuration to point at DR database<br># Update Secrets Manager in DR region with new primary endpoint<br>aws secretsmanager update-secret \<br>  --secret-id prod/app/db-credentials \<br>  --secret-string &quot;$(aws rds describe-db-instances \<br>    --db-instance-identifier production-db-dr \<br>    --region us-east-1 \<br>    --query &#39;DBInstances[0].Endpoint.Address&#39; \<br>    --output text)&quot; \<br>  --region us-east-1<br><br># 4. Scale up ECS service in DR region (Pilot Light only)<br>aws ecs update-service \<br>  --cluster production-dr \<br>  --service app-dr \<br>  --desired-count 10 \<br>  --region us-east-1<br><br># 5. Wait for tasks to stabilize<br>aws ecs wait services-stable \<br>  --cluster production-dr \<br>  --services app-dr \<br>  --region us-east-1<br><br># 6. Verify health in DR region<br>curl https://api.example.com/health  # now resolves to DR region<br><br># Total time target: under 30 minutes for Pilot Light</pre><p>Automate every step that can be automated. A runbook with manual steps takes longer and introduces human error under stress.</p><h3>Testing the DR plan</h3><p>An untested DR plan is a hypothesis. Testing validates:</p><ul><li>Actual RTO matches planned RTO</li><li>Replication lag is within acceptable bounds</li><li>All dependencies (secrets, certificates, third-party integrations) work in the DR region</li><li>The team can execute the failover procedure under simulated stress</li></ul><p><strong>Quarterly restore testing:</strong></p><pre># Restore latest RDS snapshot to test instance in DR region<br>SNAPSHOT=$(aws rds describe-db-snapshots \<br>  --db-instance-identifier production-db \<br>  --query &#39;DBSnapshots[-1].DBSnapshotIdentifier&#39; \<br>  --output text)<br><br>aws rds restore-db-instance-from-db-snapshot \<br>  --db-instance-identifier dr-test-restore \<br>  --db-snapshot-identifier $SNAPSHOT \<br>  --db-instance-class db.t3.medium \<br>  --region us-east-1<br><br># Time this step — compare to your RTO target<br>time aws rds wait db-instance-available \<br>  --db-instance-identifier dr-test-restore \<br>  --region us-east-1<br><br># Verify data integrity<br># Run validation queries against the restored instance<br># Clean up<br>aws rds delete-db-instance \<br>  --db-instance-identifier dr-test-restore \<br>  --skip-final-snapshot \<br>  --region us-east-1</pre><p><strong>Annual game day:</strong> simulate a regional failure with the on-call team and execute the full failover procedure. Measure total time. Identify gaps. Fix them before the next game day: not during the next real incident.</p><h3>Common DR failures</h3><p><strong>Backups in the primary region.</strong> An S3 bucket in ap-south-1 that stores ap-south-1 RDS snapshots provides no DR value during a regional outage. Every backup must be in a different region from what it&#39;s backing up.</p><p><strong>Secrets not replicated.</strong> The application fails over to the DR region but can’t connect to the database because the Secret Manager secret with credentials only exists in the primary region. Replicate secrets as part of DR infrastructure setup.</p><p><strong>Untested promotion.</strong> RDS read replica promotion takes time and requires reconfiguring connection strings. Application health checks in the DR region fail because they point at the old primary endpoint. Test the promotion and the connection string update.</p><p><strong>RTO based on estimate, not measurement.</strong> “Recovery should take about 30 minutes” is not a validated RTO. Measure every step. Sum the times. Add 20% for the unexpected. That’s your realistic RTO: and it’s almost always longer than teams estimate.</p><p>Multi-region DR is infrastructure insurance. The cost is real and ongoing: the replication, the warm standby capacity, the regular testing. The return is business continuity during a class of failure that is rare but, when it happens, is severe. The organizations that discover their DR plan doesn’t work during an actual outage pay twice: once in the outage itself, and once rebuilding the trust they lost. Test before you need it.</p><p><em>Tom Jose is a DevOps Engineer at </em><a href="https://kotaico.de"><em>KotaiCode</em></a><em>, where the team helps companies get their Kubernetes and AWS infrastructure production-ready.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=47a8512a5733" width="1" height="1" alt=""><hr><p><a href="https://medium.com/kotaicode/multi-region-disaster-recovery-on-aws-a-production-guide-47a8512a5733">Multi-Region Disaster Recovery on AWS: A Production Guide</a> was originally published in <a href="https://medium.com/kotaicode">kotaicode</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>