How to Set Up a Kubernetes Cluster on AWS

How to Set Up a Kubernetes Cluster on AWS

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

eksctl utils update-cluster-logging \
  --cluster production-cluster \
  --enable-types all \
  --approve
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

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

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.

References

Exit mobile version