Terraform EKS with GitHub Actions CI/CD
Deploy a production-grade EKS cluster with Terraform modules and CI/CD pipelines.
Introduction
30 Second Summary
When teams manage cloud infrastructure by hand, one wrong click can take down production. The fix is infrastructure as code with automated guardrails that review every change before it reaches real servers.
In this project, you will build a production-ready Terraform repository that deploys an EKS Kubernetes cluster on AWS, complete with GitHub Actions CI/CD pipelines that automatically plan on pull requests and apply on merge.
What You'll Build
You will run kubectl get nodes against a live Kubernetes cluster you provisioned entirely through code, then watch GitHub Actions catch infrastructure changes in a pull request before they touch AWS.
By the end of this project, you'll have:
- A modular Terraform codebase with reusable VPC and EKS modules, separated from environment-specific configuration.
- A working EKS cluster with managed node groups that you can connect to with kubectl and see running nodes.
- GitHub Actions workflows that automatically run terraform plan on pull requests and terraform apply on merge to main, with a manual approval gate for production safety.
- Remote state stored in S3 with DynamoDB locking, preventing state corruption in team environments.
- Secret Mission: Add a production environment with hardened settings including multi-AZ NAT gateways, private-only cluster endpoints, and IRSA for pod-level AWS access.
Are there any prerequisites?
This is an advanced project. You need Terraform CLI, AWS CLI (configured with credentials), kubectl, and Git installed on your machine.
You also need a paid AWS account (EKS does not have a free tier) and a GitHub account.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your infrastructure skills.
Set Up Repository Structure and Remote State Backend
Without remote state, Terraform tracks your infrastructure in a local file on your machine.
That local file cannot be shared across team members or CI/CD runners. Collaboration becomes impossible, and the file itself becomes a single point of failure. In this step, you will create a remote state backend using S3 and DynamoDB that stores your Terraform state centrally and prevents concurrent writes from corrupting it.
In this step, get ready to:
- Create the production repository folder structure and initialize Git.
- Write the Terraform configuration for the S3 state bucket and DynamoDB lock table.
- Deploy and verify the state backend resources with the AWS CLI.
Create the repository folder structure
Production Terraform repositories follow a consistent layout that separates reusable modules from environment-specific configuration. You will create this structure now so every file has a clear home from the start.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, initialize a Git repository, and build the directory layout by running these commands:
mkdir eks-terraform-cicd
cd eks-terraform-cicd
git init
mkdir -p modules environments/dev .github/workflows state-backend
What does this structure do?
- modules/ holds reusable Terraform modules you can call from any environment.
- environments/dev/ holds the dev environment configuration that references those modules.
- .github/workflows/ holds the CI/CD pipeline definitions for GitHub Actions.
- state-backend/ holds a standalone Terraform config that bootstraps the remote state infrastructure.
- Confirm the folders exist by running:
ls
You should see four entries: .github, environments, modules, and state-backend.
Don't see all four folders?
Make sure you ran the mkdir -p command from inside the eks-terraform-cicd directory. Run pwd to confirm your current location ends with eks-terraform-cicd.
help me figure out why my folder structure is missing directories
Write and deploy the state backend bootstrap
The state backend is a dedicated Terraform configuration whose only job is to create the S3 bucket and DynamoDB table that all other environments will use to store their state. You will build this file in chunks, running Terraform between edits to confirm each piece works.
- Create state-backend/main.tf and add the Terraform and provider configuration by pasting the following code:
terraform {
required_version = ">= 1.5.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.52"
}
}
}
provider "aws" {
region = "us-east-1"
}
What does this code do?
- required_version = ">= 1.5.7" ensures anyone running this config has a compatible Terraform CLI.
- required_providers pins the AWS provider to version 6.52 or higher, which is required by the EKS module you will use later.
- provider "aws" sets the region to us-east-1 for all resources in this configuration.
- Save state-backend/main.tf.
- Initialize Terraform in the state-backend directory by running:
cd state-backend
terraform init
You should see a message ending with Terraform has been successfully initialized! confirming the AWS provider was downloaded.
Seeing a provider error?
If you see a message about failing to query available provider packages, check your internet connection. Also confirm your AWS CLI is configured by running aws sts get-caller-identity in a separate terminal.
help me fix my terraform init provider error
- Add the S3 bucket and versioning resources below the provider block in state-backend/main.tf:
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-eks-terraform-state-[[STATE_BUCKET_SUFFIX=\"your unique suffix e.g. jsmith-2026\"]]"
lifecycle {
prevent_destroy = true
}
tags = {
Name = "Terraform State"
Environment = "shared"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
What does this code do?
The aws_s3_bucket resource creates your state bucket. S3 bucket names must be globally unique across all AWS accounts, so you need a personal suffix.
- prevent_destroy = true stops accidental deletion of your state file.
- The versioning resource enables version history so you can recover a previous state if something goes wrong.
- Save state-backend/main.tf and run a plan to confirm Terraform can parse the new resources:
terraform plan
You should see Plan: 2 to add, 0 to change, 0 to destroy at the bottom of the output.
- Add the encryption and public access block resources below the versioning resource in state-backend/main.tf:
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
What does this code do?
- The encryption configuration ensures every object written to this bucket is encrypted at rest with KMS.
- The public access block applies four layers of protection that prevent anyone from accidentally making the bucket or its objects public.
- Now add the DynamoDB lock table and outputs at the bottom of state-backend/main.tf:
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform State Locks"
Environment = "shared"
ManagedBy = "terraform"
}
}
output "state_bucket_name" {
value = aws_s3_bucket.terraform_state.id
}
output "dynamodb_table_name" {
value = aws_dynamodb_table.terraform_locks.name
}
What does this code do?
The DynamoDB table acts as a lock. When one person or CI runner runs terraform apply, Terraform writes a lock entry to this table. Any second attempt to apply at the same time is blocked until the first finishes, preventing state corruption.
PAY_PER_REQUEST means you only pay for actual read/write operations. For a lock table with minimal traffic, the cost is effectively zero under the free tier.
- Save state-backend/main.tf.
Before you apply, what do you think Terraform will create? How many resources should it report in the plan?
- Deploy the state backend by running:
terraform apply
Terraform will show a plan and ask you to confirm. Type yes and press Enter.
You should see Apply complete! Resources: 5 added, 0 changed, 0 destroyed. along with two outputs showing your bucket name and table name.
Seeing a bucket name error?
If you see an error about the bucket already existing, your suffix is not unique enough. S3 bucket names are globally unique across all AWS accounts. Try adding more characters to your suffix (e.g. append today's date).
help me fix my S3 bucket name conflict during terraform apply
✔️ Awesome, I've got everything!
Great. Double check you have saved state-backend/main.tf and that your terminal shows the apply completed successfully.
ⓧ I'd like to double check the full code
Your state-backend/main.tf should contain these four chunks in order. Scroll up and confirm each one is present:
- Terraform block + provider block (required_version, required_providers, provider "aws").
- S3 bucket + versioning (aws_s3_bucket and aws_s3_bucket_versioning).
- Encryption + public access block (aws_s3_bucket_server_side_encryption_configuration and aws_s3_bucket_public_access_block).
- DynamoDB table + outputs (aws_dynamodb_table, state_bucket_name, dynamodb_table_name).
Verify the state backend resources exist
Terraform reported success, but you should always confirm resources actually exist in AWS independently.
- List your S3 buckets and filter for the state bucket by running:
aws s3 ls | grep terraform-state
You should see a line showing your bucket name with today's creation date.
- Verify the DynamoDB table exists by running:
aws dynamodb list-tables --region us-east-1
You should see terraform-state-locks in the TableNames array.
Not seeing your resources?
Confirm you are querying the correct region. Both commands should target us-east-1 since that is where the provider deployed them. Run aws configure get region to check your default region.
help me find my Terraform state backend resources in AWS
Your remote state backend is live. Every Terraform environment you create from here on will store its state in this S3 bucket, locked by this DynamoDB table. Next up, you will build the VPC and EKS modules that deploy a real Kubernetes cluster.
Build VPC and EKS Infrastructure Modules
Your remote state backend is live and ready to store infrastructure state securely. Now it's time to write the Terraform configuration that defines your actual cloud infrastructure.
Writing everything in one flat Terraform file makes changes dangerous. A failed IAM change can block an unrelated VPC update. By separating infrastructure into modules, you isolate concerns and can target applies safely. In this step, you'll use battle-tested community modules to define your VPC and EKS cluster.
In this step, get ready to:
- Write the VPC module configuration with networking and Kubernetes subnet tags.
- Add the EKS cluster module with managed node groups.
- Configure the remote backend and validate the full plan.
Write the dev environment VPC configuration
The community terraform-aws-modules/vpc/aws module encodes production networking best practices. It creates a VPC with public and private subnets across multiple availability zones, a NAT gateway for outbound internet access from private subnets, and the subnet tags that EKS needs to discover subnets for load balancers.
Before calling the module, you need to define the variables it will reference.
- Create environments/dev/variables.tf and add the project-level variables:
variable "aws_region" {
description = "AWS region for the infrastructure"
type = string
}
variable "project_name" {
description = "Name of the project, used as prefix for resources"
type = string
}
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
What do these variables do?
Each variable declaration tells Terraform to expect a value at runtime. The description field documents what the variable holds, and type enforces the data type so Terraform can catch mistakes early.
- Below the vpc_cidr variable, add the networking variables:
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
Why list(string) for subnets?
The VPC module expects one subnet CIDR per availability zone. Using list(string) lets you pass multiple CIDR blocks that map to each AZ.
- Save variables.tf.
✔️ Awesome, I've got everything!
Great. Confirm you have 7 variable blocks total in the file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your file has these two groups in order:
- Project-level variables: aws_region, project_name, environment, vpc_cidr.
- Networking variables: availability_zones, private_subnet_cidrs, public_subnet_cidrs.
- Create environments/dev/terraform.tfvars to supply the actual values for your dev environment:
aws_region = "us-east-1"
project_name = "eks-production"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
private_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnet_cidrs = ["10.0.101.0/24", "10.0.102.0/24"]
What does this file do?
The .tfvars file assigns concrete values to each variable. This is where environment separation happens. A production environment would have a different .tfvars with more AZs and a larger CIDR range.
- Save terraform.tfvars.
- Confirm both files exist by running:
ls environments/dev/
You should see variables.tf and terraform.tfvars listed in the output.
Now you can write the VPC module that references these variables.
- Create environments/dev/main.tf and add the AWS provider at the top:
provider "aws" {
region = var.aws_region
}
- Below the provider block, add the VPC module configuration:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "6.6.1"
name = "${var.project_name}-${var.environment}-vpc"
cidr = var.vpc_cidr
azs = var.availability_zones
private_subnets = var.private_subnet_cidrs
public_subnets = var.public_subnet_cidrs
enable_nat_gateway = true
single_nat_gateway = true
enable_dns_hostnames = true
public_subnet_tags = {
"kubernetes.io/role/elb" = 1
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1
}
tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this VPC module create?
- source and version pin the community VPC module to v6.6.1 so builds are reproducible.
- azs spreads resources across 2 availability zones for redundancy.
- single_nat_gateway = true saves cost in dev by routing all private subnets through one NAT gateway.
- The kubernetes.io/role/elb and kubernetes.io/role/internal-elb tags let EKS automatically discover which subnets to place load balancers in.
- Save main.tf.
Seeing a syntax error?
Check that the provider block closes with } before the module "vpc" block begins. Make sure every opening { has a matching } and that string interpolations use ${...} syntax.
help me fix a syntax error in my Terraform VPC module configuration
Add the EKS cluster configuration
With the VPC defined, you can now add the EKS cluster that will run inside it. The community EKS module handles the complex IAM roles, security groups, and add-ons that a production cluster needs.
- In environments/dev/main.tf, add the EKS module below the VPC module:
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "21.24.1"
cluster_name = "${var.project_name}-${var.environment}"
cluster_version = "1.33"
cluster_endpoint_public_access = true
cluster_endpoint_private_access = true
enable_cluster_creator_admin_permissions = true
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
eks_managed_node_groups = {
default = {
instance_types = ["t3.medium"]
min_size = 2
max_size = 4
desired_size = 2
}
}
tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this EKS module configure?
- cluster_version = "1.33" pins to Kubernetes 1.33 for stability.
- Both public and private endpoint access means you can reach the API from your laptop and from within the VPC.
- enable_cluster_creator_admin_permissions grants your IAM identity admin access to the cluster via an access entry.
- The managed node group launches 2 t3.medium EC2 instances that EKS manages (patching, draining, and replacement are automatic).
- subnet_ids = module.vpc.private_subnets places worker nodes in private subnets so they are not directly exposed to the internet.
- Save main.tf.
Why community modules over custom?
The terraform-aws-modules/eks/aws and terraform-aws-modules/vpc/aws modules encode years of production best practices including proper tagging, IAM entry management, and add-on ordering. Writing custom modules from scratch is a multi-week effort that teaches Terraform syntax but not EKS patterns. The community modules let you focus on architecture decisions.
✔️ Awesome, I've got everything!
Great. Confirm you saved main.tf with the provider block at the top, then the VPC module, then the EKS module.
ⓧ I'd like to double check the full code
Your environments/dev/main.tf should contain three sections in this order:
- The provider "aws" block (3 lines) setting region to var.aws_region.
- The module "vpc" block calling terraform-aws-modules/vpc/aws version 6.6.1 with subnets, NAT gateway, and Kubernetes tags.
- The module "eks" block calling terraform-aws-modules/eks/aws version 21.24.1 with the cluster name, node group, and endpoint settings.
- Create environments/dev/outputs.tf to expose key values you'll need after deployment:
output "cluster_name" {
description = "EKS cluster name"
value = module.eks.cluster_name
}
output "cluster_endpoint" {
description = "EKS cluster API endpoint"
value = module.eks.cluster_endpoint
}
output "cluster_region" {
description = "AWS region where the cluster is deployed"
value = var.aws_region
}
output "vpc_id" {
description = "VPC ID"
value = module.vpc.vpc_id
}
What are outputs for?
Outputs make Terraform print key values after an apply. You'll use cluster_name and cluster_region to configure kubectl in the next step.
- Save outputs.tf.
- Confirm your dev directory now has four files by running:
ls environments/dev/
You should see main.tf, outputs.tf, terraform.tfvars, and variables.tf in the output.
Missing a file?
Make sure you created each file inside the environments/dev/ directory, not in the project root. If a file ended up in the wrong place, move it with mv.
help me find where my Terraform files ended up
Configure the remote backend and validate the plan
The infrastructure code is written, but Terraform doesn't know where to store its state yet. You need to point it at the S3 bucket and DynamoDB table you created in Step 1.
- Create environments/dev/backend.tf to configure remote state storage:
terraform {
backend "s3" {
bucket = [[STATE_BUCKET="your-state-bucket-name-from-step-1"]]
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
What does this backend block do?
- bucket points to the S3 bucket you deployed in Step 1.
- key sets the path inside the bucket where this environment's state file lives. Each environment gets its own key to avoid collisions.
- dynamodb_table enables state locking so two people cannot run terraform apply at the same time.
- encrypt = true ensures the state file is encrypted at rest using the KMS key on the bucket.
- Save backend.tf.
- Create environments/dev/versions.tf to pin the Terraform and provider versions:
terraform {
required_version = ">= 1.5.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.52"
}
}
}
Why pin versions?
The EKS module v21.24.1 requires the AWS provider >= 6.52. Pinning here ensures anyone cloning this repo gets a compatible provider version without guessing.
- Save versions.tf.
- Initialize Terraform in the dev environment to download the modules and configure the backend by running:
cd environments/dev
terraform init
Terraform will download the VPC and EKS modules from the registry and configure the S3 backend. You should see output ending with Terraform has been successfully initialized! along with messages about initializing the backend and downloading modules.
Backend initialization failed?
If you see an error about the S3 bucket not existing, double-check the bucket value in backend.tf matches your actual bucket name from Step 1 exactly. Run aws s3 ls to see your available buckets.
If you see a DynamoDB error, confirm the table name is terraform-state-locks (not terraform-locks or another variation).
help me debug my Terraform backend initialization error
Now for the moment of truth. Before you run the plan, what do you think Terraform will report? Consider how many AWS resources a VPC with 2 AZs, subnets, a NAT gateway, and an EKS cluster with node groups would need.
- Generate the execution plan to see what Terraform will create by running:
terraform plan
The plan will take a few seconds as Terraform calculates all the resources needed. At the end, you should see a summary line showing resources to add (typically 30-50+ resources) and zero to change or destroy.
Scan the plan output for the key resources: VPC, subnets, NAT gateway, internet gateway, route tables, EKS cluster, managed node group, security groups, and IAM roles.
Plan shows errors instead of resources?
If you see a variable-related error, confirm terraform.tfvars exists in the same directory and all 7 variables are defined.
If you see a module source error, confirm you are running terraform plan from inside environments/dev/ (not the project root). The terraform init step must have completed successfully first.
help me fix my terraform plan errors
Your infrastructure is defined and validated. Next up, you'll run terraform apply to deploy a real EKS cluster and connect to it with kubectl.
Deploy the Dev EKS Cluster and Connect with kubectl
Your terraform plan showed zero errors, but a plan only proves your syntax is valid. It does not prove that AWS will accept your resource configuration, that IAM permissions are correct, or that the modules compose together without conflicts at deploy time.
In this step, you will deploy the full EKS cluster and VPC stack to AWS. Then you will connect with kubectl to prove the cluster is alive and responding to commands.
In this step, get ready to:
- Deploy the EKS cluster and VPC with terraform apply.
- Connect kubectl to the new cluster.
- Verify the cluster has healthy nodes and running system pods.
Deploy the full stack with terraform apply
Provisioning an EKS cluster involves creating the Kubernetes control plane, registering managed node groups, and waiting for health checks to pass. This process takes approximately 12-18 minutes.
This will cost money
The EKS cluster costs $0.10 per hour. The two t3.medium EC2 nodes cost approximately $0.04 per hour each. The NAT gateway costs approximately $0.045 per hour. Your total running cost is roughly $0.23 per hour.
You will destroy these resources at the end of the project. If you complete the project in about 2 hours, the total cost is under $1.
- In your terminal, make sure you are still in the environments/dev directory from the previous step.
- Deploy all resources by running:
terraform apply
- Terraform will show the plan again and ask for confirmation. Type yes and press Enter to proceed.
What happens during the apply?
Terraform creates resources in dependency order. The VPC and subnets come first, then the NAT gateway and route tables, then the EKS control plane, and finally the managed node group.
The EKS control plane is the slowest resource to provision. You will see the terminal appear to pause on the module.eks resources for several minutes. This is normal.
After approximately 15 minutes, Terraform prints Apply complete! followed by the outputs you defined. Note the cluster_name and cluster_region values in the output. You will use them in the next command.
Apply failed or timed out?
- If you see a permissions error, confirm your AWS CLI credentials have the permissions to create EKS clusters, VPCs, and IAM roles. Check with aws sts get-caller-identity that you are using the expected account.
- If you see a quota or limit error, your account may have hit the default VPC or EIP limit for the region. Try a different region by updating terraform.tfvars.
- If the apply timed out, run terraform apply again. Terraform will pick up where it left off because state is tracked remotely.
Help me debug my terraform apply failure
Update kubeconfig and verify node connectivity
Your EKS cluster is running, but kubectl on your local machine does not know about it yet. You need to update your local kubeconfig file so kubectl can authenticate and connect.
- Configure kubectl to connect to your new cluster by running:
aws eks update-kubeconfig --name eks-production-dev --region us-east-1
What does this command do?
This command adds a new context to your ~/.kube/config file. The context contains the cluster API endpoint and the authentication method (your AWS credentials). After this runs, kubectl commands are directed at your EKS cluster.
Before you run the next command, think about what you expect to see. How many nodes did your Terraform configuration request?
- Verify that your nodes are healthy by running:
kubectl get nodes
You should see 2 nodes listed, both with a Ready status. This matches the desired_size = 2 you configured in your EKS module.
Nodes not showing Ready?
- If you see no nodes at all, wait 2-3 minutes. The managed node group can take a few minutes after the cluster is ready to register nodes.
- If you see a connection error, confirm the aws eks update-kubeconfig command completed without errors. Run it again if needed.
- If nodes show NotReady, wait another minute and run kubectl get nodes again. Nodes transition from NotReady to Ready as kubelet completes initialization.
Help me troubleshoot my kubectl connection to EKS
Confirm cluster system pods are running
Healthy nodes are a good sign, but a fully functional cluster also needs its system components running. CoreDNS handles service discovery inside the cluster. Kube-proxy manages networking rules on each node.
- Check that all expected namespaces exist by running:
kubectl get namespaces
You should see four namespaces: default, kube-system, kube-public, and kube-node-lease.
- Verify the critical system pods are running by checking the kube-system namespace:
kubectl get pods -n kube-system
You should see CoreDNS pods and kube-proxy pods all in Running status. This confirms your cluster is fully operational and ready to host workloads.
Why check system pods?
A cluster with Ready nodes but broken system pods will silently fail when you deploy applications. CoreDNS resolves service names to IP addresses. Without it, pods cannot find each other. Kube-proxy routes traffic between services. Checking both now saves debugging time later.
Pods stuck in Pending or CrashLoopBackOff?
- Pending pods usually mean the nodes are not ready yet. Wait a few minutes and run the command again.
- CrashLoopBackOff on CoreDNS can indicate a networking issue with the VPC or subnets. Check that your NAT gateway was created successfully in the AWS VPC console.
Help me debug kube-system pods that are not running
Your EKS cluster is deployed, connected, and healthy. You have a running Kubernetes environment provisioned entirely through Terraform code. Next up, you will add GitHub Actions CI/CD so your team can safely plan and apply changes through pull requests instead of running commands from a laptop.
Add GitHub Actions CI/CD Pipeline
You just deployed your EKS cluster from your laptop. That works for one person, but in a team this approach creates problems.
Manual applies cause infrastructure drift when teammates run different versions of code. They skip code reviews because there is no pull request gate. They leave no audit trail because nothing is logged centrally. CI/CD with plan-on-PR and apply-on-merge with approval gates solves all three.
In this step, get ready to:
- Create a plan workflow that runs terraform plan on every pull request.
- Create an apply workflow that runs terraform apply on merge to main with an approval gate.
- Push the repository to GitHub and verify the workflows appear under Actions.
Create the plan workflow trigger and permissions
The plan workflow fires on every pull request that touches infrastructure files. It runs terraform plan so reviewers can see exactly what will change before approving.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Create .github/workflows/plan.yml and add the trigger and permissions configuration:
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
permissions:
id-token: write
contents: read
pull-requests: write
What does this configuration do?
- The pull_request trigger fires this workflow only when a PR targets main and changes files under environments/ or modules/. README changes will not waste CI minutes.
- The id-token: write permission lets the runner request a short-lived OIDC token from GitHub, which AWS exchanges for temporary credentials. No long-lived secrets required.
- The pull-requests: write permission allows the workflow to post plan output as a PR comment (useful for future enhancements).
Add the plan workflow job steps
- Add the jobs section below the permissions block in .github/workflows/plan.yml:
jobs:
plan:
name: Terraform Plan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.8"
- name: Terraform Init
run: terraform init
working-directory: environments/dev
- name: Terraform Plan
run: terraform plan -no-color
working-directory: environments/dev
What do these job steps do?
- The aws-actions/configure-aws-credentials@v6 action exchanges the GitHub OIDC token for temporary AWS credentials via the role ARN stored in your repository secret.
- The hashicorp/setup-terraform@v4 action installs Terraform 1.15.8 on the runner, matching the version you used locally.
- The -no-color flag on plan strips ANSI escape codes so the output renders cleanly in GitHub logs.
- Save .github/workflows/plan.yml.
- Confirm the file exists by running:
cat .github/workflows/plan.yml
You should see the full YAML content you just added, starting with name: Terraform Plan.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
Your plan.yml should contain two sections, in this order:
- The trigger and permissions block (name, on, permissions).
- The jobs block (jobs, plan, steps including Checkout, Configure AWS Credentials, Setup Terraform, Terraform Init, Terraform Plan).
Scroll up and confirm each section is present, in order.
File not found?
- Make sure you ran mkdir -p .github/workflows from your project root directory (the same folder that contains environments/ and state-backend/).
- Check for typos in the path. The folder is .github with a leading dot.
Still stuck? Why can't I find my plan.yml file?
Create the apply workflow
The apply workflow fires when code merges to main. It uses a GitHub Actions environment called production with required reviewers, creating a manual approval gate before any infrastructure changes go live.
- Create .github/workflows/apply.yml and add the trigger and permissions configuration:
name: Terraform Apply
on:
push:
branches:
- main
paths:
- 'environments/**'
- 'modules/**'
permissions:
id-token: write
contents: read
- Add the jobs section below the permissions block in .github/workflows/apply.yml:
jobs:
apply:
name: Terraform Apply
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.8"
- name: Terraform Init
run: terraform init
working-directory: environments/dev
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: environments/dev
What is different about this workflow?
The environment: production line tells GitHub to pause the job and wait for a designated reviewer to approve before proceeding. This is the approval gate that prevents accidental infrastructure changes from merging straight to production.
The -auto-approve flag skips Terraform's interactive prompt because the human approval already happened at the GitHub environment level.
- Save .github/workflows/apply.yml.
- Confirm both workflow files exist by running:
ls .github/workflows/
You should see apply.yml and plan.yml listed.
Only seeing one file?
Double check that both files are saved in .github/workflows/ (not a nested subfolder). Verify you are running ls from your project root.
Need help? I only see one workflow file in .github/workflows
Why OIDC instead of IAM user keys?
With OIDC, GitHub mints a short-lived JWT for each workflow run. AWS exchanges it for temporary credentials scoped to that single job. No long-lived access keys to rotate, no secrets that can leak in logs.
This is the current best practice for GitHub Actions to AWS authentication. The aws-actions/configure-aws-credentials action handles the entire token exchange automatically.
Push to GitHub and configure the production environment
Before the workflows can run, you need to connect GitHub to AWS via OIDC and push your code to a GitHub repository.
- Create a new repository on GitHub. Name it something like eks-terraform-cicd. Keep it private. Do not initialize it with a README.
- Open the AWS IAM console at https://console.aws.amazon.com/iam/.
- In the left navigation pane, click Identity providers, then click Add provider.
- Select OpenID Connect as the provider type.
- For Provider URL, enter https://token.actions.githubusercontent.com and click Get thumbprint.
- For Audience, enter sts.amazonaws.com.
- Click Add provider.
The OIDC identity provider now exists in your AWS account. Next, create an IAM role that GitHub Actions can assume.
- In the IAM console, click Roles in the left pane, then click Create role.
- Select Web identity as the trusted entity type.
- For Identity provider, choose token.actions.githubusercontent.com.
- For Audience, choose sts.amazonaws.com.
- Click Next.
- Attach the AdministratorAccess policy (for this learning project only). Click Next.
- Name the role github-actions-terraform-role and click Create role.
Why AdministratorAccess?
This is a learning project where the role provisions VPCs, EKS clusters, and IAM resources. In production you would scope the policy down to only the exact permissions Terraform needs. For this project, broad access avoids permission errors during plan and apply.
- Open the role you just created and click the Trust relationships tab.
- Click Edit trust policy.
- Replace the existing Condition block so the full policy looks like this (substituting your GitHub username and repository name):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID="your AWS account ID"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[[GITHUB_ORG="your-github-username"]]/[[GITHUB_REPO="your-repo-name"]]:*"
}
}
}
]
}
What does this trust policy do?
The StringLike condition with :* at the end allows both branch-based workflows (plan on PRs) and environment-based workflows (apply with the production environment). GitHub changes the sub claim format depending on whether an environment is specified in the workflow.
- Click Update policy.
- Copy the role ARN from the role summary page (it looks like arn:aws:iam::123456789012:role/github-actions-terraform-role).
Now add the role ARN as a secret in your GitHub repository.
- In your GitHub repository, go to Settings > Secrets and variables > Actions.
- Click New repository secret.
- Set the name to AWS_ROLE_ARN and paste the role ARN as the value.
- Click Add secret.
Next, set up the production environment with an approval gate.
- In your GitHub repository, go to Settings > Environments.
- Click New environment.
- Name it production and click Configure environment.
- Under Deployment protection rules, check Required reviewers.
- Add your own GitHub username as a reviewer.
- Click Save protection rules.
Finally, push the complete repository to GitHub.
- Push your code by running these commands from your project root:
git add .
git commit -m "Add CI/CD workflows and infrastructure code"
git remote add origin https://github.com/[[GITHUB_ORG="your-github-username"]]/[[GITHUB_REPO="your-repo-name"]].git
git push -u origin main
Push rejected or authentication error?
- If you see a rejected push, make sure the remote repository is empty (no README or .gitignore was auto-created).
- If you see an authentication error, GitHub requires a Personal Access Token or SSH key. Generate a token at https://github.com/settings/tokens and use it as the password when prompted.
Need help? I cannot push to my GitHub repository
Before you check the result, what do you expect to see under the Actions tab? Do you think the workflows will have run already, or just be listed?
- In your GitHub repository, click the Actions tab.
You should see both Terraform Plan and Terraform Apply listed as workflows in the left sidebar. The apply workflow may have triggered from your push to main, but it will wait for approval at the production environment gate before running any Terraform commands.
Your CI/CD pipeline is in place. Every pull request will automatically show a plan diff, and merges to main will only apply after a human approves. Next up, you will clean up your cluster to avoid ongoing charges.
Clean Up and Tear Down
Your EKS cluster is running and your CI/CD pipeline is configured. Every hour the cluster stays active costs money.
EKS charges $0.10/hour for the control plane, the two t3.medium nodes add ~$0.08/hour combined, and the NAT gateway adds another ~$0.045/hour. Proper teardown is a production skill in itself. In this step, you will destroy all billable resources and verify they are gone.
In this step, get ready to:
- Run terraform destroy to remove the EKS cluster, nodes, and VPC.
- Verify no project resources remain in the AWS Console.
- Optionally destroy the state backend if you are done experimenting.
Run terraform destroy on the dev environment
Terraform tracks every resource it created. The terraform destroy command reads the state file and removes each resource in the correct dependency order.
- Navigate to the environments/dev directory by running:
cd environments/dev
- Destroy all resources managed by this Terraform configuration by running:
terraform destroy
What happens when you run this?
Terraform will show you a plan of everything it intends to destroy. It then prompts you to confirm by typing yes. The full destruction takes approximately 10 minutes because EKS cluster deletion is a multi-step process on the AWS side.
Terraform deletes resources in reverse dependency order: node groups first, then the cluster, then networking (subnets, NAT gateway, VPC).
- When Terraform asks Do you really want to destroy all resources?, type yes and press Enter.
- Wait for the process to complete. You will see resources being destroyed one by one in the terminal output.
When Terraform finishes, you should see a message ending with Destroy complete! followed by the count of destroyed resources.
Destroy taking longer than 15 minutes?
EKS node group deletion can occasionally stall if pods are stuck terminating. If the destroy hangs beyond 20 minutes, press Ctrl+C to cancel, then run terraform destroy again. Terraform will pick up where it left off.
Help me troubleshoot a stuck terraform destroy for EKS
Verify destruction is complete in the AWS Console
Terminal output alone is not proof. Confirming in the AWS Console gives you certainty that no billable resources remain.
- Open the AWS Console in your browser and make sure you are in the us-east-1 region (shown in the top-right corner).
- Search for EKS in the top search bar and select Elastic Kubernetes Service.
- Confirm the Clusters page shows no cluster named eks-production-dev.
- Search for VPC in the top search bar and select VPC.
- Confirm the Your VPCs page shows no VPC named eks-production-dev-vpc. You should only see the default VPC.
Both pages should be clear of any resources from this project. This confirms all billable infrastructure has been removed.
Optionally remove the state backend
The S3 bucket and DynamoDB table from Step 1 are free-tier eligible. They cost nothing to keep around.
Should I keep or destroy the state backend?
If you plan to attempt the Secret Mission or re-deploy the cluster later, keep the state backend. It stores your Terraform state history and costs nothing under the free tier.
If you are completely done with this project and want a clean slate, destroy it. You will need to remove the prevent_destroy lifecycle rule first, because Terraform refuses to destroy resources protected by that rule.
- If you want to destroy the state backend, open state-backend/main.tf and find this block inside the aws_s3_bucket resource:
lifecycle {
prevent_destroy = true
}
- Delete those three lines (the entire lifecycle block), then save the file.
- Navigate to the state-backend directory and destroy the backend resources by running:
cd ../../state-backend
terraform destroy
- Type yes when prompted to confirm.
After this completes, the S3 bucket and DynamoDB table will be removed from your AWS account.
Seeing a prevent_destroy error?
If Terraform refuses to destroy the S3 bucket, you likely still have the prevent_destroy = true lifecycle rule in state-backend/main.tf. Remove the entire lifecycle block, save the file, and run terraform destroy again.
Help me fix a prevent_destroy error in Terraform
Secret mission
Harden for Production with Multi-AZ, Private Endpoints, and IRSA
The dev environment uses cost-saving shortcuts like a single NAT gateway and a public cluster endpoint. A real production cluster needs high availability, restricted network access, and least-privilege pod identities. Can you build a hardened production environment that passes a terraform plan?
Clean Up Your Resources
Clean Up Your Resources
You already destroyed the EKS cluster and VPC in Step 5. The remaining resources are the Terraform state backend (S3 bucket and DynamoDB table), which are free-tier eligible, and the IAM resources you created for GitHub Actions OIDC. There are no ongoing costs from what remains.
Decide whether to keep your resources for future Terraform projects, or delete them entirely.
Resources you used:
- S3 Bucket (my-eks-terraform-state-<UNIQUE-SUFFIX>) for Terraform state storage.
- DynamoDB Table (terraform-state-locks) for state locking.
- IAM OIDC Identity Provider (token.actions.githubusercontent.com) for GitHub Actions authentication.
- IAM Role referenced by the AWS_ROLE_ARN secret in GitHub.
- GitHub Repository with workflow files and production environment.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you plan to continue experimenting with Terraform and EKS, or want to reuse the state backend for future infrastructure projects.
- The S3 bucket and DynamoDB table remain in your AWS account at no cost (free-tier eligible).
- The IAM OIDC provider and role remain active for future GitHub Actions workflows.
- Your GitHub repository stays available with all workflow configurations intact.
- Local project files stay on your Desktop for reference.
Pause - I'll come back to this later
Since the EKS cluster is already destroyed, there are no active compute resources incurring charges. The remaining S3 and DynamoDB resources are free-tier eligible, so there is nothing to pause.
- Your state backend (S3 bucket and DynamoDB table) costs nothing to keep idle.
- IAM resources (OIDC provider and role) have no per-resource cost.
- Leave everything as-is and return when you are ready to deploy again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up your AWS account, GitHub, and local machine.
- Delete the S3 bucket via the AWS Console. Open the S3 console, select your state bucket, click Empty, type permanently delete to confirm, then click Empty. After emptying completes, select the bucket again, click Delete, enter the bucket name, and click Delete bucket.
- Delete the DynamoDB table by running:
aws dynamodb delete-table --table-name terraform-state-locks
- Delete the IAM OIDC Identity Provider. Open the IAM console, choose Identity providers in the left navigation pane, select the checkbox next to token.actions.githubusercontent.com, type delete in the confirmation field, and click Delete.
- Delete the IAM Role. In the IAM console, choose Roles in the left navigation pane, search for the role you created for GitHub Actions, select it, and click Delete. Confirm the deletion.
- Delete the GitHub repository. Go to your repository on GitHub, click Settings, scroll to the bottom of the General page, click Delete this repository, and confirm by typing the repository name.
- Delete your local project files by running:
cd ~/Desktop
rm -rf eks-production-terraform
Why empty the S3 bucket first?
Your state bucket has versioning enabled. The S3 Console's Empty action removes all object versions and delete markers in one step. You cannot delete a bucket until it is completely empty of all versioned objects.
Nice Work!
Nice Work!
You just built a production-grade Terraform repository that provisions and manages an EKS cluster through automated CI/CD. This is the exact infrastructure pattern real platform engineering teams use to manage Kubernetes at scale.
You learned how to:
- Build a production-grade Terraform repository with modular code separation, environment isolation via tfvars, and remote state management on S3 with DynamoDB locking.
- Deploy a working EKS cluster with managed node groups and verify Kubernetes connectivity via kubectl, seeing real nodes in Ready status.
- Create GitHub Actions CI/CD pipelines with plan-on-PR and apply-on-merge patterns, including OIDC authentication and manual approval gates for production safety.
- Secret Mission: Hardened the infrastructure for production with multi-AZ NAT gateways, private cluster endpoints, IRSA for pod-level IAM, and CloudWatch logging.
Ready to quiz yourself?