All Articles

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.

DevOpsBoysApr 1, 20268 min read
Share:Tweet

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):

bash
# 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-1

Now configure the backend:

hcl
# 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

hcl
# 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

hcl
# 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

hcl
# 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

hcl
# 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

hcl
# 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

yaml
# .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 tfplan

Running It

bash
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 nodes

Architecture 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


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.

Newsletter

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

Comments