| 1 | --- |
| 2 | title: EKS |
| 3 | description: Stand up an EKS Auto Mode cluster on a Network and run containers on it — Deployments for servers, Jobs for run-to-completion work, Manifests for everything else. No YAML, no kubectl. |
| 4 | --- |
| 5 | |
| 6 | **EKS** (Elastic Kubernetes Service) is AWS's managed Kubernetes: |
| 7 | AWS runs the Kubernetes control plane, and your containers run on |
| 8 | it as Kubernetes objects. Alchemy targets **Auto Mode**, where AWS |
| 9 | also manages the nodes, storage, and load-balancer integration — |
| 10 | no machines to operate. |
| 11 | |
| 12 | The Kubernetes primitives you'll meet here: |
| 13 | |
| 14 | - A **Cluster** is one Kubernetes installation: the API server |
| 15 | plus the Nodes that run your containers. |
| 16 | - A **Pod** is one or more containers running together — the unit |
| 17 | Kubernetes schedules onto Nodes. |
| 18 | - A **Deployment** keeps a set number of identical Pods running, |
| 19 | replacing ones that fail. A **Service** gives them one stable |
| 20 | address — and, as type `LoadBalancer`, a public one. |
| 21 | - A **Job** runs a Pod to completion; a **CronJob** does that on a |
| 22 | schedule. |
| 23 | - Everything in Kubernetes is an object described by a |
| 24 | **manifest** you apply to the Cluster. |
| 25 | |
| 26 | Alchemy models these directly: |
| 27 | [`Cluster`](/providers/aws/eks/cluster) with `compute: "auto"` |
| 28 | stands up the control plane from a VPC, and the cluster-agnostic |
| 29 | `alchemy/Kubernetes` workloads target it by passing the cluster |
| 30 | resource as their `cluster` prop: |
| 31 | [`Kubernetes.Deployment`](/providers/kubernetes/deployment) |
| 32 | synthesizes a Kubernetes `Deployment` + `Service`; |
| 33 | [`Kubernetes.Job`](/providers/kubernetes/job) a `Job` or `CronJob`; |
| 34 | and [`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies |
| 35 | any raw Kubernetes object. The workloads live in the same TypeScript |
| 36 | program as the cluster, with no YAML and no `kubectl apply` step — |
| 37 | and the same workloads run on any other cluster your kubeconfig can |
| 38 | reach (`Kubernetes.KubeConfig(...)`). |
| 39 | |
| 40 | The workload providers ship in `Kubernetes.providers()` — compose it |
| 41 | with `AWS.providers()` in the Stack: |
| 42 | |
| 43 | ```typescript |
| 44 | import * as AWS from "alchemy/AWS"; |
| 45 | import * as Kubernetes from "alchemy/Kubernetes"; |
| 46 | import * as Layer from "effect/Layer"; |
| 47 | |
| 48 | export default Alchemy.Stack( |
| 49 | "my-app", |
| 50 | { |
| 51 | providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), |
| 52 | state: Alchemy.localState(), |
| 53 | }, |
| 54 | // ... |
| 55 | ); |
| 56 | ``` |
| 57 | |
| 58 | ## Create an Auto Mode cluster |
| 59 | |
| 60 | `compute: "auto"` turns on EKS Auto Mode with sensible defaults — |
| 61 | managed compute (`system` and `general-purpose` node pools), block |
| 62 | storage, elastic load balancing, and `API` authentication — and, |
| 63 | when you don't pass `roleArn`, provisions and owns the cluster and |
| 64 | node IAM roles with the standard Auto Mode managed policies: |
| 65 | |
| 66 | ```typescript |
| 67 | // alchemy.run.ts |
| 68 | import * as AWS from "alchemy/AWS"; |
| 69 | |
| 70 | const network = yield* AWS.EC2.Network("Network", { |
| 71 | cidrBlock: "10.42.0.0/16", |
| 72 | availabilityZones: 2, |
| 73 | nat: "single", |
| 74 | }); |
| 75 | |
| 76 | const cluster = yield* AWS.EKS.Cluster("Cluster", { |
| 77 | compute: "auto", |
| 78 | resourcesVpcConfig: { |
| 79 | subnetIds: network.privateSubnetIds, |
| 80 | endpointPublicAccess: true, |
| 81 | endpointPrivateAccess: true, |
| 82 | }, |
| 83 | }); |
| 84 | ``` |
| 85 | |
| 86 | The cluster lands in the network's private subnets, built on a |
| 87 | [`Network`](/providers/aws/ec2/network). Budget for the create: an |
| 88 | EKS control plane takes ~10 minutes to provision. |
| 89 | |
| 90 | ## Grant access and install add-ons |
| 91 | |
| 92 | Auto Mode provisions with `authenticationMode: "API"`, so cluster |
| 93 | access is granted through |
| 94 | [`AccessEntry`](/providers/aws/eks/accessentry) resources rather than |
| 95 | the `aws-auth` ConfigMap, and |
| 96 | [`Addon`](/providers/aws/eks/addon) installs EKS add-ons (EKS picks |
| 97 | the default compatible version when you don't pin one): |
| 98 | |
| 99 | ```typescript |
| 100 | const admin = yield* AWS.EKS.AccessEntry("ClusterAdmin", { |
| 101 | clusterName: cluster.clusterName, |
| 102 | principalArn: "arn:aws:iam::123456789012:role/YourAdminRole", |
| 103 | accessPolicies: [ |
| 104 | { |
| 105 | policyArn: |
| 106 | "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", |
| 107 | accessScope: { type: "cluster" }, |
| 108 | }, |
| 109 | ], |
| 110 | }); |
| 111 | |
| 112 | const metricsServer = yield* AWS.EKS.Addon("MetricsServer", { |
| 113 | clusterName: cluster.clusterName, |
| 114 | addonName: "metrics-server", |
| 115 | }); |
| 116 | ``` |
| 117 | |
| 118 | The deploying principal is bootstrapped as cluster admin |
| 119 | (`bootstrapClusterCreatorAdminPermissions: true` is part of the Auto |
| 120 | Mode defaults), so these are for everyone (and everything) else that |
| 121 | needs in. |
| 122 | |
| 123 | ## Deploy a server |
| 124 | |
| 125 | [`Kubernetes.Deployment`](/providers/kubernetes/deployment) is a |
| 126 | replicated Kubernetes server — the Kubernetes analog of |
| 127 | `AWS.ECS.Service`. It |
| 128 | synthesizes a Kubernetes `Deployment` + `Service` (+ |
| 129 | `ServiceAccount`) and applies them via server-side apply, with the |
| 130 | container image coming from exactly one of three sources flat on |
| 131 | props: `image` (a registry reference, mirrored into ECR), `context` |
| 132 | (build your own Dockerfile), or `main` (bundle an inline Effect |
| 133 | program). The simplest form runs a remote image with no Effect |
| 134 | runtime in the container: |
| 135 | |
| 136 | ```typescript |
| 137 | const echo = yield* Kubernetes.Deployment("EchoServer", { |
| 138 | cluster, |
| 139 | image: "registry.k8s.io/echoserver:1.10", |
| 140 | namespace: "default", |
| 141 | replicas: 2, |
| 142 | port: 8080, |
| 143 | serviceType: "LoadBalancer", |
| 144 | }); |
| 145 | |
| 146 | echo.url; // LoadBalancer hostname (an NLB on Auto Mode) |
| 147 | echo.deploymentName; // K8s-native attrs: deploymentName, serviceName, ... |
| 148 | ``` |
| 149 | |
| 150 | `serviceType: "LoadBalancer"` provisions a cloud load balancer and |
| 151 | exposes its hostname as `url`. Swap `image` for |
| 152 | `context: "./legacy"` to build your own Dockerfile (`dockerfile` is |
| 153 | a path, defaulting to `${context}/Dockerfile`). |
| 154 | |
| 155 | ## Effect servers with bindings |
| 156 | |
| 157 | Pass `main: import.meta.url` and a constructor Effect and the program is |
| 158 | bundled into a generated image instead — the same authoring model as |
| 159 | [Lambda](/aws/compute/lambda). Bindings work identically too: |
| 160 | `Deployment` accepts the same `{ env, policyStatements }` binding |
| 161 | contract as a Lambda `Function`, so every AWS `Binding.Service` |
| 162 | attaches environment variables to the Pod spec and IAM policy |
| 163 | statements to a generated **Pod Identity role**: |
| 164 | |
| 165 | ```typescript |
| 166 | const api = yield* Kubernetes.Deployment( |
| 167 | "Api", |
| 168 | { cluster, main: import.meta.url, port: 3000, replicas: 2 }, |
| 169 | Effect.gen(function* () { |
| 170 | const putItem = yield* AWS.DynamoDB.PutItem(table); |
| 171 | return { |
| 172 | fetch: Effect.gen(function* () { |
| 173 | yield* putItem({ Item: { id: { S: "1" } } }); |
| 174 | return HttpServerResponse.text("ok"); |
| 175 | }), |
| 176 | }; |
| 177 | }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), |
| 178 | ); |
| 179 | ``` |
| 180 | |
| 181 | Every Kubernetes workload on EKS gets Pod Identity as standard: |
| 182 | Alchemy creates |
| 183 | the IAM role, wires it to the workload's ServiceAccount with a |
| 184 | [`PodIdentityAssociation`](/providers/aws/eks/podidentityassociation), |
| 185 | and Pods resolve credentials through the EKS Pod Identity |
| 186 | container-credentials chain — no OIDC provider or IRSA annotation |
| 187 | ceremony. The tagged form |
| 188 | (`class Api extends Kubernetes.Deployment<Api, Shape>()("Api") {}` + |
| 189 | `Api.make(props, impl)`) works exactly as it does on Lambda and |
| 190 | Cloudflare Workers. |
| 191 | |
| 192 | ## Run-to-completion work with Job |
| 193 | |
| 194 | [`Kubernetes.Job`](/providers/kubernetes/job) runs a container to |
| 195 | completion — the Kubernetes analog of `AWS.ECS.Task`. Same three image sources, |
| 196 | same bindings and Pod Identity; an Effect impl returns `{ run }` |
| 197 | instead of `{ fetch }`, executing to completion inside the Pod: |
| 198 | |
| 199 | ```typescript |
| 200 | const migrate = yield* Kubernetes.Job("DbMigrate", { |
| 201 | cluster, |
| 202 | image: "ghcr.io/acme/migrator:v3", |
| 203 | backoffLimit: 2, |
| 204 | }); |
| 205 | ``` |
| 206 | |
| 207 | Set `schedule` (standard 5-field cron) and a Kubernetes `CronJob` is |
| 208 | synthesized instead of a plain `Job`: |
| 209 | |
| 210 | ```typescript |
| 211 | const nightly = yield* Kubernetes.Job("NightlyBackfill", { |
| 212 | cluster, |
| 213 | main: import.meta.url, |
| 214 | schedule: "0 3 * * *", |
| 215 | }); |
| 216 | ``` |
| 217 | |
| 218 | ## Everything else is a Manifest |
| 219 | |
| 220 | [`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies any |
| 221 | raw Kubernetes object — StatefulSets, Namespaces, CRDs — via server-side apply. |
| 222 | The manifest is a literal object, exactly as you would write it in |
| 223 | YAML: |
| 224 | |
| 225 | ```typescript |
| 226 | const namespace = yield* Kubernetes.Manifest("DemoNamespace", { |
| 227 | cluster, |
| 228 | manifest: { |
| 229 | apiVersion: "v1", |
| 230 | kind: "Namespace", |
| 231 | metadata: { name: "demo" }, |
| 232 | }, |
| 233 | }); |
| 234 | ``` |
| 235 | |
| 236 | There is no kubeconfig step: Alchemy authenticates to the cluster's |
| 237 | API with your AWS credentials (a presigned STS token) and applies |
| 238 | objects via **server-side apply** under the `alchemy` field manager, |
| 239 | so deploys converge the live objects the same way the rest of your |
| 240 | Stack converges cloud resources. Unknown kinds resolve through the |
| 241 | Kubernetes API discovery endpoint, so CRDs work without any |
| 242 | registration. |
| 243 | |
| 244 | ## Install a Helm chart |
| 245 | |
| 246 | [`Kubernetes.HelmChart`](/providers/kubernetes/helmchart) renders a |
| 247 | chart with the |
| 248 | local `helm` CLI (`helm template` — install helm on your machine, |
| 249 | like Docker for image builds) and applies the rendered objects |
| 250 | through the same server-side-apply path as `Manifest`: |
| 251 | |
| 252 | ```typescript |
| 253 | const ingress = yield* Kubernetes.HelmChart("IngressNginx", { |
| 254 | cluster, |
| 255 | chart: "ingress-nginx", |
| 256 | repo: "https://kubernetes.github.io/ingress-nginx", |
| 257 | version: "4.11.2", |
| 258 | namespace: "ingress-nginx", |
| 259 | createNamespace: true, |
| 260 | values: { |
| 261 | controller: { replicaCount: 2 }, |
| 262 | }, |
| 263 | }); |
| 264 | ``` |
| 265 | |
| 266 | `chart` also accepts `oci://` references and local chart directories, |
| 267 | and `values` is a literal object — the same shape as a `values.yaml` |
| 268 | file. Because the objects are applied (not `helm install`ed), Alchemy |
| 269 | owns their lifecycle: the full render is re-applied whenever the |
| 270 | chart's inputs change, objects that drop out of the render are |
| 271 | pruned, and destroy deletes them — |
| 272 | there is no in-cluster Helm release record. Charts that rely on |
| 273 | install/upgrade hooks for correctness should be installed with Helm |
| 274 | directly. |
| 275 | |
| 276 | ## What EKS adds to Kubernetes workloads |
| 277 | |
| 278 | The `Kubernetes.*` resources are cluster-agnostic. When their |
| 279 | `cluster` is an EKS cluster, `AWS.providers()` contributes an |
| 280 | adapter that handles the AWS-specific parts. |
| 281 | |
| 282 | ### Authentication |
| 283 | |
| 284 | Alchemy signs a short-lived bearer token with your AWS credentials |
| 285 | for every request, so there is no kubeconfig to manage. It's the |
| 286 | same token `aws eks get-token` produces. The principal that created the |
| 287 | cluster is its admin. Grant anyone else access with |
| 288 | [`AccessEntry`](/providers/aws/eks/accessentry). |
| 289 | |
| 290 | ### Images in ECR |
| 291 | |
| 292 | Each `Deployment` and `Job` gets its own private ECR repository. |
| 293 | `main` programs are bundled and built into an image, `context` |
| 294 | Dockerfiles are built, and `image` references are pulled and pushed |
| 295 | into the repository, so pods only pull from your account. Set |
| 296 | `architecture: "arm64"` for Graviton nodes. The default builds |
| 297 | `amd64`. The repository is deleted when the workload is destroyed. |
| 298 | |
| 299 | ### Pod Identity |
| 300 | |
| 301 | Each workload gets an IAM role and a |
| 302 | [`PodIdentityAssociation`](/providers/aws/eks/podidentityassociation) |
| 303 | for its ServiceAccount. AWS bindings used by the workload add their |
| 304 | IAM statements to that role, and the pod receives temporary |
| 305 | credentials for it through the EKS Pod Identity agent. Attach |
| 306 | AWS-managed or customer-managed policies with |
| 307 | `identity.managedPolicyArns`: |
| 308 | |
| 309 | ```typescript |
| 310 | const exporter = yield* Kubernetes.Job("Export", { |
| 311 | cluster, |
| 312 | main: import.meta.url, |
| 313 | identity: { |
| 314 | managedPolicyArns: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"], |
| 315 | }, |
| 316 | }); |
| 317 | ``` |
| 318 | |
| 319 | ### Load balancers |
| 320 | |
| 321 | Auto Mode's built-in load balancer controller only handles Services |
| 322 | with `loadBalancerClass: eks.amazonaws.com/nlb`, and creates internal |
| 323 | load balancers by default. On `LoadBalancer` Services Alchemy sets |
| 324 | that class (when the cluster has Auto Mode load balancing enabled) |
| 325 | and the `service.beta.kubernetes.io/aws-load-balancer-scheme: |
| 326 | internet-facing` annotation, so `url` is reachable from the |
| 327 | internet. Your own `serviceAnnotations` take precedence. Set the |
| 328 | scheme to `internal` to keep a service private: |
| 329 | |
| 330 | ```typescript |
| 331 | const internal = yield* Kubernetes.Deployment("Internal", { |
| 332 | cluster, |
| 333 | image: "ghcr.io/acme/internal:v1", |
| 334 | port: 8080, |
| 335 | serviceAnnotations: { |
| 336 | "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", |
| 337 | }, |
| 338 | }); |
| 339 | ``` |
| 340 | |
| 341 | ### Cleanup |
| 342 | |
| 343 | The ECR repository and Pod Identity role outlive the cluster's own |
| 344 | objects. On destroy Alchemy deletes the in-cluster objects if the |
| 345 | cluster is still reachable, then deletes the repository and role |
| 346 | whether or not the cluster still exists, so destroying a cluster and |
| 347 | its workloads together leaves nothing behind in AWS. |
| 348 | |
| 349 | ## Target an EKS cluster you don't manage |
| 350 | |
| 351 | To deploy onto a cluster created elsewhere, name it in a connection. |
| 352 | The adapter looks up the endpoint and certificate authority from the |
| 353 | cluster name: |
| 354 | |
| 355 | ```typescript |
| 356 | const cluster: Kubernetes.Connection = { |
| 357 | auth: { kind: "aws-eks", clusterName: "shared-prod", region: "us-west-2" }, |
| 358 | }; |
| 359 | ``` |
| 360 | |
| 361 | `region` defaults to the Stack's AWS region. The `aws-eks` auth kind |
| 362 | is added to the `Kubernetes.Connection` type by importing |
| 363 | `alchemy/AWS`, and it requires `AWS.providers()` in the Stack. Your |
| 364 | AWS principal needs an access entry on the cluster. Workloads get |
| 365 | the same ECR, Pod Identity, and load balancer behavior as on a |
| 366 | Stack-managed cluster. |
| 367 | |
| 368 | ## Run on SageMaker HyperPod |
| 369 | |
| 370 | An EKS cluster can also orchestrate a |
| 371 | [SageMaker HyperPod](/aws/compute/hyperpod) fleet — persistent, |
| 372 | health-checked ML compute whose nodes join the cluster as |
| 373 | ordinary Kubernetes nodes. |
| 374 | [`AWS.SageMaker.Cluster`](/providers/aws/sagemaker/cluster) |
| 375 | attaches the fleet to the EKS control plane: |
| 376 | |
| 377 | ```typescript |
| 378 | const hyperpod = yield* AWS.SageMaker.Cluster("HyperPod", { |
| 379 | orchestrator: { Eks: { ClusterArn: cluster.clusterArn } }, |
| 380 | vpcConfig: { |
| 381 | SecurityGroupIds: [clusterSecurityGroupId], |
| 382 | Subnets: network.privateSubnetIds, |
| 383 | }, |
| 384 | instanceGroups: { |
| 385 | workers: { |
| 386 | InstanceType: "ml.g5.xlarge", |
| 387 | InstanceCount: 4, |
| 388 | ExecutionRole: role.roleArn, |
| 389 | LifeCycleConfig: { |
| 390 | SourceS3Uri: script.sourceS3Uri, |
| 391 | OnCreate: script.onCreate, |
| 392 | }, |
| 393 | }, |
| 394 | }, |
| 395 | }); |
| 396 | ``` |
| 397 | |
| 398 | `Kubernetes.Deployment` and `Kubernetes.Job` then opt onto those |
| 399 | nodes in plain Kubernetes vocabulary — the HyperPod resources |
| 400 | expose the derived values as attributes. The instance-group keys |
| 401 | carry through to the cluster's attributes as types — |
| 402 | `hyperpod.instanceGroups.workers` is typed per key and a typo'd |
| 403 | name is a compile error — so the workload is connected to the |
| 404 | fleet through the resource graph, and a team's `ComputeQuota` |
| 405 | materializes the governed namespace and Kueue queue: |
| 406 | |
| 407 | ```typescript |
| 408 | const train = yield* Kubernetes.Job("Train", { |
| 409 | cluster, |
| 410 | main: import.meta.url, |
| 411 | namespace: researchQuota.namespace, // hyperpod-ns-research |
| 412 | labels: { |
| 413 | [AWS.SageMaker.KUEUE_QUEUE_NAME_LABEL]: researchQuota.queueName, |
| 414 | [AWS.SageMaker.KUEUE_PRIORITY_CLASS_LABEL]: "training-priority", |
| 415 | }, |
| 416 | podTemplate: { |
| 417 | spec: { nodeSelector: hyperpod.instanceGroups.workers.nodeSelector }, |
| 418 | }, |
| 419 | }); |
| 420 | ``` |
| 421 | |
| 422 | See [HyperPod](/aws/compute/hyperpod) for the fleet's |
| 423 | prerequisites (lifecycle scripts, the dependencies Helm chart, |
| 424 | EKS auth-mode and version constraints) and the full governance |
| 425 | surface. |
| 426 | |
| 427 | ## Where next |
| 428 | |
| 429 | - [Kubernetes](/kubernetes) — the full guide to `Kubernetes.*` |
| 430 | workloads, manifests, and Helm charts, with a |
| 431 | [tutorial](/kubernetes/tutorial/part-1) you can run on a local |
| 432 | cluster before deploying to EKS. |
| 433 | - [VPC & networking](/aws/networking) — what the `Network` helper |
| 434 | builds under the cluster. |
| 435 | - [HyperPod](/aws/compute/hyperpod) — run training fleets on this |
| 436 | cluster's nodes. |
| 437 | - [Choosing a runtime](/aws/compute/choosing-a-runtime) — when Lambda or ECS |
| 438 | is the better fit than running your own Kubernetes workloads. |
| 439 | - [`Cluster` reference](/providers/aws/eks/cluster), |
| 440 | [`Deployment` reference](/providers/kubernetes/deployment), |
| 441 | [`Job` reference](/providers/kubernetes/job), |
| 442 | [`Manifest` reference](/providers/kubernetes/manifest) — every prop |
| 443 | and attribute. |