Skip to content

EKS

EKS (Elastic Kubernetes Service) is AWS’s managed Kubernetes: AWS runs the Kubernetes control plane, and your containers run on it as Kubernetes objects. Alchemy targets Auto Mode, where AWS also manages the nodes, storage, and load-balancer integration — no machines to operate.

The Kubernetes primitives you’ll meet here:

  • A Cluster is one Kubernetes installation: the API server plus the Nodes that run your containers.
  • A Pod is one or more containers running together — the unit Kubernetes schedules onto Nodes.
  • A Deployment keeps a set number of identical Pods running, replacing ones that fail. A Service gives them one stable address — and, as type LoadBalancer, a public one.
  • A Job runs a Pod to completion; a CronJob does that on a schedule.
  • Everything in Kubernetes is an object described by a manifest you apply to the Cluster.

Alchemy models these directly: Cluster with compute: "auto" stands up the control plane from a VPC, and the cluster-agnostic alchemy/Kubernetes workloads target it by passing the cluster resource as their cluster prop: Kubernetes.Deployment synthesizes a Kubernetes Deployment + Service; Kubernetes.Job a Job or CronJob; and Kubernetes.Manifest applies any raw Kubernetes object. The workloads live in the same TypeScript program as the cluster, with no YAML and no kubectl apply step — and the same workloads run on any other cluster your kubeconfig can reach (Kubernetes.KubeConfig(...)).

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

import * as AWS from "alchemy/AWS";
import * as Kubernetes from "alchemy/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/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 an init 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: drift is corrected on every deploy, 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.

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.