How to Deploy an EKS Cluster with Pulumi (Python)
Deploy a production-ready Amazon EKS cluster in typed Python using the pulumi_eks component, wiring managed node groups, OIDC/IRSA, and a kubeconfig output. This walk-through is part of the AWS Provider Deep Dive within Pulumi Patterns & Provider Management, and it assumes the credential and state foundations covered there.
EKS is one of the highest-value targets for Pulumi Python: an EKS cluster is dozens of interdependent resources (VPC, IAM roles, control plane, node groups, OIDC provider, security groups), and managing them through a typed program means you get IDE completion, mypy validation, and pytest coverage instead of a sprawling HCL module. The pulumi_eks package wraps the raw pulumi_aws resources into a single high-level Cluster component, so most of the wiring is handled for you while still letting you drop down to the underlying resources when needed.
Context
pulumi_eks.Cluster is a component resource — a Python class that registers other resources under a single logical parent rather than mapping one-to-one onto an AWS API call. That distinction matters the moment you read a preview. A one-line eks.Cluster(...) expands into an aws.eks.Cluster, two IAM roles with four managed-policy attachments, a security group and its ingress rules, an aws.iam.OpenIdConnectProvider, an aws.eks.NodeGroup, a pulumi_kubernetes.Provider built from the generated kubeconfig, and the aws-auth ConfigMap that grants the node role permission to register. Roughly forty URNs appear under one name in pulumi stack export.
The practical consequence is ordering. Pulumi derives the dependency graph from Output values, not from the order of statements in __main__.py, so the control plane is created before the node group only because the node group's launch configuration reads cluster.eks_cluster.name. If you ever bypass that by hard-coding a name string, you break the edge and the node group races the control plane — the deploy fails with ResourceNotFoundException: No cluster found for name: app. Keep every cross-resource reference as an Output and the ordering takes care of itself.
The second consequence is timing. A cold pulumi up for an EKS cluster runs 10–15 minutes: the control plane alone takes around nine, and the node group waits for instances to reach Ready. Pulumi's default resource timeout is generous enough, but CI jobs are frequently not — budget at least 25 minutes of wall clock for the pipeline step before the first apply.
Prerequisites
- Python 3.9+ with a virtual environment activated.
pulumi>=3.100,pulumi-aws>=6.0,pulumi-eks>=2.0, andpulumi-kubernetes>=4.0pinned inrequirements.txt(orpyproject.toml).- AWS credentials resolvable by the provider (an assumed role or OIDC session—never static keys committed to source).
- IAM permissions for the deploying principal covering
eks:*,ec2:*(VPC/subnet/security-group),iam:CreateRole/AttachRolePolicy/CreateOpenIDConnectProvider, andautoscaling:*. kubectlinstalled locally to verify the EKS cluster after deployment.- A stack initialized and the region set:
pulumi stack init dev && pulumi config set aws:region us-east-1.
CLI: Verify the toolchain and credentials before deploying.
python -c "import pulumi_eks, pulumi_aws; print('ok')" aws sts get-caller-identity
Implementation
1. Define typed cluster configuration
Drive the EKS cluster shape from a frozen dataclass so every knob is validated and discoverable, rather than passing loose keyword arguments. Pull secrets and environment-specific values from Pulumi config where appropriate.
# config.py
# CLI: pulumi config set eks:minNodes 2
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Sequence
import pulumi
@dataclass(frozen=True)
class EksConfig:
name: str
instance_type: str = "t3.medium"
min_size: int = 2
max_size: int = 4
desired_size: int = 2
k8s_version: str = "1.30"
tags: dict[str, str] = field(default_factory=dict)
def load_eks_config() -> EksConfig:
"""Build a typed EKS config from Pulumi stack configuration."""
cfg = pulumi.Config("eks")
return EksConfig(
name=cfg.get("name") or "app",
instance_type=cfg.get("instanceType") or "t3.medium",
min_size=cfg.get_int("minNodes") or 2,
max_size=cfg.get_int("maxNodes") or 4,
desired_size=cfg.get_int("desiredNodes") or 2,
k8s_version=cfg.get("k8sVersion") or "1.30",
tags={"managed-by": "pulumi", "stack": pulumi.get_stack()},
)
# Provider note: the AWS region comes from `aws:region`, not this dataclass.
2. Provision a VPC for the EKS cluster
EKS needs subnets across at least two Availability Zones. The awsx (crosswalk) package builds a best-practice VPC—public subnets for load balancers, private subnets for nodes—in a few lines. If you prefer a hand-rolled network, see Building a Reusable VPC Component in Pulumi (Python) for the underlying pattern.
Two details in this network shape are load-bearing for EKS specifically. First, the AWS Load Balancer Controller discovers where to place an ALB by reading subnet tags: kubernetes.io/role/elb=1 on public subnets and kubernetes.io/role/internal-elb=1 on private ones. awsx.ec2.Vpc does not add those, so an Ingress object will sit in Pending forever with the controller logging could not discover any subnets. Second, NatGatewayStrategy.SINGLE puts one NAT gateway in one Availability Zone; that is fine for a development stack and a single point of failure for production, where ONE_PER_AZ is the correct choice at roughly triple the hourly cost.
# network.py
# CLI: pulumi preview
from __future__ import annotations
import pulumi_awsx as awsx
def create_cluster_vpc(name: str) -> awsx.ec2.Vpc:
"""Create a multi-AZ VPC with public and private subnets for EKS."""
return awsx.ec2.Vpc(
f"{name}-vpc",
cidr_block="10.0.0.0/16",
number_of_availability_zones=2,
# State implication: subnet IDs are Output[str]; never index them
# synchronously—pass the Output lists straight into the cluster.
nat_gateways=awsx.ec2.NatGatewayConfigurationArgs(
strategy=awsx.ec2.NatGatewayStrategy.SINGLE,
),
)
3. Create the EKS cluster with managed node groups and IRSA
The eks.Cluster component provisions the control plane, the node IAM roles, the OIDC provider, and a managed node group. Enabling create_oidc_provider=True is what makes IAM Roles for Service Accounts (IRSA) work—pods can then assume scoped IAM roles instead of inheriting the node's instance profile.
Three of these arguments deserve a note. version pins the Kubernetes minor version of the control plane; leaving it unset means AWS picks the current default, and the next time that default moves your pulumi preview shows an unplanned control-plane update. node_associate_public_ip_address=False forces kubelet traffic out through the NAT gateway, which is what you want when nodes live in private subnets — set it wrongly and the instances get public IPs but no route, because awsx does not attach an internet gateway route to private subnet route tables. desired_capacity is only read at creation time: once the autoscaling group exists, Cluster Autoscaler or a manual scale changes the live count, and Pulumi will fight it on the next up unless you add ignore_changes for that field.
# __main__.py
# CLI: pulumi up
from __future__ import annotations
import pulumi
import pulumi_eks as eks
from config import load_eks_config
from network import create_cluster_vpc
conf = load_eks_config()
vpc = create_cluster_vpc(conf.name)
cluster = eks.Cluster(
conf.name,
vpc_id=vpc.vpc_id,
public_subnet_ids=vpc.public_subnet_ids,
private_subnet_ids=vpc.private_subnet_ids,
# Run nodes in private subnets only; expose via load balancers.
node_associate_public_ip_address=False,
version=conf.k8s_version,
instance_type=conf.instance_type,
desired_capacity=conf.desired_size,
min_size=conf.min_size,
max_size=conf.max_size,
# Provider note: this creates the OIDC provider required for IRSA.
create_oidc_provider=True,
tags=conf.tags,
)
# State implication: kubeconfig contains a short-lived exec credential, not a
# static token. It is recorded in state—treat the stack as sensitive.
pulumi.export("kubeconfig", pulumi.Output.secret(cluster.kubeconfig))
pulumi.export("cluster_name", cluster.eks_cluster.name)
pulumi.export("oidc_provider_arn", cluster.core.oidc_provider.arn)
4. Attach an IRSA role to a service account (optional but recommended)
Once create_oidc_provider=True is set, you can mint IAM roles that specific Kubernetes service accounts assume. This keeps pod permissions least-privilege instead of granting the whole node group broad access.
The mechanism is worth understanding because the trust policy is where it usually goes wrong. The kubelet projects a signed service-account token into the pod; the AWS SDK exchanges it at STS via AssumeRoleWithWebIdentity; STS validates the signature against the OIDC provider's published JWKS and then checks that the token's sub claim matches the StringEquals condition below. The sub claim is always the literal string system:serviceaccount:<namespace>:<name>, and the condition key is the OIDC issuer host without the https:// scheme — which is why the code strips it before formatting. Get either wrong and the pod sees An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity, with no indication of which half failed.
# irsa.py
# CLI: pulumi up
from __future__ import annotations
import json
import pulumi
import pulumi_aws as aws
import pulumi_eks as eks
def create_irsa_role(
name: str,
cluster: eks.Cluster,
namespace: str,
service_account: str,
policy_arn: str,
) -> aws.iam.Role:
"""Create an IAM role assumable by one Kubernetes service account."""
oidc_arn = cluster.core.oidc_provider.arn
oidc_url = cluster.core.oidc_provider.url
def _policy(args: list[str]) -> str:
arn, url = args[0], args[1]
# Condition keys use the issuer host only — strip the scheme.
host = url.removeprefix("https://")
return json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Federated": arn},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
f"{host}:aud": "sts.amazonaws.com",
f"{host}:sub": (
f"system:serviceaccount:{namespace}:{service_account}"
),
}
},
}
],
}
)
# State implication: the policy document is an Output, so the Role is only
# created after the OIDC provider exists — the dependency edge is implicit.
assume_policy = pulumi.Output.all(oidc_arn, oidc_url).apply(_policy)
role = aws.iam.Role(f"{name}-irsa", assume_role_policy=assume_policy)
aws.iam.RolePolicyAttachment(
f"{name}-irsa-attach", role=role.name, policy_arn=policy_arn
)
return role
Annotate the Kubernetes ServiceAccount with the resulting role ARN — eks.amazonaws.com/role-arn — and the mutating admission webhook injects AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE into every pod that uses it. Boto3 1.28+ and the AWS SDK for Java 2.x pick those up automatically through the default credential chain, with no code change in the application.
# serviceaccount.py
# CLI: pulumi up
from __future__ import annotations
import pulumi
import pulumi_kubernetes as k8s
import pulumi_eks as eks
def bind_service_account(
name: str, cluster: eks.Cluster, namespace: str, role_arn: pulumi.Input[str]
) -> k8s.core.v1.ServiceAccount:
"""Create a ServiceAccount annotated for IRSA on the EKS cluster."""
# Provider note: use the cluster's own provider, not ambient kubeconfig,
# so the object lands on this EKS cluster even in a multi-stack workspace.
return k8s.core.v1.ServiceAccount(
f"{name}-sa",
metadata=k8s.meta.v1.ObjectMetaArgs(
name=name,
namespace=namespace,
annotations={"eks.amazonaws.com/role-arn": role_arn},
),
opts=pulumi.ResourceOptions(provider=cluster.provider),
)
Verification
Confirm the EKS cluster is reachable and the node group is healthy. Pulumi exports the kubeconfig as a secret; write it to a temporary file to drive kubectl.
CLI: Pull the kubeconfig from the stack and check node status.
pulumi stack output kubeconfig --show-secrets > /tmp/kubeconfig.json KUBECONFIG=/tmp/kubeconfig.json kubectl get nodes -o wide KUBECONFIG=/tmp/kubeconfig.json kubectl get pods -A
For an automated check, assert the exported outputs with a pulumi.runtime mock test so the EKS cluster shape is validated in CI without touching AWS:
# tests/test_eks.py
# CLI: pytest tests/test_eks.py -v
from __future__ import annotations
import pytest
import pulumi
from typing import Any
class _Mocks(pulumi.runtime.Mocks):
def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> tuple[str, dict[str, Any]]:
return (f"{args.name}-id", {**args.inputs})
def call(self, args: pulumi.runtime.MockCallArgs) -> dict[str, Any]:
return {}
@pytest.fixture(autouse=True)
def _set_mocks() -> None:
pulumi.runtime.set_mocks(_Mocks(), preview=False)
@pytest.mark.asyncio
async def test_cluster_version_is_pinned() -> None:
"""Assert the cluster is created with an explicit Kubernetes version."""
from config import load_eks_config
conf = load_eks_config()
assert conf.k8s_version, "Kubernetes version must be pinned, never default"
assert conf.min_size <= conf.desired_size <= conf.max_size
Gotchas & Edge Cases
Synchronously indexing subnet outputs throws at runtime. vpc.private_subnet_ids is an Output[list[str]], not a Python list. Writing vpc.private_subnet_ids[0] raises a type error because the value is not resolved yet. Pass the whole Output straight into eks.Cluster, or transform it with .apply()—never slice it directly.
Forgetting create_oidc_provider breaks IRSA silently. Without the OIDC provider, pods fall back to the node instance role and your scoped IAM roles are simply never assumable. There is no error at deploy time; you only discover it when a pod gets AccessDenied. Always enable it up front if any workload needs AWS permissions.
The kubeconfig is sensitive and lives in state. cluster.kubeconfig embeds an aws eks get-token exec credential and cluster CA data. Export it wrapped in pulumi.Output.secret() and ensure your state backend is encrypted—see Securing Pulumi secrets with AWS KMS and HashiCorp Vault for KMS-backed state encryption.
Renaming the Pulumi resource destroys the EKS cluster. The logical name in eks.Cluster(conf.name, ...) is part of the URN. Change eks:name in stack config from app to platform and the preview reads + create eks:index:Cluster platform followed by - delete eks:index:Cluster app — a full teardown of the control plane and every workload on it. If you must rename, add opts=pulumi.ResourceOptions(aliases=[pulumi.Alias(name="app")]) first, deploy that no-op, then rename.
Deleting the stack hangs on the VPC. Kubernetes-created load balancers and their security groups are not in Pulumi state, so pulumi destroy tears down the EKS cluster and then stalls on the VPC with DependencyViolation: The vpc 'vpc-0a1b…' has dependencies and cannot be deleted. Delete Service type=LoadBalancer and Ingress objects and wait for the AWS Load Balancer Controller to clean up before destroying, or the ENIs it left behind block the subnets.
Changing instance_type replaces the whole node group. aws.eks.NodeGroup treats the instance type list as an immutable property. Going from t3.medium to m6i.large shows ++ aws:eks/nodeGroup:NodeGroup app-nodes replace [diff: ~instanceTypes], and the replacement drains nodes only if a PodDisruptionBudget forces it to. Set PDBs on anything stateful before you touch that field.
Operational Notes
Choosing the node backend is the decision that shapes every later upgrade. pulumi_eks exposes three, and they are not interchangeable after the fact — switching from managed to self-managed nodes is a create-and-drain migration, not an in-place edit.
Addons and the version treadmill
The VPC CNI, CoreDNS, and kube-proxy ship as EKS addons with their own version axis. Leave them on the AWS default and a control-plane upgrade eventually leaves an addon two minors behind, at which point pods stop getting IPs. Declare them explicitly so the version is reviewable in a diff:
# addons.py
# CLI: pulumi up --target-dependents
from __future__ import annotations
import pulumi
import pulumi_aws as aws
import pulumi_eks as eks
def install_addons(
name: str, cluster: eks.Cluster, cni_role_arn: pulumi.Input[str]
) -> None:
"""Pin the EKS-managed addons instead of tracking the AWS default."""
aws.eks.Addon(
f"{name}-vpc-cni",
cluster_name=cluster.eks_cluster.name,
addon_name="vpc-cni",
addon_version="v1.18.3-eksbuild.2",
# Provider note: OVERWRITE lets Pulumi adopt the addon AWS pre-installed
# at cluster creation instead of failing with ResourceInUseException.
resolve_conflicts_on_create="OVERWRITE",
resolve_conflicts_on_update="OVERWRITE",
service_account_role_arn=cni_role_arn,
)
aws.eks.Addon(
f"{name}-coredns",
cluster_name=cluster.eks_cluster.name,
addon_name="coredns",
addon_version="v1.11.1-eksbuild.9",
resolve_conflicts_on_update="PRESERVE",
)
Without resolve_conflicts_on_create="OVERWRITE" the first pulumi up fails with ResourceInUseException: Addon vpc-cni is already present in cluster app, because EKS installs a default copy the moment the control plane comes up.
Upgrading the Kubernetes version
EKS refuses to skip minor versions, so 1.28 → 1.30 is two separate deploys. Each one follows the same loop, and the addon check at the end is the step teams forget.
CLI: Confirm nothing deprecated is still in use before the bump.
pulumi config set eks:k8sVersion 1.31 pulumi preview --diff KUBECONFIG=/tmp/kubeconfig.json kubectl get apiservices | grep -v True
Cost and drift
An idle EKS cluster costs about $73/month for the control plane before a single node exists, plus roughly $33/month per NAT gateway. Ephemeral preview environments should therefore share one EKS cluster across namespaces rather than standing up a control plane per pull request. On drift: Cluster Autoscaler mutating desired_capacity is the most common source of a phantom diff, and the fix is pulumi.ResourceOptions(ignore_changes=["desiredCapacity"]) on the node group rather than reconciling the number by hand. The wider treatment of that pattern is in idempotency and drift detection in Python IaC.
FAQ
Should I use pulumi_eks or build the EKS cluster from raw pulumi_aws resources?
Start with pulumi_eks. It encapsulates the control plane, node IAM roles, OIDC provider, and node group with sensible defaults, which removes most of the boilerplate and the easy-to-miss security-group wiring. Drop to raw pulumi_aws.eks resources only when you need a configuration the component does not expose, such as a fully custom launch template.
How do I roll the Kubernetes version without recreating the EKS cluster?
Bump version (and the node group's version) and run pulumi up. EKS performs an in-place control-plane upgrade, then node groups are replaced on a rolling basis. Always run pulumi preview first to confirm the control plane is updated rather than replaced, and upgrade one minor version at a time.
Can I deploy this into a specific AWS account using an assumed role?
Yes. Instantiate an aws.Provider with assume_role and pass it via opts=pulumi.ResourceOptions(provider=...), exactly as described in managing multi-account AWS environments with Pulumi Python. Keep one stack per account so state stays isolated.
Why does kubectl time out even though pulumi up succeeded?
The most common cause is nodes in private subnets without a route to the EKS control plane or to ECR. Confirm the NAT gateway exists and that the EKS cluster security group allows node-to-control-plane traffic on port 443. pulumi stack output plus aws eks describe-cluster will confirm the endpoint is active.
How do I give a colleague kubectl access without sharing the kubeconfig secret?
Grant their IAM principal cluster access rather than distributing credentials. On EKS API access-entry mode you create an aws.eks.AccessEntry plus an aws.eks.AccessPolicyAssociation; on the older aws-auth mode you add their role ARN to the ConfigMap via cluster.core.aws_auth. Either way they run aws eks update-kubeconfig --name app themselves and authenticate with their own session.
Should the EKS cluster live in the same Pulumi stack as the workloads running on it?
No. Keep the EKS cluster in a platform stack and consume it from application stacks through a StackReference, so redeploying an application never puts the control plane in the plan. Export the EKS cluster name and OIDC ARN, and rebuild the Kubernetes provider on the consumer side — the pattern is covered in handling Pulumi stack outputs and cross-stack references.
Related
- AWS Provider Deep Dive — the parent guide on provider initialization, credential routing, and state isolation.
- Managing multi-account AWS environments with Pulumi Python — deploy the EKS cluster into an isolated account with an assume-role provider.
- Securing Pulumi secrets with AWS KMS and HashiCorp Vault — encrypt the kubeconfig and workload secrets at rest in state.