Skip to content

EKS

EKS is AWS’s managed Kubernetes. AWS runs the Kubernetes control plane, and your containers run on it as Kubernetes objects. In Auto Mode, AWS also manages the nodes, their storage and their load balancers, so there are no machines to operate.

Choose EKS when you want Kubernetes itself: Helm charts, operators, raw manifests, or a team that already runs on it. For containers without Kubernetes, ECS has less to learn and less to pay for.

A cluster is one Kubernetes installation: an API server and the nodes that run containers. A Pod is one or more containers scheduled together onto a node. A Deployment keeps a number of identical Pods running and replaces any that fail, and a Service gives them one stable address, public if it’s a LoadBalancer. A Job runs a Pod until it finishes, and a CronJob runs one on a schedule.

Every one of these is an object, described by a manifest and applied to the cluster.

In Alchemy, AWS.EKS.Cluster creates the cluster from a VPC. The workloads come from @alchemy.run/kubernetes: Kubernetes.Deployment creates a Deployment and its Service, Kubernetes.Job a Job or CronJob, and Kubernetes.Manifest any other object. Each takes the cluster as its cluster prop, in the same program, with no YAML and no kubectl. The same workloads run on any cluster your kubeconfig reaches.

The workload providers ship in Kubernetes.providers() — compose it with AWS.providers() in the Stack:

import * as AWS from "@alchemy.run/aws";
import * as Kubernetes from "@alchemy.run/kubernetes";
import * as Layer from "effect/Layer";
export default Alchemy.Stack(
"my-app",
{
providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()),
state: Alchemy.localState(),
},
// ...
);

compute: "auto" turns on EKS Auto Mode with sensible defaults — managed compute (system and general-purpose node pools), block storage, elastic load balancing, and API authentication — and, when you don’t pass roleArn, provisions and owns the cluster and node IAM roles with the standard Auto Mode managed policies:

alchemy.run.ts
import * as AWS from "@alchemy.run/aws";
const network = yield* AWS.EC2.Network("Network", {
cidrBlock: "10.42.0.0/16",
availabilityZones: 2,
nat: "single",
});
const cluster = yield* AWS.EKS.Cluster("Cluster", {
compute: "auto",
resourcesVpcConfig: {
subnetIds: network.privateSubnetIds,
endpointPublicAccess: true,
endpointPrivateAccess: true,
},
});

The cluster lands in the network’s private subnets, built on a Network. Budget for the create: an EKS control plane takes ~10 minutes to provision.

Auto Mode provisions with authenticationMode: "API", so cluster access is granted through AccessEntry resources rather than the aws-auth ConfigMap, and Addon installs EKS add-ons (EKS picks the default compatible version when you don’t pin one):

const admin = yield* AWS.EKS.AccessEntry("ClusterAdmin", {
clusterName: cluster.clusterName,
principalArn: "arn:aws:iam::123456789012:role/YourAdminRole",
accessPolicies: [
{
policyArn:
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy",
accessScope: { type: "cluster" },
},
],
});
const metricsServer = yield* AWS.EKS.Addon("MetricsServer", {
clusterName: cluster.clusterName,
addonName: "metrics-server",
});

The deploying principal is bootstrapped as cluster admin (bootstrapClusterCreatorAdminPermissions: true is part of the Auto Mode defaults), so these are for everyone (and everything) else that needs in.

Kubernetes.Deployment is a replicated Kubernetes server — the Kubernetes analog of AWS.ECS.Service. It synthesizes a Kubernetes Deployment + Service (+ ServiceAccount) and applies them via server-side apply, with the container image coming from exactly one of three sources flat on props: image (a registry reference, mirrored into ECR), context (build your own Dockerfile), or main (bundle an inline Effect program). The simplest form runs a remote image with no Effect runtime in the container:

const echo = yield* Kubernetes.Deployment("EchoServer", {
cluster,
image: "registry.k8s.io/echoserver:1.10",
namespace: "default",
replicas: 2,
port: 8080,
serviceType: "LoadBalancer",
});
echo.url; // LoadBalancer hostname (an NLB on Auto Mode)
echo.deploymentName; // K8s-native attrs: deploymentName, serviceName, ...

