The Setup That Started This Comparison
The number that made our finance lead go quiet was $0.10 per cluster per hour — that’s the EKS control plane fee, and it sounds trivial until you multiply it across three regions, add managed node group overhead, factor in NAT gateway egress between regions, and suddenly you’re looking at a baseline cost before a single pod runs that’s hard to justify to a startup board. So yeah, we did the math and then immediately asked: what does this look like if we just… manage it ourselves?
Here’s the exact setup we ran for six weeks. EKS 1.29 with managed node groups on m6i.xlarge instances across us-east-1, eu-west-1, and ap-southeast-1. Against that: a kubeadm-bootstrapped cluster on the same instance types, same VPCs, same security groups, same Calico CNI version. The only variable was who managed the control plane. No cheat codes, no ARM instances to skew compute costs, no spot-only tricks. We needed a clean read, not an optimized one. The kubeadm init config we landed on after a lot of yak-shaving looked like this:
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.29.3
controlPlaneEndpoint: "k8s-cp.internal.yourdomain.com:6443"
networking:
podSubnet: "192.168.0.0/16"
serviceSubnet: "10.96.0.0/12"
etcd:
local:
dataDir: /var/lib/etcd
extraArgs:
quota-backend-bytes: "8589934592" # 8GB — default 2GB will bite you with stateful workloads
apiServer:
extraArgs:
audit-log-maxage: "30"
audit-log-maxbackup: "10"
audit-log-maxsize: "100"
That quota-backend-bytes flag is one of those things nobody mentions until your etcd starts throwing mvcc: database space exceeded errors at 2am. We hit it on day nine. The stateful workloads we were running — Kafka, PostgreSQL with streaming replication, and a Redis cluster — generate way more etcd churn than typical stateless apps, and the default 2GB etcd quota is genuinely too small if you’re using operators that reconcile frequently. EKS abstracts all of this away. Self-managed means you own every one of these failure modes.
The spoiler I’ll give you up front: EKS won for teams of one to five engineers, and self-managed won for teams of eight or more with a dedicated platform function. That boundary isn’t arbitrary — it maps almost exactly to whether you have someone whose job is Kubernetes, not just someone who also does Kubernetes. A team of three shipping product features cannot afford to spend the 40+ hours we burned on etcd backup automation, control plane upgrade runbooks, and kube-apiserver HA across three regions. Those hours have to come from somewhere. If you’re also evaluating where AI tooling fits into your eng workflow — because cutting toil on the infrastructure side often means leaning harder on AI-assisted coding to stay productive — the Best AI Coding Tools in 2026 guide covers that space well and it’s worth reading alongside infra decisions. Tooling choices compound: the less operational burden your infra setup creates, the more cognitive bandwidth you have to actually use those tools well.
One thing that caught me off guard: the networking complexity differential between EKS and self-managed is almost entirely in the egress paths, not the pod networking. Both clusters ran Calico fine. The pain with self-managed was that we had to manually manage the AWS VPC CNI alternative, deal with source NAT ourselves, and — critically — figure out cross-region pod routing without the EKS-native VPC peering integrations that just work out of the box. We ended up using Cilium’s clustermesh on the self-managed side, which added another surface area to understand. EKS clusters with the VPC CNI and cluster mesh via AWS CloudMap was maybe 30% less config. That gap closes if you know Cilium deeply. We didn’t, and that’s an honest answer most comparisons won’t give you.
Building an Operational Tool for Heavy Industry When Your Data Looks Nothing Like the Tutorial Examples
What ‘Self-managed Kubernetes’ Actually Means in 2026
The phrase “self-managed Kubernetes” does a lot of hand-waving over a massive operational surface area. I’ve seen teams treat it like a badge of honor — “we run our own clusters” — right up until they’re paged at 2am because etcd lost quorum and nobody wrote down the restore procedure. Before you commit to this path, you need to be specific about which self-managed Kubernetes you’re actually signing up for, because kubeadm, kops, Kubespray, and Talos OS are four genuinely different bets.
Your Four Realistic Options (and What They Actually Cost You)
kubeadm is the closest thing to “raw” Kubernetes. You get maximum control and maximum toil. Every node join, every cert rotation, every upgrade is a manual operation you either script yourself or execute by hand. I’d use it for learning or for environments where the cluster topology is weird enough that automation tools would fight you the whole way.
kops is what most AWS-native teams reach for. It manages your cloud infrastructure alongside your cluster — security groups, ASGs, Route53 entries — so upgrades feel more like kops upgrade cluster --yes than a 14-step runbook. The catch: kops is opinionated about AWS, and if you’re multi-cloud, it’ll be a constant source of friction.
Kubespray is Ansible-based, which means it fits teams that already have Ansible muscle memory. The operational overhead is medium — you’re not writing raw kubeadm scripts, but you’re also maintaining a pile of YAML inventory files and role variables. Upgrades are reliable but slow. Expect a 40-60 minute rolling upgrade for a 10-node cluster.
Talos OS is the one that changed my assumptions. Talos eliminates SSH access entirely — the OS is immutable and API-driven. Your cluster nodes have no shell, no package manager, no manual footprint. Upgrades are atomic OS image swaps. The operational overhead is lower than kubeadm once you’re past the learning curve, but the docs assume you already understand Kubernetes internals deeply. Don’t start here if you’re also learning Kubernetes at the same time.
Deploying Flask on AWS Lambda Without Losing Your Mind: A Setup-to-Production Guide
The Actual kubeadm Init Command You Need
Every tutorial shows you the minimal init. This is the one you actually want for anything beyond a throwaway sandbox:
kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--apiserver-advertise-address=192.168.1.10 \
--control-plane-endpoint="k8s-api.internal.example.com:6443" \
--upload-certs \
--kubernetes-version=v1.30.2
Three things to understand here. --pod-network-cidr must match whatever CNI you’re deploying — Flannel expects 10.244.0.0/16, Calico defaults to 192.168.0.0/16. Pick wrong and your pods will deploy but never communicate. --apiserver-advertise-address is the IP the API server binds to on this specific node — use the private IP of your first control plane node, not a public IP, not a hostname. And --control-plane-endpoint is the single most important flag for HA: this needs to be a load balancer address or a DNS name that points at your load balancer, not an individual node. Use a hostname here, not an IP, because when you inevitably need to change the endpoint, you’ll update DNS instead of rebuilding the cluster.
The Load Balancer You Should Have Built on Day One
Here’s the thing the documentation buries: if you’re running multiple control plane nodes — which you should be for anything production — you need a load balancer in front of port 6443 before you run kubeadm init. Not after. The --control-plane-endpoint flag gets baked into your cluster certificates and your kubeconfig. Change it later and you’re rotating certs across every node and every client config. The load balancer doesn’t need to be fancy: HAProxy on a dedicated VM with a simple TCP passthrough config works fine, and the entire config is about 10 lines:
frontend k8s-api
bind *:6443
mode tcp
default_backend k8s-control-plane
backend k8s-control-plane
mode tcp
balance roundrobin
option tcp-check
server cp1 192.168.1.10:6443 check
server cp2 192.168.1.11:6443 check
server cp3 192.168.1.12:6443 check
If you’re on AWS, use a Network Load Balancer targeting port 6443 on your control plane instances. Do not use an Application Load Balancer — it doesn’t support raw TCP, and the Kubernetes API is TLS-terminated by the API server itself, not by your load balancer.
Your Control Plane is Your Problem, Full Stop
The operational surface nobody talks about enough: etcd is the database your entire cluster state lives in, and it’s your responsibility to back it up, restore it, and not let it fall out of quorum. A three-node etcd cluster tolerates one node failure. Lose two nodes and you’re in manual recovery territory, which means reading the etcd recovery docs at 3am while your app is down. Set up a CronJob or a cron on each control plane node that runs this nightly:
Getting Selenium to Actually Work in CI/CD for JavaScript Apps (Without Losing Your Mind)
ETCDCTL_API=3 etcdctl snapshot save /backup/etcd-$(date +%Y%m%d).db \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
--key=/etc/kubernetes/pki/etcd/healthcheck-client.key
Then ship those backups off-node immediately. A backup that lives on the same machine you just lost is not a backup. Beyond etcd, your API server certificates expire after one year by default. kubeadm auto-renews them during upgrades, but if you haven’t upgraded in 12 months — which happens more than anyone admits — you’ll wake up to a cluster where kubectl get pods returns a TLS error. The fix is kubeadm certs renew all, but the real fix is not letting 12 months pass between upgrades, which means you need a documented quarterly upgrade process from day one, not day 300.
EKS in 2026: What’s Changed and What’s Still Annoying
EKS Auto Mode is the biggest shift in managed Kubernetes on AWS in years, and I don’t say that lightly. Launched in late 2024, it flips the node management model entirely — instead of you provisioning node groups and fighting Karpenter configs, EKS Auto Mode handles compute provisioning natively as part of the control plane. No separate Karpenter installation, no managed node group YAML to babysit. You define workloads, and the cluster figures out the nodes. The thing that caught me off guard was how opinionated it is about instance selection — it chooses based on your pod specs and current Spot pricing without you touching a NodePool manifest. For greenfield projects, I’d start here by default.
The $0.10/hour per cluster charge hasn’t gone anywhere. That’s roughly $73/month per cluster just for the control plane to exist. Sounds trivial until you’re running separate clusters for dev, staging, prod, plus a few feature-branch environments, and suddenly you’re looking at $500+/month before a single pod runs. Teams that went cluster-per-team at scale have quietly started consolidating into namespace-based multi-tenancy to kill this. If you’re spinning up ephemeral clusters for CI environments, automate their teardown or use eksctl delete cluster in your pipeline cleanup step — I’ve seen forgotten dev clusters quietly drain budgets for months.
eksctl vs. Terraform: Stop Using the Wrong One
The honest answer is that eksctl is great for getting a cluster running in 15 minutes and terrible for everything after that. Run this and you have a working cluster:
eksctl create cluster \
--name my-cluster \
--region us-east-1 \
--version 1.30 \
--nodegroup-name standard-workers \
--node-type m6i.large \
--nodes 3 \
--nodes-min 1 \
--nodes-max 5 \
--managed
But eksctl generates CloudFormation stacks it partially owns, and once you need to modify VPC settings, add OIDC providers, or hook into existing networking, you’re fighting it instead of using it. Terraform’s aws_eks_cluster resource with the terraform-aws-modules/eks module wins for anything you need to reproduce. The state is yours, the dependencies are explicit, and when someone asks “what changed between last week and today,” you have a git diff. Use eksctl to prototype. Use Terraform when the cluster matters.
Stop Using IRSA. Use Pod Identity.
IRSA (IAM Roles for Service Accounts) has been the standard for pod-level AWS permissions for years, but EKS Pod Identity is the right answer now. The annotation difference is small but the operational model is meaningfully better. With IRSA, your ServiceAccount looks like this:
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-app
namespace: default
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/MyAppRole
With Pod Identity, you drop the annotation entirely and instead create an association through the EKS API or Terraform:
aws eks create-pod-identity-association \
--cluster-name my-cluster \
--namespace default \
--service-account my-app \
--role-arn arn:aws:iam::123456789012:role/MyAppRole
The practical win is decoupling IAM from your Kubernetes manifests. With IRSA, rotating a role ARN means touching your ServiceAccount YAML and redeploying. With Pod Identity, the binding lives in the EKS control plane — your app team manages Kubernetes objects, your platform team manages IAM associations, and neither needs to touch the other’s config. The gotcha: Pod Identity requires the eks-pod-identity-agent add-on running on your nodes, and it won’t silently fall back to IRSA if it’s missing — pods just won’t get credentials. Install the add-on first, verify it’s running with kubectl get pods -n kube-system -l app.kubernetes.io/name=eks-pod-identity-agent, then migrate service accounts one at a time.
- IRSA still works — AWS hasn’t deprecated it, and if you have 200 service accounts already annotated, there’s no emergency migration
- Pod Identity doesn’t support cross-account role assumption chains in the same way IRSA does — if that’s your setup, test carefully before switching
- The SDK version matters — your application needs an AWS SDK version that supports the
eks-auth:AssumeRoleForPodIdentityAPI call; older SDKs fall back to nothing instead of IRSA
Control Plane: The Biggest Practical Difference
EKS gives you a managed control plane and then firmly closes the door behind you. You cannot SSH into it, you cannot query etcd directly, and you cannot pass arbitrary flags to the API server. That last one actually bit us hard. We needed to enable --enable-admission-plugins=PodSecurityPolicy,NodeRestriction,MutatingAdmissionWebhook with a specific webhook configuration timeout that AWS hadn’t exposed through their API. The workaround was ugly — we had to restructure our admission webhook to respond faster rather than getting the extra time we actually needed. If your architecture assumes you can tune the API server, EKS will make you rethink that assumption, not accommodate it.
Self-managed flips this completely. You own the control plane, which means you own every failure mode. The thing that made this real for me was etcd backup discipline. Before every cluster upgrade, without exception, we run:
ETCDCTL_API=3 etcdctl snapshot save /backup/etcd-$(date +%Y%m%d).db \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key
EKS handles this for you silently, which is genuinely nice until you start wondering what the retention policy is and whether you can restore to a specific point-in-time. You can’t trigger a manual etcd snapshot on EKS. You’re trusting AWS’s backup schedule, and that backup is not directly accessible to you. For most teams, that trade-off is completely fine. For teams in heavily regulated environments who need proof of backup or point-in-time restoration control, it’s a real problem.
The Upgrade Story Is Where the Trade-offs Get Concrete
EKS upgrades are initiated with a single command: aws eks update-cluster-version --name my-cluster --kubernetes-version 1.30. Then you wait. The control plane upgrade takes around 20-25 minutes and you have no visibility into what’s happening — no progress bar, just polling aws eks describe-cluster until the status flips from UPDATING to ACTIVE. It’s slow, but it’s boring in the best possible way. kubeadm upgrades are genuinely faster once you know what you’re doing, but “once you know what you’re doing” is doing a lot of work in that sentence.
I learned the node drain lesson the hard way on a self-managed cluster. We ran kubeadm upgrade apply v1.29.0 on the control plane without draining worker nodes first. What broke: kube-proxy on the workers was still running the old version and started logging version skew warnings. A few pods that were talking directly to the API server via in-cluster service accounts hit authentication errors because the API server had upgraded its token validation logic while kube-proxy hadn’t caught up yet. Nothing catastrophically exploded, but we had about 8 minutes of elevated 5xx rates on three services before we drained the workers, ran kubeadm upgrade node on each one, and uncordoned them. The recovery command sequence was:
# On each worker, after the control plane was upgraded
kubectl drain worker-01 --ignore-daemonsets --delete-emptydir-data
ssh worker-01 "sudo kubeadm upgrade node && sudo systemctl restart kubelet"
kubectl uncordon worker-01
The right process is to drain each worker before upgrading it, not after. Obvious in retrospect, less obvious at 11pm when you’re trying to squeeze an upgrade into a maintenance window. EKS handles node group upgrades through managed node group rolling updates, which enforce the drain automatically. You can debate whether having the guardrails imposed on you is infantilizing or just sensible — after that incident, I stopped having strong opinions about it.
Networking: Where Self-managed Gets Complicated Fast
EKS’s default networking story is deceptively simple: every pod gets a real VPC IP address via the AWS VPC CNI. No overlay, no VXLAN, no encapsulation overhead. Pod-to-pod traffic looks like regular VPC traffic, which means your security groups, VPC Flow Logs, and existing network tooling all just work. The thing that caught me off guard the first time was IP exhaustion. If your node is in a /24 subnet, you’re working with 256 addresses total — shared between nodes, pods, and anything else in that subnet. Spin up a few m5.xlarge nodes with 15 pods each and you’ll burn through that allocation fast. The fix is either pre-planning your subnet sizing (use /20 or larger, minimum) or enabling prefix delegation on the VPC CNI, which lets each ENI attach a /28 prefix instead of individual IPs. Missing this detail in initial setup has bitten me more than once.
CNI Choice on Self-managed: Pick Cilium, Don’t Look Back
Self-managed gives you the full CNI menu: Flannel if you want simple and don’t care about network policy, Calico if you want mature and battle-tested, Cilium if you want eBPF-based observability and you’re willing to pay the setup cost upfront. I switched to Cilium after spending too many hours debugging dropped packets with no visibility into why. The Hubble UI alone — real-time service dependency maps, per-flow DNS visibility, drop reason attribution — justifies the choice. Here’s the Helm install that actually works:
helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=<YOUR_API_SERVER_IP> \
--set k8sServicePort=6443 \
--set nativeRoutingCIDR="10.0.0.0/8" \
--set ipam.mode=kubernetes \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true \
--set operator.replicas=1
Two values here that actually matter. kubeProxyReplacement=true tells Cilium to take over everything kube-proxy normally handles using eBPF — this eliminates iptables entirely and you’ll see measurable latency improvements on services with many endpoints. nativeRoutingCIDR is the range Cilium should route natively without encapsulation; set this to your pod CIDR and you get near-zero overhead routing. Get this wrong and Cilium falls back to VXLAN encapsulation silently, which defeats half the point. Verify with cilium status --verbose and look for KubeProxyReplacement: True in the output.
Load Balancer Provisioning: The Biggest Day-2 Gap
On EKS, you install the AWS Load Balancer Controller once and then a Service of type LoadBalancer just provisions an NLB. Done. On self-managed, that same manifest will sit in Pending forever unless you’ve wired something up. MetalLB is the standard answer for bare-metal and most on-prem setups. Here’s the IPAddressPool and L2Advertisement manifest you actually need:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: production-pool
namespace: metallb-system
spec:
addresses:
- 192.168.10.50-192.168.10.100
autoAssign: true
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-advert
namespace: metallb-system
spec:
ipAddressPools:
- production-pool
nodeSelectors:
- matchLabels:
node-role.kubernetes.io/worker: "true"
The L2Advertisement resource is the part that trips people up. MetalLB in L2 mode elects a node to answer ARP requests for each IP — meaning all traffic for that service funnels through one node before spreading to pods. That’s fine for many workloads, but it’s not true load balancing at the network layer. If you need BGP-based load balancing with real ECMP, MetalLB supports it but you need a router that speaks BGP on your network. Most homelab and on-prem setups I’ve seen end up using L2 mode and living with the single-node bottleneck until it actually becomes a problem. Alternatively, skip MetalLB entirely and front your cluster with an external HAProxy or Nginx that routes to NodePorts — less elegant but easier to reason about under pressure at 2am.
The honest summary: EKS networking requires almost no decisions, which is its strength and its limitation. You can’t swap the CNI without significant pain, and you’re tied to AWS’s VPC model. Self-managed with Cilium gives you network policy, observability, and performance that EKS’s VPC CNI doesn’t match — but you’re owning the operational surface. For teams running mixed workloads where visibility into service-to-service traffic is non-negotiable, self-managed wins here. For teams that want to ship features and not think about networking, EKS wins by default.
Storage: EBS CSI, EFS, and the Pain of Getting PVCs Right
Storage: EBS CSI, EKS Add-ons, and the IAM Gotchas Nobody Warns You About
The single most common storage mistake I see new teams make on EKS: they assume EBS storage just works out of the box. It doesn’t. The EBS CSI driver is a managed add-on — meaning you have to explicitly install it, and before you do that, you need an IAM role with the right policy attached to your node group or, better, via IRSA (IAM Roles for Service Accounts). Miss this and your pods will sit in Pending forever with a cryptic failed to provision volume error that points nowhere useful. The managed add-on does at least handle driver version upgrades for you, which is worth something — but the IAM setup is 100% your problem regardless.
On self-managed Kubernetes running on AWS EC2, you’re doing the exact same IAM dance, but now you’re also pinning and upgrading the driver version yourself. The driver lives at github.com/kubernetes-sigs/aws-ebs-csi-driver and you install it via Helm. The catch is that driver versions are tightly coupled to Kubernetes minor versions — run 1.30 nodes with a driver built for 1.28 and you’ll hit subtle failures with volume attachment that only show up under load. At least with EKS managed add-ons, AWS nominally tests these combinations. Self-managed means you own that matrix.
Here’s the StorageClass definition I actually use for gp3 on both setups. The WaitForFirstConsumer binding mode is non-negotiable if you care about avoiding cross-AZ volume attachment failures — and you do, because those failures are silent until a pod is suddenly unable to mount a volume that exists in us-east-1a while the pod scheduled to us-east-1b:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
parameters:
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
allowVolumeExpansion: true
The iops: "3000" and throughput: "125" are the baseline gp3 defaults — you’re not paying extra for those, and they’re significantly better than gp2 defaults. You can push gp3 to 16,000 IOPS and 1,000 MB/s throughput for a cost premium, which makes sense for database workloads. The key thing allowVolumeExpansion: true buys you: you can edit a PVC’s storage request and the CSI driver will call the AWS API to resize the volume online without downtime. On gp3 this actually works. I’ve done it on live production PostgreSQL pods.
EFS for ReadWriteMany: Honest Assessment
EFS gives you ReadWriteMany access, which sounds great until you sit through the aws-efs-csi-driver setup. You need: the driver itself (another Helm install), an EFS filesystem created in your VPC, mount targets in every subnet your pods might land in, a security group that allows NFS traffic on port 2049 from your node security group, and an AccessPoint per StorageClass if you want per-PVC isolation. That’s five distinct AWS resources to coordinate before a single pod can write to a shared volume. The driver’s documentation has improved but the operational surface area is real.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: efs-sc
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap
fileSystemId: fs-0123456789abcdef0
directoryPerms: "700"
gidRangeStart: "1000"
gidRangeEnd: "2000"
basePath: "/dynamic"
My honest take: EFS is worth the pain for exactly one category of workload — stateful applications that genuinely need multiple pods reading and writing the same filesystem simultaneously. Think legacy CMS systems, shared upload directories, or ML training jobs that need a shared checkpoint store. For everything else, reach for S3 first. The AWS SDK is available in every language, S3 versioning gives you better durability than EFS anyway, and you avoid the NFS performance unpredictability entirely. EFS throughput scales with storage used (in the default Bursting mode), which means a nearly-empty filesystem has almost no burst capacity — a trap that bites teams running small shared volumes hard. If you do use EFS, switch the throughput mode to Elastic and accept the per-GB-transferred pricing; it’s more predictable under variable load.
Operational Overhead: The Real Cost Comparison
The thing that caught me off guard the first time I ran an EKS cluster in production wasn’t the $0.10/hour cluster fee — it was the bill at the end of the month that was 3x what I expected. EKS’s per-cluster charge is actually the least of your problems. The real bleed happens in four specific places: NAT gateway data transfer charges (currently $0.045/GB in us-east-1, and your nodes are chatty), Application Load Balancer hours (each type: LoadBalancer service spins up a new ALB at ~$16-18/month minimum before a single byte flows through it), CloudWatch Logs ingestion at $0.50/GB, and cross-AZ traffic between your nodes and control plane. Run kubectl get svc --all-namespaces | grep LoadBalancer on a cluster that’s been running six months and count how many orphaned load balancers your team forgot to clean up. I’ve seen that alone cost $200+/month on a “small” cluster.
Self-managed sounds cheaper until you price in the engineer. Kubernetes control plane upgrades are not a helm upgrade. A minor version bump on a kubeadm cluster looks like this:
# On each control plane node
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.29.x-00
kubeadm upgrade plan
kubeadm upgrade apply v1.29.x
# Then for every node
kubectl drain node01 --ignore-daemonsets --delete-emptydir-data
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.29.x-00 kubectl=1.29.x-00
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon node01
Multiply that by three control plane nodes, then however many workers you have, then do it again in 4 months for the next minor version. That’s not 4 hours/month — that’s 4 hours that month, plus the 2am page when etcd gets into a weird state because one node’s clock drifted. I’ve personally lost a Saturday to a self-managed control plane etcd quorum issue. You also need to build your own observability stack. Prometheus + Grafana + Loki + alerting is maybe a week of work to do right, and then someone has to own it. EKS with CloudWatch Container Insights isn’t great, but it exists on day one without you touching anything.
The Honest Break-Even Math
Let’s use real numbers without making anything up. EKS charges $0.10/hour per cluster, which is $72/month flat before you run a single workload. Add one NAT gateway ($32-45/month depending on data transfer), and two ALBs for a typical ingress setup ($32-36/month), and you’re already at ~$140-150/month in infrastructure overhead before instance costs. Now the self-managed side: three t3.medium control plane nodes (you need them for HA) run about $95/month total in EC2 costs on-demand. But your engineer is spending roughly 4 hours/month on maintenance — upgrades, certificate rotation, etcd backups, investigating control plane weirdness. If that engineer’s fully-loaded cost is anything above $0, those hours matter. At even a modest internal rate, 4 hours isn’t free. The break-even isn’t purely about the $72 EKS fee. It’s about whether your team’s control plane expertise is a good use of their time versus shipping features.
Where Self-Managed Actually Wins
The math flips completely when you’re running many clusters. If you have 10 dev/staging/preview clusters — which is totally normal if you do branch-based environments or have multiple teams — EKS costs you $720/month just in cluster fees, $864/year before a single pod runs. That’s real money that buys you either nothing (if your workloads are lightweight) or a decent chunk of engineering time. This is the scenario where something like k3s on cheap VMs actually makes sense. A k3s control plane on a single t3.small costs about $15/month and handles dev workloads fine:
# Spin up a k3s cluster in under 2 minutes
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.29.0+k3s1" sh -s - \
--disable traefik \
--node-name control-plane-01
# Get the kubeconfig
cat /etc/rancher/k3s/k3s.yaml
The caveat: you need someone who actually knows Kubernetes internals to own those self-managed clusters. Not “has used kubectl for a year” — knows what happens when the API server can’t reach etcd, knows how to recover from a botched upgrade, knows how to read journalctl -u kubelet and actually understand the output. If that person leaves your team, your self-managed clusters become a liability overnight. EKS is a hedge against that bus factor. The honest recommendation: use EKS for production where the $72/cluster/month is noise compared to an outage, and use k3s or similar for your fleet of throwaway dev environments where the per-cluster fee actually matters.
Head-to-Head Comparison Table
Head-to-Head Comparison
The honest version of this comparison starts with a confession: I spent three months running self-managed Kubernetes on EC2 before moving to EKS, and the thing that broke me wasn’t the complexity — it was upgrade day. More on that in a second. Here’s the full picture first.
| Dimension | Self-Managed (kubeadm/kops) | EKS |
|---|---|---|
| Control Plane Management | Full control — you own etcd, kube-apiserver, scheduler. You can tune everything. You also fix everything when it breaks at 2am. | AWS-managed. You cannot SSH into control plane nodes. You cannot see etcd directly. That’s the deal. |
| Upgrade Experience | Manual, but genuinely fast if you know what you’re doing. kubeadm upgrade apply v1.30.0 can go smooth in 20 minutes — or surface a broken admission webhook you forgot about. |
Managed, but slower. AWS rolls out new versions weeks to months after upstream. You click upgrade in console or run aws eks update-cluster-version, but you’re waiting on AWS’s schedule, not Kubernetes’s. |
| Networking Flexibility | Install any CNI you want — Cilium, Calico, Flannel, Weave (if you’re feeling nostalgic). Full eBPF dataplane available without caveats. | VPC CNI is the default and it works well, but it burns through your subnet IPs fast. You can swap to Cilium in chained mode, but the docs are incomplete and you’ll spend a day on it. |
| IAM Integration | Manual RBAC + OIDC setup. You’re configuring --oidc-issuer-url yourself, writing your own ServiceAccount annotations, and testing if kube2iam or kiam actually works reliably (spoiler: kiam has rough edges). |
IRSA and Pod Identity are native and well-integrated. eksctl create iamserviceaccount handles the full chain. Pod Identity (the newer approach) is cleaner still — no annotation magic required. |
| Observability Out-of-Box | You build it. Prometheus + Grafana + Loki is the standard stack. That’s a Helm chart away — but you’re also owning the storage, retention, and alerting config from day one. | CloudWatch Container Insights gives you basic node/pod metrics and logs with minimal config. Useful as a baseline. Most teams still bolt on Prometheus anyway because CloudWatch queries are expensive and slow. |
| Minimum Viable Team Size | Two dedicated platform engineers minimum — and that’s if they’re experienced. One person doing self-managed Kubernetes alongside product work is a recipe for 3am incidents. | One engineer can realistically manage an EKS cluster while doing other work. The operational surface area is smaller because the hard parts (etcd, apiserver HA) are AWS’s problem. |
| Rough Cost Floor | EC2 cost only for control plane nodes — typically 3x t3.medium or similar for HA. Roughly $90–$120/month just for masters, before your workload nodes. |
$0.10/hr per cluster (~$73/month) plus your EC2 or Fargate node costs. That cluster fee adds up fast if you’re running 10+ clusters across envs. |
| Biggest Dealbreaker | Etcd operations. Backup, restore, compaction, defragmentation — if you haven’t done a real etcd restore under pressure, you don’t know what you’re signing up for. | Version lag. Kubernetes 1.32 ships upstream; you might wait 3–4 months before it’s available on EKS. If you need a specific API or feature fast, you’re stuck. |
The upgrade experience gap is where I see teams get surprised. Self-managed feels slower because you’re doing it manually, but the actual execution time is faster. With kubeadm you can be on 1.31 the day it drops:
# Drain control plane node
kubectl drain cp-node-1 --ignore-daemonsets --delete-emptydir-data
# On the control plane node
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.31.0-1.1
kubeadm upgrade apply v1.31.0
# Verify
kubeadm upgrade plan
kubectl uncordon cp-node-1
With EKS, you’re waiting for AWS to certify the version, push AMIs, and open the upgrade path. The aws eks update-cluster-version --name my-cluster --kubernetes-version 1.31 command is simpler — but you can only run it when AWS says the version is available. For teams tracking upstream CVEs or needing a specific Gateway API version, this lag is a real operational constraint, not a minor inconvenience.
The cost math also shifts depending on cluster count. A single EKS cluster at $73/month cluster fee is negligible. Run eight clusters (prod, staging, dev, preview envs across two regions) and you’re paying $584/month before a single pod runs. Self-managed doesn’t have that per-cluster tax, which is why platform teams building internal developer platforms sometimes prefer it — you can spin up throwaway clusters cheaply. On the flip side, self-managed control plane nodes aren’t free either, and if you’re running three m5.large instances for HA masters, you’re at ~$200/month in EC2 alone per cluster, so do the math for your specific topology before assuming self-managed is cheaper.
The IAM story is where EKS genuinely wins with no asterisk. Pod Identity (introduced in late 2023, now the recommended path) is cleaner than IRSA was:
# Associate IAM role with a service account via Pod Identity
aws eks create-pod-identity-association \
--cluster-name my-cluster \
--namespace my-app \
--service-account my-service-account \
--role-arn arn:aws:iam::123456789012:role/my-app-role
No annotation juggling on the ServiceAccount, no OIDC thumbprint rotation surprises. Self-managed OIDC works, but I’ve seen teams spend two days debugging token validation failures because their issuer URL had a trailing slash mismatch. These are the stupid problems EKS just eliminates.
When to Pick Self-managed Kubernetes
The EKS control plane fee is $0.10/hour per cluster — roughly $73/month. That sounds trivial until you’re running 40 clusters for dev, staging, and per-tenant isolation. That’s $2,920/month before you’ve scheduled a single pod. I’ve seen teams rationalize this cost for years, then switch to kubeadm-on-EC2 or Talos Linux once someone actually put the number in a spreadsheet. If your architecture has more than ~15 clusters and they’re not all production workloads, self-managed is worth the operational overhead just on economics alone.
You’re on bare metal or an unsupported provider
This one’s non-negotiable. If you’re colocating hardware in Equinix Metal, running on Hetzner, or using a regional cloud that doesn’t offer a managed control plane, self-managed is your only path. Hetzner in particular is popular for cost-sensitive European workloads — a CCX33 dedicated instance runs around €0.50/hour and you can bootstrap a full cluster with hcloud-cloud-controller-manager in under an hour. The gotcha I hit: cloud controller managers on non-AWS providers often lag behind upstream Kubernetes releases by 2-3 minor versions. Pin your Kubernetes version until the CCM catches up, or you’ll spend a weekend debugging LoadBalancer services that silently fail.
You need flags EKS doesn’t expose
EKS manages your API server. That’s the deal. You don’t get to pass arbitrary flags to kube-apiserver, and the list of supported admission controllers is fixed by AWS. Here’s what that means practically: if you want to enable --audit-log-maxbackup, use a custom admission webhook for mutating specific resource shapes, or enable --feature-gates=ValidatingAdmissionPolicy=true before AWS decides to ship it — you can’t. On a self-managed cluster with kubeadm, your control plane config looks like this:
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
apiServer:
extraArgs:
audit-log-path: /var/log/kubernetes/audit.log
audit-log-maxage: "30"
audit-log-maxbackup: "10"
audit-log-maxsize: "100"
feature-gates: "ValidatingAdmissionPolicy=true,CELValidatingAdmissionPolicy=true"
extraVolumes:
- name: audit-logs
hostPath: /var/log/kubernetes
mountPath: /var/log/kubernetes
writable: true
That level of control matters if you’re in a regulated environment where your compliance team hands you a CIS benchmark and says “pass every check.” Some of those checks require audit log settings that EKS simply won’t give you.
CNI flexibility is a real differentiator
EKS defaults to the AWS VPC CNI. You can replace it, but you’re fighting the grain of the platform — IAM assumptions, IP addressing behavior, and EKS add-on management all assume VPC CNI is there. Self-managed clusters let you start with Cilium from day one. And Cilium on bare metal or non-AWS hardware unlocks things that are genuinely hard to replicate otherwise: eBPF-based dataplane that bypasses iptables entirely (meaningful throughput difference at 10k+ connections/sec), WireGuard-encrypted pod-to-pod traffic with a single Helm value:
# cilium install with WireGuard node-to-node encryption helm install cilium cilium/cilium --version 1.15.0 \ --namespace kube-system \ --set encryption.enabled=true \ --set encryption.type=wireguard \ --set encryption.nodeEncryption=true \ --set kubeProxyReplacement=strict \ --set bpf.masquerade=true
The thing that caught me off guard: WireGuard encryption on Cilium adds about 10-15% CPU overhead on high-throughput nodes, but eliminates the need for a service mesh for encryption. If you were going to run Istio just for mTLS, this trade-off is often a net win on complexity and resource cost.
Platform engineering as a first-class job function
This is the honest filter that most teams skip. Self-managed Kubernetes isn’t just “more work upfront.” It’s ongoing work: etcd backup strategies, control plane upgrades that don’t drain your worker nodes, certificate rotation before your cluster stops accepting connections (the default kubeadm cert expiry is 1 year — set a calendar reminder or automate renewal with kubeadm certs renew all in a CronJob). If your “platform team” is two backend developers who also own the product’s API, this will end badly around 2am on a weekday. But if you have engineers whose primary KPI is cluster reliability and developer experience — people who are excited to read the etcd changelog — self-managed will give them the use they want and the visibility they need to do the job properly.
- etcd should be on dedicated nodes or external, not co-located with API server on small clusters
- Control plane upgrades with kubeadm follow a strict one-minor-version-at-a-time rule — skipping 1.28 → 1.30 directly will break things
- Run
kubeadm upgrade planbefore every upgrade cycle; it catches version skew issues before they’re production problems - Velero for cluster-state backup is non-optional; losing etcd on a self-managed cluster means rebuilding from manifests
When to Pick EKS
Here’s the scenario I see constantly: a five-person backend team, one of whom half-jokingly gets called “the Kubernetes person” because they went to KubeCon once. That developer is also writing features, doing code reviews, and on the on-call rotation. In 2023, asking that person to also manage a self-managed control plane was a slow-motion disaster — etcd upgrades, API server cert rotations, kube-proxy DaemonSet version drift. EKS Auto Mode in 2026 changes that math. Node provisioning, OS patching, and component upgrades happen without a pull request. Your Kubernetes person can actually do Kubernetes work instead of “why is kubelet on this node three minor versions behind” work. That’s the real pitch.
The AWS Integration Argument Isn’t About Convenience, It’s About Toil
If your stack is already AWS-native — Route53 for DNS, ACM for certs, ALB for ingress, IAM for everything — running self-managed Kubernetes means you’re either writing custom controllers or duct-taping together third-party operators to bridge those gaps. I’ve seen teams spend two weeks getting a self-managed cluster to talk to ACM correctly for cert provisioning. With EKS, you get the AWS Load Balancer Controller, ExternalDNS with native Route53 support, and IRSA (IAM Roles for Service Accounts) out of the box. IRSA specifically is underrated. Instead of baking credentials into pods or using instance profiles with way too much scope, you get per-pod IAM bindings:
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/my-app-role
That’s it. No Vault agent sidecars, no secrets operator, no credential rotation logic. The OIDC federation between EKS and IAM handles it. On self-managed, replicating this cleanly takes real effort and ongoing maintenance.
Compliance Is Where Self-Managed Gets Painful Fast
SOC 2, PCI-DSS, HIPAA — these audits love asking “show me your patch management process for the Kubernetes control plane.” With EKS, the answer is a link to the AWS shared responsibility model documentation and a screenshot of your cluster version history in the console. AWS handles control plane security patching; that’s explicitly in their SLA. With self-managed, you’re writing runbooks, building audit trails, and explaining to auditors why your kube-apiserver wasn’t patched for 47 days because the upgrade broke a CRD. I’m not saying self-managed can’t pass audits — it can — but you’re doing substantially more work to get there, and that work isn’t shipping product.
Karpenter on EKS Is Genuinely the Best Node Autoscaling Experience Right Now
Karpenter is technically cloud-agnostic, but let’s be honest — the EKS + Karpenter combination is where the tooling is mature, the docs are battle-tested, and the edge cases have been hit by enough people that Stack Overflow has answers. A basic NodePool that provisions right-sized Spot instances looks like this:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: node.kubernetes.io/instance-type
operator: In
values: ["m6i.large", "m6a.large", "m5.large"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
limits:
cpu: 1000
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s
The consolidateAfter: 30s means Karpenter will bin-pack and terminate underutilized nodes aggressively. On a self-managed cluster using the Cluster Autoscaler, you’re waiting minutes for scale-down decisions and fighting with node group rigidity. Karpenter on EKS provisions new node types dynamically — if m6i.large Spot capacity dries up, it’ll grab an m6a.large without you touching anything.
The 3 AM Call You’re Trying to Avoid
etcd leader elections fail. It happens. On a self-managed cluster, that failure wakes you up. You’re SSHing into control plane nodes, checking etcdctl endpoint health, potentially dealing with a split-brain scenario, and hoping your backup from four hours ago is good enough. The blast radius is your entire cluster — every workload, every deployment, every CronJob, gone until you sort it out. EKS’s managed control plane runs etcd across multiple availability zones with AWS handling the HA configuration. You don’t have access to those nodes, which sounds limiting until 3 AM when you realize “not my problem” is actually a feature. The one genuine trade-off: you can’t tune etcd directly, which matters if you’re storing large custom resources or have unusual watch patterns. For most applications, you’ll never hit that ceiling.
The Moment EKS Won for Us (And the Moment It Didn’t)
2:07 AM. PagerDuty fires. One of our three control plane nodes on our self-managed cluster had gone offline — EC2 instance just died, no warning. I spent the next 40 minutes SSHing into the remaining nodes, confirming etcd quorum was still intact (it was, barely — two of three nodes), manually terminating and relaunching the bad instance, then watching kubeadm refuse to rejoin it cleanly because the certificates had rotated six days prior and the node’s bootstrap config was stale. That incident cost us about 90 minutes of degraded cluster state and two hours of my sleep. On EKS, this literally does not happen to you. AWS replaces control plane nodes without any notification because you don’t own them. You never see the event. Your engineers sleep.
That night I started the migration conversation. But EKS has a hard ceiling that we slammed into about four months later. We needed to enforce a specific PodSecurity policy — not the built-in restricted or baseline labels, but a custom admission configuration that defined per-namespace exception profiles using --admission-control-config-file on the API server. On a self-managed cluster you just edit the kube-apiserver manifest:
# /etc/kubernetes/manifests/kube-apiserver.yaml (self-managed)
- --admission-plugins=NodeRestriction,PodSecurity
- --admission-control-config-file=/etc/kubernetes/admission-config.yaml
On EKS, you don’t touch that file. You don’t touch any API server flags. When I opened a support ticket asking how to pass custom admission control configuration, the response was unambiguous:
The Amazon EKS managed Kubernetes control plane does not support
custom --admission-control-config-file flags. API server configuration
is managed by AWS and cannot be modified by customers. For custom
admission logic, we recommend OPA Gatekeeper or Kyverno.
No workaround exists at the API server level. Full stop. What we did instead was migrate entirely to OPA Gatekeeper, which honestly ended up being more powerful — but the migration took a week we hadn’t planned for. The Gatekeeper equivalent of a custom PodSecurity profile looks like this:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPSPPrivilegedContainer
metadata:
name: deny-privileged-containers
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
excludedNamespaces:
- kube-system
- monitoring
You get finer-grained control over exceptions per namespace, and the policies are version-controlled like any other Kubernetes resource. The trade-off is operational overhead — Gatekeeper itself is a deployment you manage, it adds webhook latency to pod admission (usually under 10ms but measurable), and when Gatekeeper is misconfigured it will block all pod scheduling in ways that are genuinely terrifying the first time you see them. Set failurePolicy: Ignore on non-critical constraints until you trust your policies.
Here’s the honest verdict: if you have a four-person team and your job is to ship product, EKS is the correct answer. The control plane tax — patching, certificate rotation, etcd backups, multi-AZ node distribution, API server availability — disappears. You pay for it with reduced control, roughly $0.10/hour per cluster for the managed control plane, and occasional frustration when AWS’s defaults don’t match your security model. But for a platform team where Kubernetes infrastructure is the product — where you’re managing dozens of clusters, running your own admission webhooks, or supporting teams with wildly different compliance requirements — self-managed is worth it. You’ll spend the time you saved from incident response on deliberate configuration instead, which is a trade most platform engineers prefer.
FAQ
Can I switch from self-managed to EKS without downtime?
Yes, but not in a single weekend. The thing that caught me off guard the first time I did this migration was that “zero downtime” and “zero risk” are very different things. The practical approach is blue/green at the cluster level: spin up the EKS cluster in parallel, shift traffic incrementally using weighted DNS or a load balancer target group, then drain the old cluster. Don’t even attempt an in-place migration — Kubernetes doesn’t have a native “hand off these workloads to a different control plane” command, so anyone promising that is selling you something.
Here’s the actual flow I use. First, get Velero running on both clusters:
# Install Velero on old cluster with S3 backup location
velero install \
--provider aws \
--plugins velero/velero-plugin-for-aws:v1.9.0 \
--bucket my-velero-backups \
--backup-location-config region=us-east-1 \
--snapshot-location-config region=us-east-1 \
--secret-file ./credentials-velero
# Kick off a full backup
velero backup create pre-migration-snapshot --include-namespaces=production
Stateless workloads move cleanly. The pain is stateful ones. If you’re running PVCs backed by hostPath or local storage on the old cluster, you need to migrate data to EBS or EFS before the switchover — plan a maintenance window for that part specifically. For databases, I always prefer migrating the data layer separately (RDS, snapshot restore, whatever fits) and decoupling it from the Kubernetes migration entirely. DNS cutover with a 60-second TTL set 24 hours beforehand is your best friend here. Don’t flip TTL on the day of the migration.
Is EKS Auto Mode worth it, or does it take away too much control?
Depends entirely on your team. EKS Auto Mode handles node provisioning, scaling, OS patching, and Kubernetes version upgrades automatically. If you’re a team of 3 running 15 microservices, this is a gift. If you’re a platform team that has specific kernel parameters, custom AMIs, or FIPS compliance requirements baked into your node bootstrap scripts, Auto Mode will frustrate you constantly because you lose direct access to node configuration and launch templates.
The pricing reality: Auto Mode nodes carry a surcharge on top of the EC2 instance cost — currently $0.0012 per vCPU-hour and $0.0012 per GB-hour of memory managed. On a cluster running r6i.4xlarge instances (16 vCPU, 128GB RAM) that’s roughly an extra $340/month per node just for the management overhead. For a 20-node cluster that’s $6,800/month on top of EC2. Do that math before committing.
The control question is real. You cannot use custom AMIs with Auto Mode — it uses AWS-managed Bottlerocket nodes only. You can’t run privileged init containers that require specific kernel modules loaded at boot. I’d use Auto Mode if: your workloads are standard web/API services, you don’t have compliance requirements that mandate custom OS hardening, and your team genuinely doesn’t want to think about nodes. I’d skip it if you have GPU workloads with custom driver versions, eBPF tools that need kernel 5.15+, or any security baseline that requires CIS benchmark customization at the OS layer.
What’s the minimum node count for a production-ready self-managed cluster?
Three control plane nodes and three worker nodes is the floor, and that’s not just an opinionated take — etcd requires a quorum. With two control plane nodes you have no quorum tolerance. One node goes down for patching, you’re in a split-brain situation and the cluster stops accepting writes. Three control plane nodes tolerate one failure. That’s your minimum.
# Quick sanity check on etcd cluster health
ETCDCTL_API=3 etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
endpoint health --cluster
# You want to see this for all three members:
# https://10.0.1.10:2379 is healthy: committed version:3, raft version:3
For workers, three nodes spread across three availability zones is the minimum to handle AZ-level failures without taking down your whole app. In practice I run a 3 control plane / 3 worker setup for low-traffic internal tooling and bump to 3 control plane / 5+ workers for anything customer-facing. The workers matter more than you think for PodDisruptionBudgets — if you have minAvailable: 2 on a deployment and only 2 workers, any node drain for patching will block indefinitely. With 3 workers, draining one still leaves two available and the drain completes cleanly. That’s a gotcha that bites people running tight clusters.
Does EKS support the latest Kubernetes version faster than self-managed setups?
Self-managed wins on raw speed to latest version, but EKS catches up within a few weeks and the gap has narrowed significantly. With kubeadm or k3s you can be on a new minor version the day it’s tagged upstream. EKS typically supports a new minor version 2–6 weeks after the upstream GA release. That lag exists because AWS validates the version against their CNI (VPC CNI), load balancer controller, and EBS CSI driver before releasing it — which is actually useful validation you’d otherwise have to do yourself.
The more interesting question is end-of-life. EKS deprecates versions on a published schedule (14 months of support per minor version as of the current policy) and will force-upgrade your cluster if you miss the window. Self-managed gives you no such forcing function, which means I’ve seen teams running 1.24 in production in mid-2026 because nobody prioritized upgrades. That’s not freedom, that’s technical debt accumulating interest.
If you genuinely need to be on 1.33 the week it drops — because of a specific API you’re depending on or a security fix — use self-managed. For most teams the EKS lag is irrelevant, and the automatic compatibility testing AWS does is worth the wait. Check the current EKS version support calendar at https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html before planning any upgrade cycle; they publish exact end-of-standard-support dates there.