> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# EKS Cluster Setup

> Provision an AWS EKS cluster with VPC, networking, IAM roles, node groups, and add-ons for xpander.ai

<Info>
  **Skip this guide** if you already have an EKS cluster with sufficient CPU/memory and the ability to create LoadBalancer services. Proceed directly to [PrivateLink](/self-hosted/hybrid#privatelink) or [Hybrid deployment](/self-hosted/hybrid).
</Info>

This guide provisions a production-ready EKS cluster from scratch: VPC with public/private subnets, IAM roles, node groups, and required add-ons. Running EKS Auto Mode instead? Read [EKS Auto Mode](#eks-auto-mode) at the end; three of the steps below change.

## Prerequisites

* **AWS CLI v2** configured with appropriate credentials
* **kubectl** installed
* **Helm v3** installed
* An AWS account with permissions to create EKS clusters, VPCs, and IAM roles

***

## 1. Create a VPC

Create a VPC with public and private subnets across two availability zones.

```bash theme={"dark"}
# Create VPC
aws ec2 create-vpc \
  --cidr-block 10.100.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=xpander-vpc},{Key=Project,Value=xpander}]' \
  --region <REGION> --profile <PROFILE>

# Enable DNS
aws ec2 modify-vpc-attribute --vpc-id <VPC_ID> --enable-dns-support '{"Value":true}' \
  --region <REGION> --profile <PROFILE>
aws ec2 modify-vpc-attribute --vpc-id <VPC_ID> --enable-dns-hostnames '{"Value":true}' \
  --region <REGION> --profile <PROFILE>
```

Create four subnets:

| Subnet | CIDR | AZ | Type | Tag |
| - | - | - | - | - |
| public-a | `10.100.1.0/24` | AZ-a | Public | `kubernetes.io/role/elb: 1` |
| public-b | `10.100.2.0/24` | AZ-b | Public | `kubernetes.io/role/elb: 1` |
| private-a | `10.100.10.0/24` | AZ-a | Private | `kubernetes.io/role/internal-elb: 1` |
| private-b | `10.100.20.0/24` | AZ-b | Private | `kubernetes.io/role/internal-elb: 1` |

```bash theme={"dark"}
# Create subnets (repeat for each)
aws ec2 create-subnet \
  --vpc-id <VPC_ID> \
  --cidr-block <CIDR> \
  --availability-zone <AZ> \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=<NAME>},{Key=kubernetes.io/role/elb,Value=1}]' \
  --region <REGION> --profile <PROFILE>
```

## 2. Create Internet Gateway and NAT Gateway

<Note>
  **Air-Gapped installs skip this step.** A sealed VPC has no internet or NAT gateways; the cluster reaches AWS services through VPC endpoints instead, at minimum `ecr.api`, `ecr.dkr`, and the S3 gateway endpoint (image pulls when your mirror is ECR), `ec2` and `sts`, plus `eks-auth` if any add-on uses EKS Pod Identity (the EBS CSI setup below does). Add `bedrock-runtime` when using Bedrock over PrivateLink, and the `ssm`/`ssmmessages`/`ec2messages` trio if operators connect through Session Manager. Keyless Bedrock on any private-subnet cluster (Hybrid included) also needs `sts`, `eks-auth` (Pod Identity) and `bedrock-runtime` endpoints.
</Note>

```bash theme={"dark"}
# Internet Gateway
aws ec2 create-internet-gateway \
  --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=xpander-igw}]' \
  --region <REGION> --profile <PROFILE>
aws ec2 attach-internet-gateway --internet-gateway-id <IGW_ID> --vpc-id <VPC_ID> \
  --region <REGION> --profile <PROFILE>

# Elastic IP for NAT
aws ec2 allocate-address --domain vpc --region <REGION> --profile <PROFILE>

# NAT Gateway (in a public subnet)
aws ec2 create-nat-gateway \
  --subnet-id <PUBLIC_SUBNET_A_ID> \
  --allocation-id <EIP_ALLOC_ID> \
  --tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=xpander-nat}]' \
  --region <REGION> --profile <PROFILE>
```

## 3. Route Tables

```bash theme={"dark"}
# Public route table → Internet Gateway
aws ec2 create-route-table --vpc-id <VPC_ID> --region <REGION> --profile <PROFILE>
aws ec2 create-route --route-table-id <PUBLIC_RT_ID> \
  --destination-cidr-block 0.0.0.0/0 --gateway-id <IGW_ID> \
  --region <REGION> --profile <PROFILE>
aws ec2 associate-route-table --route-table-id <PUBLIC_RT_ID> \
  --subnet-id <PUBLIC_SUBNET_A_ID> --region <REGION> --profile <PROFILE>
aws ec2 associate-route-table --route-table-id <PUBLIC_RT_ID> \
  --subnet-id <PUBLIC_SUBNET_B_ID> --region <REGION> --profile <PROFILE>

# Private route table → NAT Gateway
aws ec2 create-route-table --vpc-id <VPC_ID> --region <REGION> --profile <PROFILE>
aws ec2 create-route --route-table-id <PRIVATE_RT_ID> \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id <NAT_GW_ID> \
  --region <REGION> --profile <PROFILE>
aws ec2 associate-route-table --route-table-id <PRIVATE_RT_ID> \
  --subnet-id <PRIVATE_SUBNET_A_ID> --region <REGION> --profile <PROFILE>
aws ec2 associate-route-table --route-table-id <PRIVATE_RT_ID> \
  --subnet-id <PRIVATE_SUBNET_B_ID> --region <REGION> --profile <PROFILE>
```

## 4. IAM Roles

<AccordionGroup>
  <Accordion title="EKS Cluster Role">
    ```bash theme={"dark"}
    # Create cluster role
    aws iam create-role \
      --role-name xpander-eks-cluster-role \
      --assume-role-policy-document '{
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Principal": { "Service": "eks.amazonaws.com" },
            "Action": ["sts:AssumeRole", "sts:TagSession"]
          }
        ]
      }' \
      --profile <PROFILE>

    # Attach policies
    aws iam attach-role-policy --role-name xpander-eks-cluster-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSClusterPolicy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-eks-cluster-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSComputePolicy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-eks-cluster-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSBlockStoragePolicy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-eks-cluster-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSLoadBalancingPolicy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-eks-cluster-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSNetworkingPolicy --profile <PROFILE>
    ```
  </Accordion>

  <Accordion title="Node Role">
    ```bash theme={"dark"}
    aws iam create-role \
      --role-name xpander-node-role \
      --assume-role-policy-document '{
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Principal": { "Service": "ec2.amazonaws.com" },
            "Action": "sts:AssumeRole"
          }
        ]
      }' \
      --profile <PROFILE>

    aws iam attach-role-policy --role-name xpander-node-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-node-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-node-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-node-role \
      --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore --profile <PROFILE>
    aws iam attach-role-policy --role-name xpander-node-role \
      --policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy --profile <PROFILE>
    ```
  </Accordion>
</AccordionGroup>

## 5. Create EKS Cluster

```bash theme={"dark"}
aws eks create-cluster \
  --name xpander-cluster \
  --role-arn arn:aws:iam::<ACCOUNT_ID>:role/xpander-eks-cluster-role \
  --kubernetes-version 1.35 \
  --resources-vpc-config "subnetIds=<ALL_SUBNET_IDS>,endpointPublicAccess=true,endpointPrivateAccess=true,publicAccessCidrs=<YOUR_IP>/32" \
  --access-config "authenticationMode=API" \
  --region <REGION> --profile <PROFILE>
```

Wait for the cluster to become active (\~10 minutes):

```bash theme={"dark"}
aws eks wait cluster-active --name xpander-cluster --region <REGION> --profile <PROFILE>
```

## 6. Create Node Group

<Warning>
  xpander container images are **amd64 only**. Do not use ARM/Graviton instances (`t4g`, `m7g`, `c7g`, etc.).
</Warning>

**Minimum requirements:**

* Recommended minimum: 3 × `m5.xlarge` nodes (or 2 × `t3.xlarge` for evaluation)
* xpander xpander pod requests: \~5 vCPU / \~10 GiB memory at single-replica defaults
* Add \~2 vCPU / 2.5 GiB per additional `agent-worker` replica, plus capacity for active agent workspaces
* Plan additional headroom for Kubernetes system pods, DaemonSets, upgrades, and burst capacity
* See [Sizing & Resource Requirements](/self-hosted/hybrid#requirements) for per-service numbers and a calculator that sizes the cluster for your user count.

```bash theme={"dark"}
aws eks create-nodegroup \
  --cluster-name xpander-cluster \
  --nodegroup-name xpander-nodes \
  --node-role arn:aws:iam::<ACCOUNT_ID>:role/xpander-node-role \
  --subnets <PRIVATE_SUBNET_A_ID> <PRIVATE_SUBNET_B_ID> \
  --instance-types t3.large \
  --ami-type AL2023_x86_64_STANDARD \
  --scaling-config minSize=2,maxSize=4,desiredSize=3 \
  --capacity-type ON_DEMAND \
  --region <REGION> --profile <PROFILE>
```

Wait for the node group:

```bash theme={"dark"}
aws eks wait nodegroup-active \
  --cluster-name xpander-cluster \
  --nodegroup-name xpander-nodes \
  --region <REGION> --profile <PROFILE>
```

## 7. Configure kubectl

```bash theme={"dark"}
aws eks update-kubeconfig --name xpander-cluster --region <REGION> --profile <PROFILE>
kubectl get nodes  # Verify nodes are Ready
```

***

## EKS Add-Ons

Install these EKS add-ons for networking, DNS, storage, and pod identity: `vpc-cni`, `coredns`, `kube-proxy`, `aws-ebs-csi-driver`, `eks-pod-identity-agent`, and `metrics-server`.

```bash theme={"dark"}
# VPC CNI (pod networking)
aws eks create-addon --cluster-name xpander-cluster --addon-name vpc-cni \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>

# CoreDNS (cluster DNS)
aws eks create-addon --cluster-name xpander-cluster --addon-name coredns \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>

# kube-proxy
aws eks create-addon --cluster-name xpander-cluster --addon-name kube-proxy \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>

# EBS CSI Driver (persistent volumes)
aws eks create-addon --cluster-name xpander-cluster --addon-name aws-ebs-csi-driver \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>

# Pod Identity Agent
aws eks create-addon --cluster-name xpander-cluster --addon-name eks-pod-identity-agent \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>

# Metrics Server
aws eks create-addon --cluster-name xpander-cluster --addon-name metrics-server \
  --resolve-conflicts OVERWRITE --region <REGION> --profile <PROFILE>
```

### EBS CSI IAM Setup (Pod Identity)

The EBS CSI driver needs IAM permissions to provision volumes.

```bash theme={"dark"}
# Create IAM role for EBS CSI
aws iam create-role \
  --role-name xpander-ebs-csi-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": { "Service": "pods.eks.amazonaws.com" },
        "Action": ["sts:AssumeRole", "sts:TagSession"]
      }
    ]
  }' \
  --profile <PROFILE>

aws iam attach-role-policy --role-name xpander-ebs-csi-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy --profile <PROFILE>

# Associate with service account
aws eks create-pod-identity-association \
  --cluster-name xpander-cluster \
  --namespace kube-system \
  --service-account ebs-csi-controller-sa \
  --role-arn arn:aws:iam::<ACCOUNT_ID>:role/xpander-ebs-csi-role \
  --region <REGION> --profile <PROFILE>
```

### Create Default StorageClass

```bash theme={"dark"}
kubectl apply -f - <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  fsType: ext4
  encrypted: "true"
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
EOF
```

<Note>
  **Encrypt every volume.** xpander's PVCs hold customer data (agent workspaces, the data tier, Supabase storage, knowledge-base content), so the StorageClass above enables EBS **encryption at rest** (`encrypted: "true"`, using the account's default `aws/ebs` KMS key). To use a customer-managed key instead, add `kmsKeyId: "<key-arn>"` to `parameters` and grant the EBS CSI driver's role `kms:CreateGrant`/`kms:Decrypt`/`kms:GenerateDataKey*` on it. Compliance-sensitive deployments should treat an encrypted StorageClass as a hard requirement, not an option.
</Note>

***

## EKS Auto Mode

On an EKS Auto Mode cluster AWS runs the storage, load-balancing and node capabilities for you, and three things in this guide change. A reference customer install runs on Auto Mode with these settings.

1. **Storage provisioner.** Skip the `aws-ebs-csi-driver` add-on and its Pod Identity role. Auto Mode's block storage capability provisions through `ebs.csi.eks.amazonaws.com`; the classic `ebs.csi.aws.com` driver object may exist on the cluster but has no controller behind it, so a StorageClass on it never binds. Use the Auto Mode provisioner in the default StorageClass above and in the chart's fleet class:

   ```yaml theme={"dark"}
   global:
     fleet:
       storage:
         createClass: true
         provisioner: ebs.csi.eks.amazonaws.com
   ```

2. **IngressClass.** Auto Mode ships the load balancer controller but **no IngressClass**, and it **ignores the `alb.ingress.kubernetes.io/group.name` annotation**: without grouping in the class it creates one ALB per Ingress. Create an `IngressClassParams` that carries the scheme and group, and an `IngressClass` that references it; the chart's Ingresses then set `className` to that class. Repeat the pair with `scheme: internal` for hosts that only the cluster and operators should reach (deployment-manager, an internal IdP entry).

   ```yaml theme={"dark"}
   apiVersion: eks.amazonaws.com/v1
   kind: IngressClassParams
   metadata:
     name: alb
   spec:
     scheme: internet-facing
     group:
       name: xpander
   ---
   apiVersion: networking.k8s.io/v1
   kind: IngressClass
   metadata:
     name: alb
   spec:
     controller: eks.amazonaws.com/alb
     parameters:
       apiGroup: eks.amazonaws.com
       kind: IngressClassParams
       name: alb
   ```

3. **IAM for pods.** Bind AWS permissions (Bedrock, S3 for Supabase Storage) to xpander's service accounts with EKS Pod Identity associations. Use `aws eks create-pod-identity-association`, as in the EBS CSI example above, against the `default` and `agent-controller` service accounts in the release namespace. Keep the `eks-pod-identity-agent` add-on installed.

***

Four more things an Auto Mode cluster needs before an xpander install, learned on a reference installation:

* **Cluster access for the installer's identity.** A cluster created with `authenticationMode=API` grants nothing to IAM roles by default. Create an access entry for the role that runs `kubectl` and Helm, and associate `AmazonEKSClusterAdminPolicy` with it, before the first install:

  ```bash theme={"dark"}
  aws eks create-access-entry --cluster-name <cluster> --principal-arn <role arn>
  aws eks associate-access-policy --cluster-name <cluster> --principal-arn <role arn> \
    --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy --access-scope type=cluster
  ```

* **Node consolidation during the install.** Auto Mode's built-in `general-purpose` NodePool consolidates after thirty seconds and reverts any patch you make to it, so a full xpander install can have nodes removed under pods that are still scheduling. Add a higher-weight NodePool of your own for xpander's namespaces:

  ```yaml theme={"dark"}
  apiVersion: karpenter.sh/v1
  kind: NodePool
  metadata:
    name: xpander-stable
  spec:
    weight: 100
    disruption:
      consolidationPolicy: WhenEmptyOrUnderutilized
      consolidateAfter: 10m
    template:
      spec:
        nodeClassRef: { group: eks.amazonaws.com, kind: NodeClass, name: default }
        requirements:
          - { key: karpenter.sh/capacity-type, operator: In, values: ["on-demand"] }
          - { key: kubernetes.io/arch, operator: In, values: ["amd64"] }
          - { key: eks.amazonaws.com/instance-category, operator: In, values: ["c", "m", "r"] }
          - { key: eks.amazonaws.com/instance-generation, operator: Gt, values: ["4"] }
  ```

* **Ingress grouping.** Auto Mode ships the load balancer controller but no `IngressClass`. Create one, and put the ALB group name in an `IngressClassParams` object: Auto Mode ignores the `alb.ingress.kubernetes.io/group.name` annotation and would otherwise create one load balancer per Ingress. Use a second class with `scheme: internal` for hosts that must stay private.

* **Fresh nodes refuse `kubectl exec` for a moment.** Until a new node's kubelet serving certificate is signed, `kubectl exec` and `kubectl run -i` on it fail with `tls: internal error`. Retry after a few seconds; a pipeline should wrap such steps in a short retry.

## Next Steps

Your EKS cluster is ready. Continue with:

1. **[PrivateLink](/self-hosted/hybrid#privatelink)**: if your security policy requires traffic to stay within the AWS network
2. **[Hybrid deployment](/self-hosted/hybrid)**: the installer wizard, plus SSL, ingress, DNS, and verification


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.