serviceType: "LoadBalancer" provisions a cloud load balancer and exposes its hostname as url. Swap image for context: "./legacy" to build your own Dockerfile (dockerfile is a path, defaulting to ${context}/Dockerfile).

Pass main: import.meta.url and a constructor Effect and the program is bundled into a generated image instead — the same authoring model as Lambda. Bindings work identically too: Deployment accepts the same { env, policyStatements } binding contract as a Lambda Function, so every AWS Binding.Service attaches environment variables to the Pod spec and IAM policy statements to a generated Pod Identity role:

const api = yield* Kubernetes.Deployment(
"Api",
{ cluster, main: import.meta.url, port: 3000, replicas: 2 },
Effect.gen(function* () {
const putItem = yield* AWS.DynamoDB.PutItem(table);
return {
fetch: Effect.gen(function* () {
yield* putItem({ Item: { id: { S: "1" } } });
return HttpServerResponse.text("ok");
}),
};
}).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
);

Every Kubernetes workload on EKS gets Pod Identity as standard: Alchemy creates the IAM role, wires it to the workload’s ServiceAccount with a PodIdentityAssociation, and Pods resolve credentials through the EKS Pod Identity container-credentials chain — no OIDC provider or IRSA annotation ceremony. The tagged form (class Api extends Kubernetes.Deployment<Api, Shape>()("Api") {} + Api.make(props, impl)) works exactly as it does on Lambda and Cloudflare Workers.

Kubernetes.Job runs a container to completion — the Kubernetes analog of AWS.ECS.Task. Same three image sources, same bindings and Pod Identity; an Effect impl returns { run } instead of { fetch }, executing to completion inside the Pod:

const migrate = yield* Kubernetes.Job("DbMigrate", {
cluster,
image: "ghcr.io/acme/migrator:v3",
backoffLimit: 2,
});

Set schedule (standard 5-field cron) and a Kubernetes CronJob is synthesized instead of a plain Job:

const nightly = yield* Kubernetes.Job("NightlyBackfill", {
cluster,
main: import.meta.url,
schedule: "0 3 * * *",
});

Kubernetes.Manifest applies any raw Kubernetes object — StatefulSets, Namespaces, CRDs — via server-side apply. The manifest is a literal object, exactly as you would write it in YAML:

const namespace = yield* Kubernetes.Manifest("DemoNamespace", {
cluster,
manifest: {
apiVersion: "v1",
kind: "Namespace",
metadata: { name: "demo" },
},
});

There is no kubeconfig step: Alchemy authenticates to the cluster’s API with your AWS credentials (a presigned STS token) and applies objects via server-side apply under the alchemy field manager, so deploys converge the live objects the same way the rest of your Stack converges cloud resources. Unknown kinds resolve through the Kubernetes API discovery endpoint, so CRDs work without any registration.

Kubernetes.HelmChart renders a chart with the local helm CLI (helm template — install helm on your machine, like Docker for image builds) and applies the rendered objects through the same server-side-apply path as Manifest:

const ingress = yield* Kubernetes.HelmChart("IngressNginx", {
cluster,
chart: "ingress-nginx",
repo: "https://kubernetes.github.io/ingress-nginx",
version: "4.11.2",
namespace: "ingress-nginx",
createNamespace: true,
values: {
controller: { replicaCount: 2 },
},
});

chart also accepts oci:// references and local chart directories, and values is a literal object — the same shape as a values.yaml file. Because the objects are applied (not helm installed), Alchemy owns their lifecycle: the full render is re-applied whenever the chart’s inputs change, objects that drop out of the render are pruned, and destroy deletes them — there is no in-cluster Helm release record. Charts that rely on install/upgrade hooks for correctness should be installed with Helm directly.

The Kubernetes.* resources are cluster-agnostic. When their cluster is an EKS cluster, AWS.providers() contributes an adapter that handles the AWS-specific parts.

Alchemy signs a short-lived bearer token with your AWS credentials for every request, so there is no kubeconfig to manage. It’s the same token aws eks get-token produces. The principal that created the cluster is its admin. Grant anyone else access with AccessEntry.

