Terraform configuration for a production-ready Amazon EKS cluster, including VPC networking, managed node groups, EBS CSI driver, AWS Load Balancer Controller, and External Secrets Operator — all wired up via EKS Pod Identity.
Originally forked from HashiCorp's EKS tutorial and extended with additional add-ons, variable-driven configuration, and production patterns for research and exploration.
| Document | Description |
|---|---|
| AWS Load Balancer Controller — Implementation | Design decisions and configuration for the LBC add-on. |
| External Secrets Operator — Implementation | Design decisions and configuration for the ESO add-on. |
| VPC CNI — NetworkPolicy Enforcement | Why NetworkPolicy enforcement must be explicitly enabled, and how to verify it. |
terraform apply provisions ~80 resources across five logical groups:
Uses terraform-aws-modules/vpc/aws v6.6.1.
| Resource | Count | Notes |
|---|---|---|
| VPC | 1 | /16 CIDR, DNS hostnames enabled |
| Private subnets | 3 | One per AZ, tagged for internal ELB |
| Public subnets | 3 | One per AZ, tagged for public ELB |
| Internet gateway | 1 | Attached to the VPC |
| NAT gateway | 1 | Single gateway (cost-optimized) in a public subnet |
| Elastic IP | 1 | Allocated for the NAT gateway |
| Route tables | 4 | 1 public + 3 private (one per AZ) |
Uses terraform-aws-modules/eks/aws v21.19.0.
| Resource | Notes |
|---|---|
| EKS control plane | Public endpoint, Kubernetes 1.36 by default |
| OIDC provider | For IAM Roles for Service Accounts (IRSA) |
| Managed node group 1 | node-group-1: desired 2, min 1, max 3 |
| Managed node group 2 | node-group-2: desired 1, min 1, max 2 |
| Launch templates | One per node group |
| Security groups | Cluster + node groups |
| IAM roles & policies | Cluster role, node instance role |
| Amazon VPC CNI add-on | Pod networking; installed before nodes join (before_compute = true); NetworkPolicy enforcement explicitly enabled — see implementation notes |
kube-proxy add-on |
Cluster service networking |
| CoreDNS add-on | In-cluster DNS |
| EKS Pod Identity agent add-on | Installed via cluster add-on |
| AWS EBS CSI driver add-on | Installed via cluster add-on |
Module v21 note: v21 sets
bootstrap_self_managed_addons = false, so EKS no longer auto-installs VPC CNI,kube-proxy, and CoreDNS. They are declared explicitly as managed add-ons inmain.tf; omitting them leaves nodes stuckNotReadywith no CNI.
| Resource | Notes |
|---|---|
IAM role AmazonEKSTFEBSCSIRole-<cluster> |
Assumed by the EBS CSI controller pod |
| IAM policy attachment | AmazonEBSCSIDriverPolicy (AWS managed) |
| Pod Identity association | Binds ebs-csi-controller-sa in kube-system to the IAM role |
| Resource | Notes |
|---|---|
IAM policy AWSLoadBalancerControllerIAMPolicy-<cluster> |
Fetched from LBC main branch (tagged releases have missing permissions) |
IAM role AmazonEKSTFLBCRole-<cluster> |
Assumed by the LBC pod via Pod Identity |
| Pod Identity association | Binds aws-load-balancer-controller SA in kube-system to the IAM role |
Helm release aws-load-balancer-controller |
Installed from eks/aws-load-balancer-controller chart |
| Resource | Notes |
|---|---|
IAM policy ExternalSecretsOperatorIAMPolicy-<cluster> |
Secrets Manager + SSM Parameter Store read permissions |
IAM role AmazonEKSTFESORRole-<cluster> |
Assumed by the ESO pod via Pod Identity |
| Pod Identity association | Binds external-secrets SA in external-secrets namespace to the IAM role |
Helm release external-secrets |
Installed from charts.external-secrets.io chart |
| Tool | Version | Install |
|---|---|---|
| Terraform | >= 1.5.7 | brew install terraform |
| AWS CLI | v2 | brew install awscli |
| kubectl | compatible with cluster version | brew install kubectl |
Your AWS credentials must have permission to create IAM roles, VPCs, EKS clusters, EC2 instances, and related resources. The Terraform caller is automatically granted cluster-admin via enable_cluster_creator_admin_permissions = true.
1. Clone and enter the repo
git clone <repo-url>
cd eks-starter2. Configure variables
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars — at minimum, set region and cluster_name_prefix3. Initialize Terraform
terraform init4. Review the plan
terraform plan5. Apply
Enter yes when prompted.
terraform applyProvisioning takes approximately 15–20 minutes. If everything succeeds you should see something like:
...
Apply complete! Resources: 75 added, 0 changed, 0 destroyed.
Outputs:
cluster_endpoint = "https://<VERY_LONG_IDENTIFIER>.gr7.us-east-2.eks.amazonaws.com"
cluster_name = "eks-starter"
cluster_security_group_id = "sg-0123456789abcdef0"
region = "us-east-2"
6. Configure kubectl config
aws eks --region $(terraform output -raw region) update-kubeconfig \
--name $(terraform output -raw cluster_name)7. Apply the ClusterSecretStores (External Secrets Operator)
terraform apply installs ESO itself, but not the ClusterSecretStore resources — those are CRDs
that must be applied after the ESO Helm release exists. Without this step, ESO is running but
ExternalSecret objects have nothing to reference.
REGION=$(terraform output -raw region)
kubectl apply -f - <<EOF
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: ${REGION}
---
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: aws-ssm-parameter-store
spec:
provider:
aws:
service: ParameterStore
region: ${REGION}
EOFSee External Secrets Operator — Implementation
for why these have no auth section (Pod Identity) and for ExternalSecret usage examples.
8. Verify
Run these checks in order after terraform apply completes. Each builds on the previous.
Nodes — all Ready
kubectl get nodesExpected: 3 nodes (2 from node-group-1, 1 from node-group-2), all STATUS=Ready
System pods — all Running
kubectl get pods -n kube-systemExpected: all pods Running/Completed, none in Pending/CrashLoopBackOff/Error
EBS CSI driver — Pod Identity wired up
Controller and node pods running
kubectl get pods -n kube-system -l app=ebs-csi-controllerkubectl get pods -n kube-system -l app=ebs-csi-nodePod Identity association exists
aws --no-cli-pager eks list-pod-identity-associations \
--cluster-name $(terraform output -raw cluster_name) \
--region $(terraform output -raw region) \
--query 'associations[?serviceAccount==`ebs-csi-controller-sa`]'Expected: one entry with namespace=kube-system
AWS Load Balancer Controller — healthy and watching
2 replicas running
kubectl get deployment -n kube-system aws-load-balancer-controllerExpected:
READY 2/2
No errors in logs (look for "Starting", "successfully acquired lease")
kubectl logs -n kube-system \
-l app.kubernetes.io/name=aws-load-balancer-controller \
--tail=20Pod Identity association exists
aws --no-cli-pager eks list-pod-identity-associations --cluster-name $(terraform output -raw cluster_name) --region $(terraform output -raw region) \
--query 'associations[?serviceAccount==`aws-load-balancer-controller`]'Expected: one entry with namespace=kube-system
External Secrets Operator — ClusterSecretStores ready
Requires step 7 (Apply the ClusterSecretStores) to have been run first.
kubectl get clustersecretstoreExpected:
aws-secrets-managerandaws-ssm-parameter-store, bothSTATUS=Valid READY=True
metrics-server — serving resource metrics
kubectl get deployment -n kube-system metrics-serverExpected: READY 1/1
kubectl top nodesExpected: CPU and MEMORY usage listed for each node (may take ~60s after first deploy)
VPC CNI — NetworkPolicy enforcement enabled
status: ACTIVE on the add-on is not enough to confirm this — check the config value landed:
aws --no-cli-pager eks describe-addon \
--cluster-name $(terraform output -raw cluster_name) \
--addon-name vpc-cni \
--region $(terraform output -raw region) \
--query 'addon.configurationValues'Expected:
"{\"enableNetworkPolicy\":\"true\"}"
See VPC CNI — NetworkPolicy Enforcement for the
full functional test (apply a NetworkPolicy, confirm a PolicyEndpoint is created, confirm
traffic is actually blocked) — without it, applied NetworkPolicy objects are silent no-ops.
The Quickstart above runs Terraform locally. This config also deploys cleanly through HCP Terraform (formerly Terraform Cloud) using a VCS-driven workspace, where state lives remotely and runs execute on HCP's runners.
1. Connect the repo and create a workspace
In your HCP Terraform org: Settings → Version Control → authorize the GitHub app for this repo, then create a Version Control Workflow workspace pointing at it (working directory = repo root).
2. Provide AWS credentials as Environment variables
The aws provider reads the standard AWS credential chain, so these must be Environment variables — not Terraform variables, or the provider never sees them:
| Key | Category | Sensitive |
|---|---|---|
AWS_ACCESS_KEY_ID |
env | no |
AWS_SECRET_ACCESS_KEY |
env | yes |
AWS_DEFAULT_REGION |
env | no |
Use a dedicated IAM user (or, better, OIDC dynamic credentials) — never the AWS root account.
3. Set Terraform variables in the workspace
Because *.tfvars is gitignored, set overrides like cluster_name_prefix as Terraform variables in the workspace UI instead of a committed terraform.tfvars. Defaults in variables.tf apply for anything you don't override.
4. Run and apply
A push to the tracked branch queues a plan. Keep Apply Method = Manual apply (Workspace → Settings → General) so you review the plan before ~80 resources — and real cost — are created, then click Confirm & Apply. Provisioning takes ~15–20 minutes.
5. Configure kubectl — note the differences from local
terraform output -raw ... in the Quickstart does not work locally with HCP, since state is remote. Use the known values instead — cluster_name equals your cluster_name_prefix, and the region is your AWS_DEFAULT_REGION (or read both from the workspace Outputs tab):
aws eks --region <region> update-kubeconfig --name <cluster_name_prefix>Because the apply runs as the workspace's IAM identity, that identity — not your laptop — is the cluster creator/admin (enable_cluster_creator_admin_permissions = true). To get kubectl access, either point a named AWS profile at the same key and pass --profile <name> to update-kubeconfig, or grant your own principal an EKS access entry.
6. Teardown
Follow the Teardown steps below, but run terraform destroy as a destroy run from the workspace (Settings → Destruction and Deletion → Queue destroy plan) rather than locally, since state is remote.
| Variable | Default | Description |
|---|---|---|
region |
us-east-2 |
AWS region to deploy into |
cluster_name_prefix |
eks-starter |
Prefix for cluster and VPC names |
cluster_version |
1.36 |
Kubernetes version |
ami_type |
AL2023_x86_64_STANDARD |
Node AMI type |
instance_types |
["t3.small"] |
EC2 instance types for both node groups |
vpc_cidr |
10.0.0.0/16 |
VPC CIDR block |
private_subnets |
["10.0.1.0/24", ...] |
Private subnet CIDRs (one per AZ) |
public_subnets |
["10.0.4.0/24", ...] |
Public subnet CIDRs (one per AZ) |
lbc_chart_version |
3.4.0 |
Helm chart version for the AWS Load Balancer Controller |
eso_chart_version |
2.6.0 |
Helm chart version for External Secrets Operator |
eso_secret_arns |
["*"] |
AWS Secrets Manager ARN patterns ESO is permitted to read |
See terraform.tfvars.example for a ready-to-copy template.
| Output | Description |
|---|---|
cluster_name |
EKS cluster name |
cluster_endpoint |
API server endpoint URL |
cluster_security_group_id |
Security group attached to the control plane |
region |
AWS region |
eso_iam_role_arn |
IAM role ARN used by the External Secrets Operator |
Check for any ingress resources tied to AWS load balancers and delete if necessary. These LBs would get orphaned if not explicitly destroyed before cluster deletion.
kubectl get ingress --all-namespacesIf you see ingress resources, delete them first:
kubectl delete ingress -n <namespace> <ingress-name>You might need to wait a few seconds before the command completes.
Confirm the associated AWS LBs were also deleted with this aws cli command:
aws --no-cli-pager elbv2 describe-load-balancers \
--region $(terraform output -raw region) \
--query 'LoadBalancers[*].LoadBalancerName' --output textIf there's no output then you should be clear to destroy the cluster.
terraform destroy