When I first tried running Kubernetes on AWS by hand-provisioning EC2 instances and wiring up the control plane myself, I understood pretty quickly why most people just use EKS instead. Managing etcd, certificate rotation, and control plane upgrades yourself is a real time sink that adds little value for most teams. In this guide I’ll walk through setting up a production-ready cluster using Amazon EKS (Elastic Kubernetes Service), the managed path, and also cover what self-managed kubeadm-on-EC2 looks like for cases where you genuinely need that control.
Why EKS Over Self-Managed
EKS manages the Kubernetes control plane for you — API server, etcd, scheduler — with AWS handling upgrades, patching, and multi-AZ control plane availability. You’re still responsible for worker nodes, but even that can be significantly automated via managed node groups or Fargate. Unless you have a specific compliance or cost reason to self-manage the control plane, EKS is almost always the right default.
Step 1: Prerequisites
# Install the AWS CLI, eksctl, and kubectl
aws --version
eksctl version
kubectl version --client
aws configure # set your access key, secret, region
Step 2: Create a Cluster with eksctl
eksctl is by far the fastest path — it handles VPC, subnets, IAM roles, and the EKS control plane in one command:
eksctl create cluster \
--name production-cluster \
--region us-east-1 \
--version 1.30 \
--nodegroup-name standard-workers \
--node-type m5.large \
--nodes 3 \
--nodes-min 3 \
--nodes-max 10 \
--managed
This takes roughly 15–20 minutes, provisioning a VPC across multiple availability zones, the EKS control plane, and a managed node group. Output on completion:
[✓] EKS cluster "production-cluster" in "us-east-1" region is ready
Verify access:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-192-168-45-12.ec2.internal Ready <none> 2m v1.30.0-eks
ip-192-168-67-89.ec2.internal Ready <none> 2m v1.30.0-eks
ip-192-168-89-34.ec2.internal Ready <none> 2m v1.30.0-eks
Step 3: Using a Declarative Config Instead (Recommended for Real Teams)
For anything beyond a quick test, define the cluster as YAML so it’s reviewable and repeatable:
# cluster.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: production-cluster
region: us-east-1
version: "1.30"
vpc:
cidr: "10.0.0.0/16"
nat:
gateway: HighlyAvailable
managedNodeGroups:
- name: standard-workers
instanceType: m5.large
minSize: 3
maxSize: 10
desiredCapacity: 3
volumeSize: 50
privateNetworking: true
labels:
role: general
tags:
Environment: production
iam:
withOIDC: true
eksctl create cluster -f cluster.yaml
withOIDC: true is worth calling out specifically — it enables IAM Roles for Service Accounts (IRSA), which lets individual Kubernetes workloads assume specific, narrowly-scoped AWS IAM roles rather than sharing broad node-level permissions. This is foundational for good AWS-side security posture.
Step 4: Set Up IAM Roles for Service Accounts (IRSA)
This is how you grant a Pod (e.g., one that needs to read from S3) least-privilege AWS access without putting long-lived credentials in a Secret:
eksctl create iamserviceaccount \
--name s3-reader \
--namespace production \
--cluster production-cluster \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \
--approve
Reference it in your Pod spec:
apiVersion: v1
kind: Pod
metadata:
name: myapp
namespace: production
spec:
serviceAccountName: s3-reader
containers:
- name: myapp
image: myrepo/myapp:1.0.0
The Pod now automatically gets temporary AWS credentials scoped exactly to the attached policy, with no static keys anywhere.
Step 5: Install the AWS Load Balancer Controller
This is what makes Service type LoadBalancer and Ingress resources provision real AWS ALBs/NLBs correctly:
eksctl create iamserviceaccount \
--cluster production-cluster \
--namespace kube-system \
--name aws-load-balancer-controller \
--attach-policy-arn arn:aws:iam::<account-id>:policy/AWSLoadBalancerControllerIAMPolicy \
--approve
helm repo add eks https://aws.github.io/eks-charts
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=production-cluster \
--set serviceAccount.create=false \
--set serviceAccount.name=aws-load-balancer-controller
Step 6: Storage — the EBS CSI Driver
For PersistentVolumeClaim-backed workloads:
eksctl create addon --cluster production-cluster --name aws-ebs-csi-driver --force
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY
gp2 (default) kubernetes.io/aws-ebs Delete
I’d typically create a dedicated StorageClass with gp3 and Retain for production data instead of relying on the default:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-retain
provisioner: ebs.csi.aws.com
parameters:
type: gp3
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
Step 7: Cluster Autoscaling with Karpenter
Rather than the older Cluster Autoscaler, AWS now generally recommends Karpenter for faster, more cost-efficient node provisioning:
helm repo add karpenter https://charts.karpenter.sh
helm install karpenter karpenter/karpenter \
-n karpenter --create-namespace \
--set settings.clusterName=production-cluster
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["m", "c", "r"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
limits:
cpu: 1000
Step 8: Networking and Security
- Keep worker nodes in private subnets, with a NAT gateway for outbound internet access — never expose node instances directly.
- Use security groups for Pods if you need fine-grained network isolation beyond NetworkPolicies, a feature specific to the AWS VPC CNI.
- Enable EKS control plane logging (API server, audit, authenticator logs) shipped to CloudWatch:
eksctl utils update-cluster-logging \
--cluster production-cluster \
--enable-types all \
--approve
- Apply RBAC on top of IAM — an IAM user/role having
eks:DescribeClusteraccess only gets them cluster connectivity; actual Kubernetes-level permissions still flow through theaws-authConfigMap or (on newer EKS versions) EKS access entries.
eksctl create iamidentitymapping \
--cluster production-cluster \
--arn arn:aws:iam::<account-id>:role/DeveloperRole \
--group system:masters \
--username developer
I’d avoid system:masters for anyone other than true admins — map to a more scoped RBAC group instead for regular developers.
Step 9: Cost Optimization
- Mix Spot and On-Demand instances via Karpenter for non-critical, interruption-tolerant workloads.
- Right-size node instance types based on actual
kubectl top nodesusage rather than guessing. - Use Fargate profiles for bursty, low-traffic namespaces where paying per-Pod is cheaper than maintaining always-on EC2 capacity:
eksctl create fargateprofile \
--cluster production-cluster \
--name batch-jobs \
--namespace batch
Debugging Cluster Issues
eksctl utils describe-stacks --cluster production-cluster
kubectl get events -A --sort-by=.lastTimestamp
aws eks describe-cluster --name production-cluster --query cluster.status
For node-level issues, check that the node IAM role has the required managed policies (AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, AmazonEKS_CNI_Policy) — missing one of these is a very common cause of nodes failing to join the cluster.
Best Practices
- Always use
withOIDCand IRSA — never grant node-level IAM roles broad permissions that all Pods on that node inherit by default. - Spread node groups across multiple AZs for real high availability.
- Enable EKS control plane audit logging from day one — retrofitting it after an incident is far less useful.
- Keep the EKS version reasonably current; AWS deprecates old versions on a defined schedule and force-upgrades clusters that fall too far behind.
- Tag all resources consistently for cost allocation, since EKS-related AWS spend spans EC2, EBS, ELB, and more.
Multi-AZ and Disaster Recovery Considerations
EKS control planes are inherently multi-AZ by design — AWS runs the API server and etcd across at least three availability zones automatically, so that part of high availability comes for free. Worker node high availability is your responsibility, though, and it’s worth being deliberate about spreading node groups across AZs explicitly rather than assuming it happens automatically:
managedNodeGroups:
- name: standard-workers
instanceType: m5.large
minSize: 6
maxSize: 15
desiredCapacity: 6
availabilityZones: ["us-east-1a", "us-east-1b", "us-east-1c"]
Pair this with topologySpreadConstraints on your workloads (see the stateless application guide) so Kubernetes actually distributes replicas across those zones rather than happening to cluster them in one. For genuine disaster recovery beyond a single AWS region — protecting against a full regional outage — you’re looking at running a second EKS cluster in another region, with either an active-active or active-passive traffic strategy via Route 53 health checks, and application-level data replication (most managed database services support cross-region replicas) since Kubernetes itself has no native concept of cross-region failover.
Networking Deep Dive: the AWS VPC CNI
By default, EKS uses the AWS VPC CNI, which assigns each Pod a real IP address from your VPC’s CIDR range — a meaningfully different model from the overlay networks (Calico, Flannel) common in self-managed clusters. This gives Pods native VPC connectivity (useful for talking directly to RDS, ElastiCache, and other VPC-resident AWS services without extra NAT hops) but also means Pod IP exhaustion is a real, easy-to-hit constraint: each node type only supports a fixed maximum number of ENIs and IPs per ENI, capping how many Pods can run per node regardless of CPU/memory headroom.
kubectl describe node <node-name> | grep -A 5 "Allocatable"
If you’re running many small Pods per node and hitting this ceiling before compute resources are exhausted, either move to larger instance types (more ENI/IP capacity), enable prefix delegation to multiply available IPs per ENI, or switch specific workloads to Fargate, which sidesteps the per-node IP ceiling entirely by giving each Pod its own dedicated compute environment.
Multi-Cluster and Multi-Account Strategies
As organizations grow, a single shared EKS cluster for every team and environment tends to become a governance and blast-radius problem. A common pattern is separating clusters by environment (dev/staging/production) and sometimes by AWS account entirely, using AWS Organizations and cross-account IAM roles to keep production genuinely isolated:
eksctl create cluster -f cluster.yaml --profile production-account
eksctl create cluster -f cluster-staging.yaml --profile staging-account
For teams managing several clusters this way, tools like ArgoCD or Flux (GitOps controllers) become worth adopting early — they let you define desired cluster state declaratively in git and have each cluster reconcile toward it independently, rather than manually running kubectl apply or eksctl commands against each cluster by hand as the fleet grows.
Upgrade Strategy
EKS deprecates old Kubernetes minor versions on a defined schedule (typically supporting each version for about 14 months from release), and upgrades should be planned deliberately rather than reactively when AWS’s deprecation deadline arrives:
eksctl upgrade cluster --name production-cluster --version 1.31 --approve
eksctl upgrade nodegroup --cluster production-cluster --name standard-workers
I always upgrade the control plane first, confirm cluster health, then roll node groups one at a time (rather than all at once) so a problem surfaces on a subset of capacity rather than the whole fleet simultaneously — and I check the Kubernetes changelog for each version bump specifically for API deprecations that might affect manifests already running in the cluster, since those tend to be the actual source of upgrade-related breakage far more often than the node-level mechanics themselves.
Summary
Setting up Kubernetes on AWS is most efficiently done through EKS, letting AWS manage the control plane while you focus on node groups, networking, IAM integration via IRSA, storage, and autoscaling. eksctl with a declarative YAML config gets you a production-capable cluster in about 20 minutes, and layering in the AWS Load Balancer Controller, EBS CSI driver, and Karpenter rounds out a setup that can genuinely run production workloads at scale.