Each Deployment and Job gets its own private ECR repository. main programs are bundled and built into an image, context Dockerfiles are built, and image references are pulled and pushed into the repository, so pods only pull from your account. Set architecture: "arm64" for Graviton nodes. The default builds amd64. The repository is deleted when the workload is destroyed.

Each workload gets an IAM role and a PodIdentityAssociation for its ServiceAccount. AWS bindings used by the workload add their IAM statements to that role, and the pod receives temporary credentials for it through the EKS Pod Identity agent. Attach AWS-managed or customer-managed policies with identity.managedPolicyArns:

const exporter = yield* Kubernetes.Job("Export", {
cluster,
main: import.meta.url,
identity: {
managedPolicyArns: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"],
},
});

Auto Mode’s built-in load balancer controller only handles Services with loadBalancerClass: eks.amazonaws.com/nlb, and creates internal load balancers by default. On LoadBalancer Services Alchemy sets that class (when the cluster has Auto Mode load balancing enabled) and the service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing annotation, so url is reachable from the internet. Your own serviceAnnotations take precedence. Set the scheme to internal to keep a service private:

const internal = yield* Kubernetes.Deployment("Internal", {
cluster,
image: "ghcr.io/acme/internal:v1",
port: 8080,
serviceAnnotations: {
"service.beta.kubernetes.io/aws-load-balancer-scheme": "internal",
},
});

The ECR repository and Pod Identity role outlive the cluster’s own objects. On destroy Alchemy deletes the in-cluster objects if the cluster is still reachable, then deletes the repository and role whether or not the cluster still exists, so destroying a cluster and its workloads together leaves nothing behind in AWS.

To deploy onto a cluster created elsewhere, name it in a connection. The adapter looks up the endpoint and certificate authority from the cluster name:

const cluster: Kubernetes.Connection = {
auth: { kind: "aws-eks", clusterName: "shared-prod", region: "us-west-2" },
};

region defaults to the Stack’s AWS region. The aws-eks auth kind is added to the Kubernetes.Connection type by importing @alchemy.run/aws, and it requires AWS.providers() in the Stack. Your AWS principal needs an access entry on the cluster. Workloads get the same ECR, Pod Identity, and load balancer behavior as on a Stack-managed cluster.

An EKS cluster can also orchestrate a SageMaker HyperPod fleet — persistent, health-checked ML compute whose nodes join the cluster as ordinary Kubernetes nodes. AWS.SageMaker.Cluster attaches the fleet to the EKS control plane:

const hyperpod = yield* AWS.SageMaker.Cluster("HyperPod", {
orchestrator: { Eks: { ClusterArn: cluster.clusterArn } },
vpcConfig: {
SecurityGroupIds: [clusterSecurityGroupId],
Subnets: network.privateSubnetIds,
},
instanceGroups: {
workers: {
InstanceType: "ml.g5.xlarge",
InstanceCount: 4,
ExecutionRole: role.roleArn,
LifeCycleConfig: {
SourceS3Uri: script.sourceS3Uri,
OnCreate: script.onCreate,
},
},
},
});

Kubernetes.Deployment and Kubernetes.Job then opt onto those nodes in plain Kubernetes vocabulary — the HyperPod resources expose the derived values as attributes. The instance-group keys carry through to the cluster’s attributes as types — hyperpod.instanceGroups.workers is typed per key and a typo’d name is a compile error — so the workload is connected to the fleet through the resource graph, and a team’s ComputeQuota materializes the governed namespace and Kueue queue:

const train = yield* Kubernetes.Job("Train", {
cluster,
main: import.meta.url,
namespace: researchQuota.namespace, // hyperpod-ns-research
labels: {
[AWS.SageMaker.KUEUE_QUEUE_NAME_LABEL]: researchQuota.queueName,
[AWS.SageMaker.KUEUE_PRIORITY_CLASS_LABEL]: "training-priority",
},
podTemplate: {
spec: { nodeSelector: hyperpod.instanceGroups.workers.nodeSelector },
},
});

See HyperPod for the fleet’s prerequisites (lifecycle scripts, the dependencies Helm chart, EKS auth-mode and version constraints) and the full governance surface.