Hands-On Labs π οΈ#
Practice makes perfect! These labs take you from basic to advanced Terraform skills.
Lab Progression#
| Lab | Topic | Difficulty | Time |
|---|---|---|---|
| 01 | Deploy an EC2 Instance | β Beginner | 15 min |
| 02 | VPC with Public/Private Subnets | β Beginner | 20 min |
| 03 | Reusable VPC Module | ββ Intermediate | 25 min |
| 04 | Multi-Environment with Workspaces | ββ Intermediate | 30 min |
| 05 | Remote State with S3 + DynamoDB | βββ Advanced | 20 min |
| 06 | Production 3-Tier Web Architecture | βββ Advanced | 45 min |
| 07 | Serverless REST API (Lambda + API Gateway + DynamoDB) | βββ Advanced | 30 min |
| 08 | ECS Fargate Container with Auto Scaling | βββ Advanced | 35 min |
| 09 | EKS Kubernetes Cluster | ββββ Expert | 40 min |
| 10 | S3 + CloudFront Static Website with WAF | ββ Intermediate | 25 min |
Lab 01: Deploy an EC2 Instance#
Prerequisites#
- Terraform installed
- AWS CLI configured
- AWS credentials with EC2 permissions
Objectives#
- Write your first Terraform configuration
- Use data sources to find an AMI
- Create a security group and EC2 instance
- Understand outputs
Steps#
# Step 1: Create main.tf
provider "aws" {
region = "us-east-1"
}
# Step 2: Find the latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Step 3: Create a security group
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP and SSH"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Step 4: Create the EC2 instance
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t2.micro"
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl enable httpd
systemctl start httpd
echo "<h1>Hello from Terraform Lab 01</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "Terraform-Lab-01"
}
}
# Step 5: Create outputs
output "instance_id" {
value = aws_instance.web.id
}
output "public_ip" {
value = aws_instance.web.public_ip
}Commands#
# Initialize
terraform init
# Plan
terraform plan
# Apply (type 'yes' when prompted)
terraform apply
# Test: Open http://<public_ip> in browser
# Destroy when done
terraform destroyValidation#
- Run
terraform state listβ should show resources - Visit
http://<public_ip>β should see “Hello from Terraform Lab 01”
Lab 02: VPC with Public/Private Subnets#
# main.tf
provider "aws" {
region = "us-east-1"
}
data "aws_availability_zones" "available" {}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "Lab-02-VPC" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "Lab-02-IGW" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "Lab-02-Public-${count.index + 1}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "Lab-02-Private-${count.index + 1}" }
}
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
}
tags = { Name = "Lab-02-Public-RT" }
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}Challenges#
- Add a NAT Gateway for private subnets
- Add a private route table with NAT Gateway
- Deploy an EC2 instance in the private subnet
Lab 03: Reusable VPC Module#
Module Structure#
modules/
βββ vpc/
βββ main.tf
βββ variables.tf
βββ outputs.tf
βββ README.md# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for resources"
type = string
}
variable "cidr" {
description = "VPC CIDR block"
type = string
}
variable "azs" {
description = "Availability Zones"
type = list(string)
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}# modules/vpc/main.tf
locals {
az_count = length(var.azs)
}
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
tags = merge(var.tags, { Name = "${var.name}-vpc" })
}
resource "aws_subnet" "public" {
count = local.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr, 8, count.index)
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, { Name = "${var.name}-public-${count.index + 1}" })
}
resource "aws_subnet" "private" {
count = local.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr, 8, count.index + local.az_count)
availability_zone = var.azs[count.index]
tags = merge(var.tags, { Name = "${var.name}-private-${count.index + 1}" })
}# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}Lab 04: Multi-Environment with Workspaces#
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-123456789012"
key = "workspace-lab/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# main.tf
locals {
config = {
default = { instance_type = "t2.micro", count = 1 }
dev = { instance_type = "t2.nano", count = 1 }
staging = { instance_type = "t2.small", count = 2 }
prod = { instance_type = "t3.medium", count = 3 }
}
env_config = lookup(local.config, terraform.workspace, local.config.default)
}
resource "aws_instance" "web" {
count = local.env_config.count
ami = data.aws_ami.amazon_linux_2.id
instance_type = local.env_config.instance_type
tags = {
Name = "webserver-${terraform.workspace}-${count.index + 1}"
Environment = terraform.workspace
}
}Commands#
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev
terraform plan
terraform applyLab 05: Remote State with S3 + DynamoDB#
# backend-infra/main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration { status = "Enabled" }
}
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 = "AES256"
}
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
data "aws_caller_identity" "current" {}Lab 06: Production 3-Tier Web Architecture#
Build a production-ready architecture:
βββββββββββββββ
β Route 53 β
ββββββββ¬βββββββ
ββββββββΌβββββββ
β CloudFront β
ββββββββ¬βββββββ
ββββββββΌβββββββ
β ALB β
ββββββββ¬βββββββ
ββββββββββββββΌβββββββββββββ
βββββββΌββββββ βββββΌβββββ ββββββΌββββββ
β ASG Web β β ASG Appβ β ASG App β
βββββββββββββ ββββββββββ ββββββββββββ
β
βββββββββΌββββββββ
β RDS β
βββββββββββββββββKey Components#
- Networking: VPC with public/private subnets across 3 AZs
- Web Tier: Auto-scaling group with ALB in public subnets
- App Tier: Auto-scaling group in private subnets
- Database: RDS PostgreSQL in private subnets with Multi-AZ
- Security: Security groups with least privilege
- Monitoring: CloudWatch alarms and dashboards
See examples directory for the full implementation.
Lab 07: Serverless REST API (Lambda + API Gateway + DynamoDB)#
Prerequisites#
- Terraform installed
- AWS CLI configured
- AWS credentials with Lambda, API Gateway, DynamoDB, IAM, and CloudWatch permissions
Objectives#
- Deploy a DynamoDB table with Global Secondary Index
- Create an IAM role and Lambda function (Node.js 20)
- Set up API Gateway REST API with CRUD endpoints
- Configure CloudWatch monitoring dashboard
- Test the full serverless stack
Architecture#
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β API Gateway ββββββΆβ Lambda ββββββΆβ DynamoDB β
β (REST API) β β (Node.js) β β (Items) β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββSteps#
Step 1: Create the main configuration
Create main.tf with the serverless infrastructure. See the full example at examples/serverless-api, or build it step by step:
# providers.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Random suffix for unique names
resource "random_id" "suffix" {
byte_length = 4
}Step 2: Create DynamoDB table
# dynamodb.tf
resource "aws_dynamodb_table" "items" {
name = "serverless-lab-items"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
server_side_encryption {
enabled = true
}
tags = { Name = "serverless-lab-items" }
}Step 3: Create Lambda function
Create the Lambda IAM role, zip up the inline Node.js code, and deploy the function:
# lambda.tf
resource "aws_iam_role" "lambda" {
name = "serverless-lab-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "lambda_dynamodb" {
name = "serverless-lab-lambda-dynamodb"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["dynamodb:GetItem","dynamodb:PutItem","dynamodb:DeleteItem","dynamodb:Scan"]
Resource = aws_dynamodb_table.items.arn
},
{
Effect = "Allow"
Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"]
Resource = "*"
}
]
})
}
# Inline Lambda code (AWS SDK v2 is pre-installed in Lambda runtime)
data "archive_file" "lambda" {
type = "zip"
output_path = "${path.module}/lambda.zip"
source {
content = <<-EOF
const AWS = require('aws-sdk');
const crypto = require('crypto');
const docClient = new AWS.DynamoDB.DocumentClient({});
const TABLE_NAME = process.env.TABLE_NAME;
exports.handler = async (event) => {
try {
const method = event.httpMethod;
const path = event.path;
if (method === 'GET' && path === '/items') {
const result = await docClient.scan({ TableName: TABLE_NAME }).promise();
return respond(200, result.Items);
}
if (method === 'GET' && path.startsWith('/items/')) {
const id = path.split('/')[2];
const result = await docClient.get({ TableName: TABLE_NAME, Key: { id } }).promise();
if (!result.Item) return respond(404, { error: 'Not found' });
return respond(200, result.Item);
}
if (method === 'POST' && path === '/items') {
const body = JSON.parse(event.body);
const item = { id: crypto.randomUUID(), ...body, created_at: new Date().toISOString() };
await docClient.put({ TableName: TABLE_NAME, Item: item }).promise();
return respond(201, item);
}
if (method === 'DELETE' && path.startsWith('/items/')) {
const id = path.split('/')[2];
await docClient.delete({ TableName: TABLE_NAME, Key: { id } }).promise();
return respond(200, { message: 'Deleted' });
}
return respond(400, { error: 'Unsupported route' });
} catch (err) {
console.error(err);
return respond(500, { error: 'Internal error' });
}
};
function respond(statusCode, body) {
return { statusCode, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify(body) };
}
EOF
filename = "index.js"
}
}
resource "aws_lambda_function" "api" {
filename = data.archive_file.lambda.output_path
function_name = "serverless-lab-api"
role = aws_iam_role.lambda.arn
handler = "index.handler"
runtime = "nodejs20.x"
timeout = 10
memory_size = 256
source_code_hash = data.archive_file.lambda.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
ENVIRONMENT = "lab"
}
}
tags = { Name = "serverless-lab-api" }
}Step 4: Add API Gateway
# api-gateway.tf
resource "aws_api_gateway_rest_api" "api" {
name = "serverless-lab-api"
endpoint_configuration { types = ["REGIONAL"] }
}
resource "aws_api_gateway_resource" "items" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_rest_api.api.root_resource_id
path_part = "items"
}
resource "aws_api_gateway_resource" "item" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_resource.items.id
path_part = "{id}"
}
# Methods and integrations
locals {
methods = {
list_items = { resource_id = aws_api_gateway_resource.items.id, http_method = "GET" }
create_item = { resource_id = aws_api_gateway_resource.items.id, http_method = "POST" }
get_item = { resource_id = aws_api_gateway_resource.item.id, http_method = "GET" }
delete_item = { resource_id = aws_api_gateway_resource.item.id, http_method = "DELETE" }
}
}
resource "aws_api_gateway_method" "this" {
for_each = local.methods
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = each.value.resource_id
http_method = each.value.http_method
authorization = "NONE"
}
resource "aws_api_gateway_integration" "this" {
for_each = local.methods
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = each.value.resource_id
http_method = each.value.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.api.invoke_arn
}
# Lambda permission for API Gateway
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}
# Deploy the API
resource "aws_api_gateway_deployment" "api" {
depends_on = [aws_api_gateway_integration.this]
rest_api_id = aws_api_gateway_rest_api.api.id
triggers = {
redeployment = sha1(jsonencode([aws_api_gateway_resource.items.id, aws_api_gateway_resource.item.id]))
}
lifecycle { create_before_destroy = true }
}
resource "aws_api_gateway_stage" "api" {
deployment_id = aws_api_gateway_deployment.api.id
rest_api_id = aws_api_gateway_rest_api.api.id
stage_name = "dev"
}Commands#
# Initialize
terraform init
# Plan
terraform plan
# Deploy everything
terraform apply
# Test the API (copy endpoint from output)
curl -X POST <api_endpoint> \
-H "Content-Type: application/json" \
-d '{"name":"lab-test","description":"Hello from Lab 07!"}'
# List items
curl <api_endpoint>
# Check CloudWatch Dashboard (from output)
# Destroy when done
terraform destroyValidation#
terraform state listshows all created resourcesPOST /itemsreturns a 201 with the created itemGET /itemsreturns the list of items- CloudWatch dashboard is accessible via the output URL
Challenges#
- Add a
PUT /items/{id}method for updating items - Add a Global Secondary Index on
created_atfor sorting - Add API Gateway usage plan and API key for authentication
- Add Lambda environment variables for stage-aware configuration
Lab 08: ECS Fargate Container with Auto Scaling#
Prerequisites#
- Terraform installed
- AWS CLI configured
- AWS credentials with ECS, EC2, VPC, ELB, and IAM permissions
Objectives#
- Deploy a VPC with public/private subnets and NAT Gateway
- Create an ECS Fargate cluster with Container Insights
- Deploy an NGINX container behind an Application Load Balancer
- Configure CPU-based auto scaling
- View centralized CloudWatch logs
Architecture#
ββββββββββββ ββββββββββββ βββββββββββββββββββ
β ALB ββββββΆβ ECS ββββββΆβ Fargate Tasks β
β (Public) β β Service β β (Private Subnet)β
ββββββββββββ ββββββββββββ βββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββ βββββββββββββββββββ
β Auto Scaling β β CloudWatch Logs β
β (CPU-based) β β (/ecs/fargate) β
ββββββββββββββββ βββββββββββββββββββSteps#
Step 1: Create VPC networking
# networking.tf
data "aws_availability_zones" "available" {}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "fargate-lab-vpc" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "fargate-lab-public-${count.index + 1}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 2)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "fargate-lab-private-${count.index + 1}" }
}Add an Internet Gateway, NAT Gateway, Elastic IP, and route tables for both public and private subnets.
Step 2: Create the ECS cluster
# ecs.tf
resource "aws_ecs_cluster" "main" {
name = "fargate-lab-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/fargate-lab"
retention_in_days = 7
}Step 3: Create ALB and ECS service
Create the Application Load Balancer, target group, listener, security groups, task definition, and ECS service. See examples/ecs-fargate for the complete configuration.
Step 4: Add auto scaling
# autoscaling.tf
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 4
min_capacity = 1
resource_id = "service/fargate-lab-cluster/fargate-lab-service"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_cloudwatch_metric_alarm" "cpu_high" {
alarm_name = "fargate-lab-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 60
statistic = "Average"
threshold = 75
dimensions = {
ClusterName = aws_ecs_cluster.main.name
ServiceName = "fargate-lab-service"
}
alarm_actions = [aws_appautoscaling_policy.cpu_up.arn]
}Commands#
# Initialize and deploy
terraform init
terraform apply
# Get the ALB URL
open http://$(terraform output -raw alb_dns_name)
# View container logs
aws logs tail /ecs/fargate-lab --follow
# Force a new deployment
aws ecs update-service --cluster fargate-lab-cluster \
--service fargate-lab-service --force-new-deployment
# Simulate CPU load to trigger scaling
# (exec into a container and run: stress --cpu 1 --timeout 120)
# Destroy
terraform destroyValidation#
- Visit the ALB DNS name in browser β shows NGINX welcome page
terraform state listshows VPC, subnets, ALB, ECS cluster, service, task definition- CloudWatch log group has streaming logs
- Auto scaling triggers when CPU exceeds 75%
Challenges#
- Change the container image to your own custom Docker image
- Add a second container to the task definition (sidecar pattern)
- Implement blue-green deployment with separate target groups
- Add an Application Auto Scaling scheduled action to scale down at night
Lab 09: EKS Kubernetes Cluster#
Prerequisites#
- Terraform installed
- AWS CLI configured (with EKS, EC2, IAM, KMS permissions)
- kubectl installed
- aws-iam-authenticator or use
aws eks get-token(built into AWS CLI v2)
Objectives#
- Deploy a VPC with EKS-required tagging
- Create an EKS cluster with KMS secrets encryption
- Deploy a managed node group (Spot instances for cost savings)
- Install EKS add-ons (VPC CNI, CoreDNS, kube-proxy, EBS CSI)
- Deploy a sample NGINX application via Kubernetes provider
Architecture#
βββββββββββββββββββββββββββββββ
β EKS Control Plane β
β (Multi-AZ, AWS-managed) β
βββββββββββββββββββββββββββββββ€
β Managed Node Group (Spot) ββββΆ t3.medium Γ 2
β private subnets ββββΆ t3.medium Γ 2
βββββββββββββββββββββββββββββββ€
β Add-ons: β
β β’ VPC CNI (networking) β
β β’ CoreDNS (DNS) β
β β’ kube-proxy (services) β
β β’ EBS CSI (volumes) β
βββββββββββββββββββββββββββββββ€
β Sample: nginx deployment β
β + ClusterIP service β
βββββββββββββββββββββββββββββββSteps#
Step 1: Create VPC with EKS tagging
# vpc.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "eks-lab-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
enable_dns_hostnames = true
# Required EKS tags for subnet discovery
private_subnet_tags = {
"kubernetes.io/cluster/eks-lab" = "shared"
"kubernetes.io/role/internal-elb" = "1"
}
public_subnet_tags = {
"kubernetes.io/cluster/eks-lab" = "shared"
"kubernetes.io/role/elb" = "1"
}
}Step 2: Create EKS cluster IAM role and KMS key
# iam.tf
resource "aws_iam_role" "eks_cluster" {
name = "eks-lab-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" "eks_cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.eks_cluster.name
}
# kms.tf β Encryption key for Kubernetes secrets
resource "aws_kms_key" "eks" {
description = "EKS Secrets Encryption Key"
deletion_window_in_days = 7
enable_key_rotation = true
}Step 3: Create the EKS cluster
# eks.tf
resource "aws_eks_cluster" "main" {
name = "eks-lab"
role_arn = aws_iam_role.eks_cluster.arn
version = "1.28"
vpc_config {
subnet_ids = module.vpc.private_subnets
endpoint_public_access = true
}
encryption_config {
provider { key_arn = aws_kms_key.eks.arn }
resources = ["secrets"]
}
enabled_cluster_log_types = ["api", "audit"]
}Step 4: Add node group and add-ons
Create a node IAM role, managed node group (with Spot instances), and install EKS add-ons. See examples/eks-cluster for the complete configuration.
Commands#
# Deploy the cluster (takes 10-15 minutes)
terraform init
terraform apply
# Configure kubectl
aws eks update-kubeconfig --name eks-lab --region us-east-1
# Verify cluster access
kubectl get nodes
kubectl cluster-info
# Deploy a test app
kubectl create deployment nginx --image=nginx --replicas=2
kubectl expose deployment nginx --port=80 --type=LoadBalancer
kubectl get svc -w
# Check logs
kubectl logs -l app=nginx
# Destroy (this will take a while)
terraform destroyValidation#
kubectl get nodesshows 2+ nodes in Ready statekubectl get pods -Ashows system pods (CoreDNS, kube-proxy, VPC CNI)- A sample nginx deployment runs successfully
terraform state listshows all EKS resources- EBS CSI driver is functional (create a PVC to test)
Challenges#
- Create a Kubernetes Namespace, Deployment, and Service using
kubernetes_provider resources - Install an Ingress Controller (NGINX or AWS LB Controller) via Helm
- Add Cluster Autoscaler to automatically add/remove nodes
- Set up Horizontal Pod Autoscaler based on CPU/memory
Lab 10: S3 + CloudFront Static Website with WAF#
Prerequisites#
- Terraform installed
- AWS CLI configured
- AWS credentials with S3, CloudFront, WAF, and IAM permissions
Objectives#
- Create a private S3 bucket with server-side encryption
- Configure CloudFront with Origin Access Control (OAC)
- Set up WAF with rate limiting and managed security rules
- Upload sample website content
- Test global content delivery via CloudFront
Architecture#
ββββββββββββ ββββββββββββββββ ββββββββββββ
β S3 Bucket βββββββ CloudFront βββββββ Browser β
β (Origin) β β (CDN + WAF) β β (Global) β
ββββββββββββ ββββββββ¬ββββββββ ββββββββββββ
β
βΌ
ββββββββββββββββ
β WAF Web ACL β
β (Rate Limit β
β + Security) β
ββββββββββββββββSteps#
Step 1: Create the S3 bucket with security controls
# s3.tf
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "site" {
bucket = "static-site-lab-${random_id.suffix.hex}"
tags = { Name = "static-site-lab" }
}
# Block all public access
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Enable encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Enable versioning
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration { status = "Enabled" }
}Step 2: Upload sample website content
# content.tf
resource "aws_s3_object" "index_html" {
bucket = aws_s3_bucket.site.bucket
key = "index.html"
content_type = "text/html"
content = <<-HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Lab 10 - Static Site</title>
<style>
body { font-family: sans-serif; display: flex;
justify-content: center; align-items: center;
min-height: 100vh; margin: 0;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; text-align: center; }
h1 { font-size: 3rem; }
.badge { background: rgba(255,255,255,0.2); padding: 0.5rem 1.5rem;
border-radius: 50px; display: inline-block; margin-top: 1rem; }
</style>
</head>
<body>
<div>
<h1>π Lab 10 Complete!</h1>
<p>Static site deployed with S3 + CloudFront</p>
<div class="badge">β
HTTPS β’ WAF Protected β’ Edge Cached</div>
</div>
</body>
</html>
HTML
}
resource "aws_s3_object" "error_html" {
bucket = aws_s3_bucket.site.bucket
key = "error.html"
content_type = "text/html"
content = <<-HTML
<!DOCTYPE html>
<html><head><title>404</title>
<style>body{font-family:sans-serif;text-align:center;padding:50px}
h1{font-size:5rem;color:#e94560}</style></head>
<body><h1>404</h1><p>Page not found</p><a href="/">Go Home</a></body></html>
HTML
}Step 3: Create CloudFront OAC and distribution
# cloudfront.tf
resource "aws_cloudfront_origin_access_control" "site" {
name = "static-site-lab-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.site.id}"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.site.id}"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
}
# S3 bucket policy (allows only CloudFront)
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
custom_error_response {
error_code = 403
response_code = 404
response_page_path = "/error.html"
}
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/error.html"
}
}Step 4: Add S3 bucket policy for CloudFront access
Add a bucket policy that only allows CloudFront to read objects. See examples/s3-cloudfront-website for the complete WAF configuration.
Commands#
# Initialize and deploy
terraform init
terraform apply
# Get the CloudFront URL
open https://$(terraform output -raw cloudfront_domain)
# Check HTTP headers (verify caching)
curl -I https://$(terraform output -raw cloudfront_domain)
# Upload updated content
aws s3 cp my-updated-index.html s3://$(terraform output -raw s3_bucket_name)/index.html
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id $(terraform output -raw cloudfront_distribution_id) \
--paths "/*"
# Destroy
terraform destroyValidation#
- Visit the CloudFront URL β shows the sample website with styling
curl -Ishowsx-cache: Hit from cloudfrontafter first request- S3 bucket returns
403 Forbiddenwhen accessed directly (no public access) - WAF is active (trigger by sending many rapid requests)
terraform state listshows S3 bucket, CloudFront distribution, WAF ACL
Challenges#
- Add a custom domain with Route53 and ACM SSL certificate
- Configure S3 lifecycle rules to expire old object versions
- Add WAF rate limiting and IP allow/block lists
- Set up a CI/CD pipeline with S3 sync and CloudFront invalidation
π Congratulations! You’ve completed all 10 labs. You’re now ready to tackle real-world Terraform scenarios. Check the Examples section for more advanced patterns and the Interview Questions chapter for certification prep.