Build a Complete AWS Infrastructure with Terraform from Scratch (2026)
Full project walkthrough: provision a production-grade AWS VPC, EKS cluster, RDS, S3, and IAM with Terraform. Real code, real architecture, ready to use.
This is a complete project walkthrough. By the end, you'll have a production-grade AWS infrastructure provisioned entirely with Terraform:
- VPC with public/private subnets across 3 AZs
- EKS cluster with managed node groups
- RDS PostgreSQL in private subnets
- S3 bucket with versioning and encryption
- IAM roles for EKS, RDS access, and CI/CD
- Remote state in S3 + DynamoDB
- GitHub Actions to run Terraform in CI/CD
No clicking around in the AWS console. Everything is code, versioned in Git.
Project Structure
terraform-aws-infra/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── eks/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── rds/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── prod/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── staging/
│ ├── main.tf
│ └── terraform.tfvars
├── backend.tf
└── versions.tf
Step 1: Bootstrap Remote State
Before anything else, create the S3 bucket and DynamoDB table for Terraform state. Do this manually once (because you can't use Terraform to manage Terraform's own state bootstrapping):
# Create state bucket
aws s3api create-bucket \
--bucket my-terraform-state-2026 \
--region us-east-1
# Enable versioning
aws s3api put-bucket-versioning \
--bucket my-terraform-state-2026 \
--versioning-configuration Status=Enabled
# Enable encryption
aws s3api put-bucket-encryption \
--bucket my-terraform-state-2026 \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}'
# Create DynamoDB table for state locking
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1Now configure the backend:
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-2026"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}Step 2: Versions and Providers
# versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.27"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.13"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}Step 3: VPC Module
# modules/vpc/main.tf
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 3)
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
# Public subnets (load balancers, NAT gateways)
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
"kubernetes.io/role/elb" = "1"
}
}
# Private subnets (EKS nodes, RDS)
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = local.azs[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
"kubernetes.io/role/internal-elb" = "1"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# NAT Gateways (one per AZ for HA)
resource "aws_eip" "nat" {
count = 3
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip-${count.index + 1}"
}
}
resource "aws_nat_gateway" "main" {
count = 3
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "${var.project_name}-nat-${count.index + 1}"
}
}
# Route tables
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table" "private" {
count = 3
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
resource "aws_route_table_association" "public" {
count = 3
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 3
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}Step 4: EKS Module
# modules/eks/main.tf
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.kubernetes_version
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = var.private_subnet_ids
endpoint_private_access = true
endpoint_public_access = true
public_access_cidrs = var.allowed_cidr_blocks
}
enabled_cluster_log_types = ["api", "audit", "authenticator"]
depends_on = [
aws_iam_role_policy_attachment.cluster_policy,
]
}
# EKS Managed Node Group
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.cluster_name}-main"
node_role_arn = aws_iam_role.node.arn
subnet_ids = var.private_subnet_ids
ami_type = "AL2_x86_64"
instance_types = var.node_instance_types
capacity_type = "ON_DEMAND"
scaling_config {
desired_size = var.node_desired_size
min_size = var.node_min_size
max_size = var.node_max_size
}
update_config {
max_unavailable = 1
}
labels = {
role = "general"
}
depends_on = [
aws_iam_role_policy_attachment.node_policy,
aws_iam_role_policy_attachment.cni_policy,
aws_iam_role_policy_attachment.ecr_policy,
]
}
# IAM Role for EKS Control Plane
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.cluster.name
}
# IAM Role for EKS Nodes
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
role = aws_iam_role.node.name
}
resource "aws_iam_role_policy_attachment" "cni_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
role = aws_iam_role.node.name
}
resource "aws_iam_role_policy_attachment" "ecr_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
role = aws_iam_role.node.name
}
# EKS Add-ons
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.main.name
addon_name = "coredns"
resolve_conflicts_on_update = "OVERWRITE"
}
resource "aws_eks_addon" "kube_proxy" {
cluster_name = aws_eks_cluster.main.name
addon_name = "kube-proxy"
resolve_conflicts_on_update = "OVERWRITE"
}
resource "aws_eks_addon" "vpc_cni" {
cluster_name = aws_eks_cluster.main.name
addon_name = "vpc-cni"
resolve_conflicts_on_update = "OVERWRITE"
}
resource "aws_eks_addon" "ebs_csi" {
cluster_name = aws_eks_cluster.main.name
addon_name = "aws-ebs-csi-driver"
resolve_conflicts_on_update = "OVERWRITE"
}Step 5: RDS Module
# modules/rds/main.tf
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-rds-subnet-group"
subnet_ids = var.private_subnet_ids
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.eks_node_security_group_id]
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-postgres"
engine = "postgres"
engine_version = "16.2"
instance_class = var.db_instance_class
allocated_storage = 20
max_allocated_storage = 100
storage_type = "gp3"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password # Use aws_secretsmanager_secret in production
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
skip_final_snapshot = false
final_snapshot_identifier = "${var.project_name}-final-snapshot"
deletion_protection = true
performance_insights_enabled = true
}Step 6: Wire It All Together
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
project_name = var.project_name
vpc_cidr = "10.0.0.0/16"
cluster_name = "${var.project_name}-cluster"
}
module "eks" {
source = "../../modules/eks"
cluster_name = "${var.project_name}-cluster"
kubernetes_version = "1.30"
private_subnet_ids = module.vpc.private_subnet_ids
allowed_cidr_blocks = ["0.0.0.0/0"]
node_instance_types = ["t3.medium"]
node_desired_size = 3
node_min_size = 1
node_max_size = 10
}
module "rds" {
source = "../../modules/rds"
project_name = var.project_name
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
eks_node_security_group_id = module.eks.node_security_group_id
db_instance_class = "db.t3.medium"
db_name = "appdb"
db_username = "appuser"
db_password = var.db_password
}Step 7: GitHub Actions CI/CD for Terraform
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
paths: ['environments/prod/**']
pull_request:
branches: [main]
paths: ['environments/prod/**']
permissions:
id-token: write # For OIDC
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: environments/prod
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC — no stored secrets)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -out=tfplan -no-color
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = `#### Terraform Plan 📋
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply tfplanRunning It
cd environments/prod
# Initialize
terraform init
# See what will be created
terraform plan
# Create everything (~15 minutes)
terraform apply
# Get kubeconfig for EKS
aws eks update-kubeconfig \
--name myproject-cluster \
--region us-east-1
# Verify
kubectl get nodesArchitecture Summary
┌─────────────────── AWS Account ───────────────────────┐
│ │
│ VPC (10.0.0.0/16) │
│ ├── Public Subnets (3 AZs) — Load Balancers, NAT GW │
│ └── Private Subnets (3 AZs) │
│ ├── EKS Node Group (t3.medium, 3-10 nodes) │
│ └── RDS PostgreSQL (db.t3.medium, encrypted) │
│ │
│ S3: terraform state + app storage │
│ DynamoDB: state lock table │
│ ECR: container registry │
│ IAM: OIDC + IRSA for keyless auth │
└────────────────────────────────────────────────────────┘
Resources
- Terraform AWS EKS Module — Production-ready EKS module, more features than rolling your own
- Terraform: The Complete Guide (Udemy) — Best course for going from basics to production IaC
- HashiCorp Terraform Associate Certification — Worth getting before senior DevOps interviews
- DigitalOcean $200 free credit — Practice Terraform with DO provider without AWS costs
This is a real starting point for a production AWS infrastructure. From here, add monitoring (Prometheus + Grafana), GitOps (ArgoCD), and your application's Helm charts. Everything is in Git, everything is repeatable, and nothing requires clicking in the AWS console.
Stay ahead of the curve
Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.
Related Articles
Build a Complete CI/CD Pipeline with GitHub Actions + ArgoCD + EKS (2026)
A full project walkthrough — from a simple app to a production-grade GitOps pipeline with automated builds, image scanning, and deployments to AWS EKS using ArgoCD.
AWS DevOps Tools — CodePipeline to EKS Complete Overview
A complete guide to AWS DevOps services — CI/CD pipelines, container orchestration, infrastructure as code, monitoring, and security best practices.
AWS EKS Pods Stuck in Pending State: Causes and Fixes
Pods stuck in Pending on EKS are caused by a handful of known issues — insufficient node capacity, taint mismatches, PVC problems, and more. Here's how to diagnose and fix each one.