Day 4: Infrastructure as Code with Terraform
From Manual to Automated Deployment
Welcome to Day 4! Today marks a significant shift in how we deploy our Digital Twin. We're moving from manual AWS Console operations to Infrastructure as Code (IaC) using Terraform. This transformation brings version control, repeatability, and the ability to deploy multiple environments with a single command. By the end of today, you'll be managing dev, test, and production environments like a professional DevOps engineer!
What You'll Learn Today
- Terraform fundamentals - Infrastructure as Code concepts
- State management - How Terraform tracks your resources
- Workspaces - Managing multiple environments
- Automated deployment - One-command infrastructure provisioning
- Environment isolation - Separate dev, test, and production
- Optional: Custom domains - Professional DNS configuration
Part 1: Clean Slate - Remove Manual Resources
Before we embrace automation, let's clean up all the resources we created manually in Days 2 and 3. This final console tour will help reinforce what Terraform will manage for us.
Step 1: Delete Lambda Function
- Sign in to AWS Console as
aiengineer - Navigate to Lambda
- Select
twin-apifunction - Click Actions → Delete
- Type "delete" to confirm
- Click Delete
Step 2: Delete API Gateway
- Navigate to API Gateway
- Click on
twin-api-gateway - Click Actions → Delete
- Type the API name to confirm
- Click Delete
Step 3: Empty and Delete S3 Buckets
Memory Bucket:
- Navigate to S3
- Click on your memory bucket (e.g.,
twin-memory-xyz) - Click Empty
- Type "permanently delete" to confirm
- Click Empty
- After emptying, click Delete
- Type the bucket name to confirm
- Click Delete bucket
Frontend Bucket:
- Click on your frontend bucket (e.g.,
twin-frontend-xyz) - Repeat the empty and delete process
Step 4: Delete CloudFront Distribution
- Navigate to CloudFront
- Select your distribution
- Click Disable (if it's enabled)
- Wait for status to change to "Deployed" (5-10 minutes)
- Once disabled, click Delete
- Click Delete to confirm
Step 5: Verify Clean State
- Check each service to ensure no twin-related resources remain:
- Lambda: No
twin-apifunctions - API Gateway: No
twin-api-gatewayAPIs - S3: No
twin-prefixed buckets - CloudFront: No distributions for your twin
- Lambda: No
✅ Checkpoint: You now have a clean AWS account, ready for Terraform to manage everything!
Part 2: Understanding Terraform
What is Infrastructure as Code?
Infrastructure as Code (IaC) treats your infrastructure configuration as source code. Instead of clicking through console interfaces, you define your infrastructure in text files that can be:
- Version controlled - Track changes over time
- Reviewed - Use pull requests for infrastructure changes
- Automated - Deploy with CI/CD pipelines
- Repeatable - Create identical environments
Key Terraform Concepts
1. Resources: The building blocks - each AWS service you want to create
resource "aws_s3_bucket" "example" {
bucket = "my-bucket-name"
}2. State: Terraform's record of what it has created
- Stored in
terraform.tfstatefile - Maps your configuration to real resources
- Critical for updates and deletions
3. Providers: Plugins that interact with cloud providers
provider "aws" {
region = "us-east-1"
}4. Variables: Parameterize your configuration
variable "environment" {
description = "Environment name"
type = string
}5. Workspaces: Separate state for different environments
- Each workspace has its own state file
- Perfect for dev/test/prod separation
Step 1: Install Terraform
As of August 2025, Terraform installation has changed due to licensing updates. We'll use the official distribution.
Mac (using Homebrew):
brew tap hashicorp/tap
brew install hashicorp/tap/terraformMac/Linux (manual):
- Visit: https://developer.hashicorp.com/terraform/install
- Download the appropriate package for your system
- Extract and move to PATH:
# Example for Mac (adjust URL for your system)
curl -O https://releases.hashicorp.com/terraform/1.10.0/terraform_1.10.0_darwin_amd64.zip
unzip terraform_1.10.0_darwin_amd64.zip
sudo mv terraform /usr/local/bin/Windows:
- Visit: https://developer.hashicorp.com/terraform/install
- Download the Windows package
- Extract the .exe file
- Add to your PATH:
- Right-click "This PC" → Properties
- Advanced system settings → Environment Variables
- Edit PATH and add the Terraform directory
Verify Installation:
terraform --versionYou should see something like: Terraform v1.10.0 (version may vary)
Step 2: Update .gitignore
Add Terraform-specific entries to your .gitignore:
# Terraform
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
terraform.tfstate.d/
*.tfvars
!terraform.tfvars
!prod.tfvars
# Lambda packages
lambda-deployment.zip
lambda-package/
# Environment files
.env
.env.*
# Node
node_modules/
out/
.next/
# Python
__pycache__/
*.pyc
.venv/
uv.lock
# IDE
.vscode/
.idea/
*.swp
.DS_StorePart 3: Create Terraform Configuration
Step 1: Create Terraform Directory Structure
In Cursor's file explorer (the left sidebar):
- Right-click in the file explorer in the blank space below all the files
- Select New Folder
- Name it
terraform
Your project structure should now have:
twin/
├── backend/
├── frontend/
├── memory/
└── terraform/ (new)Step 2: Create Provider Configuration
Create terraform/versions.tf:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
# Uses AWS CLI configuration (aws configure)
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}Step 3: Define Variables
Create terraform/variables.tf:
variable "project_name" {
description = "Name prefix for all resources"
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.project_name))
error_message = "Project name must contain only lowercase letters, numbers, and hyphens."
}
}
variable "environment" {
description = "Environment name (dev, test, prod)"
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "Environment must be one of: dev, test, prod."
}
}
variable "bedrock_model_id" {
description = "Bedrock model ID"
type = string
default = "amazon.nova-micro-v1:0"
}
variable "lambda_timeout" {
description = "Lambda function timeout in seconds"
type = number
default = 60
}
variable "api_throttle_burst_limit" {
description = "API Gateway throttle burst limit"
type = number
default = 10
}
variable "api_throttle_rate_limit" {
description = "API Gateway throttle rate limit"
type = number
default = 5
}
variable "use_custom_domain" {
description = "Attach a custom domain to CloudFront"
type = bool
default = false
}
variable "root_domain" {
description = "Apex domain name, e.g. mydomain.com"
type = string
default = ""
}Step 4: Create Main Infrastructure
Create terraform/main.tf:
# Data source to get current AWS account ID
data "aws_caller_identity" "current" {}
locals {
aliases = var.use_custom_domain && var.root_domain != "" ? [
var.root_domain,
"www.${var.root_domain}"
] : []
name_prefix = "${var.project_name}-${var.environment}"
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
# S3 bucket for conversation memory
resource "aws_s3_bucket" "memory" {
bucket = "${local.name_prefix}-memory-${data.aws_caller_identity.current.account_id}"
tags = local.common_tags
}
resource "aws_s3_bucket_public_access_block" "memory" {
bucket = aws_s3_bucket.memory.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "memory" {
bucket = aws_s3_bucket.memory.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
# S3 bucket for frontend static website
resource "aws_s3_bucket" "frontend" {
bucket = "${local.name_prefix}-frontend-${data.aws_caller_identity.current.account_id}"
tags = local.common_tags
}
resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_website_configuration" "frontend" {
bucket = aws_s3_bucket.frontend.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.frontend.arn}/*"
},
]
})
depends_on = [aws_s3_bucket_public_access_block.frontend]
}
# IAM role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "${local.name_prefix}-lambda-role"
tags = local.common_tags
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
},
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
role = aws_iam_role.lambda_role.name
}
resource "aws_iam_role_policy_attachment" "lambda_bedrock" {
policy_arn = "arn:aws:iam::aws:policy/AmazonBedrockFullAccess"
role = aws_iam_role.lambda_role.name
}
resource "aws_iam_role_policy_attachment" "lambda_s3" {
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
role = aws_iam_role.lambda_role.name
}
# Lambda function
resource "aws_lambda_function" "api" {
filename = "${path.module}/../backend/lambda-deployment.zip"
function_name = "${local.name_prefix}-api"
role = aws_iam_role.lambda_role.arn
handler = "lambda_handler.handler"
source_code_hash = filebase64sha256("${path.module}/../backend/lambda-deployment.zip")
runtime = "python3.12"
architectures = ["x86_64"]
timeout = var.lambda_timeout
tags = local.common_tags
environment {
variables = {
CORS_ORIGINS = var.use_custom_domain ? "https://${var.root_domain},https://www.${var.root_domain}" : "https://${aws_cloudfront_distribution.main.domain_name}"
S3_BUCKET = aws_s3_bucket.memory.id
USE_S3 = "true"
BEDROCK_MODEL_ID = var.bedrock_model_id
}
}
# Ensure Lambda waits for the distribution to exist
depends_on = [aws_cloudfront_distribution.main]
}
# API Gateway HTTP API
resource "aws_apigatewayv2_api" "main" {
name = "${local.name_prefix}-api-gateway"
protocol_type = "HTTP"
tags = local.common_tags
cors_configuration {
allow_credentials = false
allow_headers = ["*"]
allow_methods = ["GET", "POST", "OPTIONS"]
allow_origins = ["*"]
max_age = 300
}
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.main.id
name = "$default"
auto_deploy = true
tags = local.common_tags
default_route_settings {
throttling_burst_limit = var.api_throttle_burst_limit
throttling_rate_limit = var.api_throttle_rate_limit
}
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.api.invoke_arn
}
# API Gateway Routes
resource "aws_apigatewayv2_route" "get_root" {
api_id = aws_apigatewayv2_api.main.id
route_key = "GET /"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_route" "post_chat" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /chat"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_route" "get_health" {
api_id = aws_apigatewayv2_api.main.id
route_key = "GET /health"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
# Lambda permission for API Gateway
resource "aws_lambda_permission" "api_gw" {
statement_id = "AllowExecutionFromAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
# CloudFront distribution
resource "aws_cloudfront_distribution" "main" {
aliases = local.aliases
viewer_certificate {
acm_certificate_arn = var.use_custom_domain ? aws_acm_certificate.site[0].arn : null
cloudfront_default_certificate = var.use_custom_domain ? false : true
ssl_support_method = var.use_custom_domain ? "sni-only" : null
minimum_protocol_version = "TLSv1.2_2021"
}
origin {
domain_name = aws_s3_bucket_website_configuration.frontend.website_endpoint
origin_id = "S3-${aws_s3_bucket.frontend.id}"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
tags = local.common_tags
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
}
# Optional: Custom domain configuration (only created when use_custom_domain = true)
data "aws_route53_zone" "root" {
count = var.use_custom_domain ? 1 : 0
name = var.root_domain
private_zone = false
}
resource "aws_acm_certificate" "site" {
count = var.use_custom_domain ? 1 : 0
provider = aws.us_east_1
domain_name = var.root_domain
subject_alternative_names = ["www.${var.root_domain}"]
validation_method = "DNS"
lifecycle { create_before_destroy = true }
tags = local.common_tags
}
resource "aws_route53_record" "site_validation" {
for_each = var.use_custom_domain ? {
for dvo in aws_acm_certificate.site[0].domain_validation_options :
dvo.domain_name => dvo
} : {}
zone_id = data.aws_route53_zone.root[0].zone_id
name = each.value.resource_record_name
type = each.value.resource_record_type
ttl = 300
records = [each.value.resource_record_value]
}
resource "aws_acm_certificate_validation" "site" {
count = var.use_custom_domain ? 1 : 0
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site[0].arn
validation_record_fqdns = [
for r in aws_route53_record.site_validation : r.fqdn
]
}
resource "aws_route53_record" "alias_root" {
count = var.use_custom_domain ? 1 : 0
zone_id = data.aws_route53_zone.root[0].zone_id
name = var.root_domain
type = "A"
alias {
name = aws_cloudfront_distribution.main.domain_name
zone_id = aws_cloudfront_distribution.main.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "alias_root_ipv6" {
count = var.use_custom_domain ? 1 : 0
zone_id = data.aws_route53_zone.root[0].zone_id
name = var.root_domain
type = "AAAA"
alias {
name = aws_cloudfront_distribution.main.domain_name
zone_id = aws_cloudfront_distribution.main.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "alias_www" {
count = var.use_custom_domain ? 1 : 0
zone_id = data.aws_route53_zone.root[0].zone_id
name = "www.${var.root_domain}"
type = "A"
alias {
name = aws_cloudfront_distribution.main.domain_name
zone_id = aws_cloudfront_distribution.main.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "alias_www_ipv6" {
count = var.use_custom_domain ? 1 : 0
zone_id = data.aws_route53_zone.root[0].zone_id
name = "www.${var.root_domain}"
type = "AAAA"
alias {
name = aws_cloudfront_distribution.main.domain_name
zone_id = aws_cloudfront_distribution.main.hosted_zone_id
evaluate_target_health = false
}
}Step 5: Define Outputs
Create terraform/outputs.tf:
output "api_gateway_url" {
description = "URL of the API Gateway"
value = aws_apigatewayv2_api.main.api_endpoint
}
output "cloudfront_url" {
description = "URL of the CloudFront distribution"
value = "https://${aws_cloudfront_distribution.main.domain_name}"
}
output "s3_frontend_bucket" {
description = "Name of the S3 bucket for frontend"
value = aws_s3_bucket.frontend.id
}
output "s3_memory_bucket" {
description = "Name of the S3 bucket for memory storage"
value = aws_s3_bucket.memory.id
}
output "lambda_function_name" {
description = "Name of the Lambda function"
value = aws_lambda_function.api.function_name
}
output "custom_domain_url" {
description = "Root URL of the production site"
value = var.use_custom_domain ? "https://${var.root_domain}" : ""
}Step 6: Create Default Variable Values
Create terraform/terraform.tfvars:
project_name = "twin"
environment = "dev"
bedrock_model_id = "amazon.nova-micro-v1:0"
lambda_timeout = 60
api_throttle_burst_limit = 10
api_throttle_rate_limit = 5
use_custom_domain = false
root_domain = ""Step 7: Update Frontend to Use Environment Variables
Before we create our deployment scripts, we need to update the frontend to use environment variables for the API URL instead of hardcoding it.
Update frontend/components/twin.tsx - find the fetch call (around line 43) and replace:
// Find this line:
const response = await fetch('http://localhost:8000/chat', {
// Replace with:
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/chat`, {This change allows the frontend to:
- Use
http://localhost:8000during local development - Use the production API URL (set via environment variable) when deployed
Note: Next.js requires environment variables accessible in the browser to be prefixed with NEXT_PUBLIC_.
Part 4: Create Deployment Scripts
Step 1: Create Scripts Directory
In Cursor's file explorer (the left sidebar):
- Right-click in the File Explorer in the blank space under the files
- Select New Folder
- Name it
scripts
Step 2: Create Shell Script for Mac/Linux
Important: All students (including Windows users) need to create this file, as it will be used by GitHub Actions on Day 5.
Create scripts/deploy.sh:
#!/bin/bash
set -e
ENVIRONMENT=${1:-dev} # dev | test | prod
PROJECT_NAME=${2:-twin}
echo "🚀 Deploying ${PROJECT_NAME} to ${ENVIRONMENT}..."
# 1. Build Lambda package
cd "$(dirname "$0")/.." # project root
echo "📦 Building Lambda package..."
(cd backend && uv run deploy.py)
# 2. Terraform workspace & apply
cd terraform
terraform init -input=false
if ! terraform workspace list | grep -q "$ENVIRONMENT"; then
terraform workspace new "$ENVIRONMENT"
else
terraform workspace select "$ENVIRONMENT"
fi
# Use prod.tfvars for production environment
if [ "$ENVIRONMENT" = "prod" ]; then
TF_APPLY_CMD=(terraform apply -var-file=prod.tfvars -var="project_name=$PROJECT_NAME" -var="environment=$ENVIRONMENT" -auto-approve)
else
TF_APPLY_CMD=(terraform apply -var="project_name=$PROJECT_NAME" -var="environment=$ENVIRONMENT" -auto-approve)
fi
echo "🎯 Applying Terraform..."
"${TF_APPLY_CMD[@]}"
API_URL=$(terraform output -raw api_gateway_url)
FRONTEND_BUCKET=$(terraform output -raw s3_frontend_bucket)
CUSTOM_URL=$(terraform output -raw custom_domain_url 2>/dev/null || true)
# 3. Build + deploy frontend
cd ../frontend
# Create production environment file with API URL
echo "📝 Setting API URL for production..."
echo "NEXT_PUBLIC_API_URL=$API_URL" > .env.production
npm install
npm run build
aws s3 sync ./out "s3://$FRONTEND_BUCKET/" --delete
cd ..
# 4. Final messages
echo -e "\n✅ Deployment complete!"
echo "🌐 CloudFront URL : $(terraform -chdir=terraform output -raw cloudfront_url)"
if [ -n "$CUSTOM_URL" ]; then
echo "🔗 Custom domain : $CUSTOM_URL"
fi
echo "📡 API Gateway : $API_URL"For Mac/Linux users only - make it executable:
chmod +x scripts/deploy.shWindows users: You don't need to run the chmod command, just create the file.
Step 3: Create PowerShell Script for Windows
Mac/Linux users: You can skip this step - it's only needed for Windows users.
Create scripts/deploy.ps1:
param(
[string]$Environment = "dev", # dev | test | prod
[string]$ProjectName = "twin"
)
$ErrorActionPreference = "Stop"
Write-Host "Deploying $ProjectName to $Environment ..." -ForegroundColor Green
# 1. Build Lambda package
Set-Location (Split-Path $PSScriptRoot -Parent) # project root
Write-Host "Building Lambda package..." -ForegroundColor Yellow
Set-Location backend
uv run deploy.py
Set-Location ..
# 2. Terraform workspace & apply
Set-Location terraform
terraform init -input=false
if (-not (terraform workspace list | Select-String $Environment)) {
terraform workspace new $Environment
} else {
terraform workspace select $Environment
}
if ($Environment -eq "prod") {
terraform apply -var-file="prod.tfvars" -var="project_name=$ProjectName" -var="environment=$Environment" -auto-approve
} else {
terraform apply -var="project_name=$ProjectName" -var="environment=$Environment" -auto-approve
}
$ApiUrl = terraform output -raw api_gateway_url
$FrontendBucket = terraform output -raw s3_frontend_bucket
try { $CustomUrl = terraform output -raw custom_domain_url } catch { $CustomUrl = "" }
# 3. Build + deploy frontend
Set-Location ..\frontend
# Create production environment file with API URL
Write-Host "Setting API URL for production..." -ForegroundColor Yellow
"NEXT_PUBLIC_API_URL=$ApiUrl" | Out-File .env.production -Encoding utf8
npm install
npm run build
aws s3 sync .\out "s3://$FrontendBucket/" --delete
Set-Location ..
# 4. Final summary
$CfUrl = terraform -chdir=terraform output -raw cloudfront_url
Write-Host "Deployment complete!" -ForegroundColor Green
Write-Host "CloudFront URL : $CfUrl" -ForegroundColor Cyan
if ($CustomUrl) {
Write-Host "Custom domain : $CustomUrl" -ForegroundColor Cyan
}
Write-Host "API Gateway : $ApiUrl" -ForegroundColor CyanPart 5: Deploy Development Environment
Step 1: Initialize Terraform
cd terraform
terraform initYou should see:
Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/aws v6.x.x...
Terraform has been successfully initialized!Step 2: Deploy Using the Script
Mac/Linux from the project root:
./scripts/deploy.sh devWindows (PowerShell) from the project root:
.\scripts\deploy.ps1 -Environment devThe script will:
- Build the Lambda package
- Create a
devworkspace in Terraform - Deploy all infrastructure
- Build and deploy the frontend
- Display the URLs
Step 3: Test Your Development Environment
- Visit the CloudFront URL shown in the output
- Test the chat functionality
- Verify everything works as before
✅ Checkpoint: Your dev environment is now deployed via Terraform!
Part 6: Deploy Test Environment
Now let's deploy a completely separate test environment:
Step 1: Deploy Test Environment
Mac/Linux:
./scripts/deploy.sh testWindows (PowerShell):
.\scripts\deploy.ps1 -Environment testStep 2: Verify Separate Resources
Check the AWS Console - you'll see separate resources for test:
twin-test-apiLambda functiontwin-test-memoryS3 buckettwin-test-frontendS3 buckettwin-test-api-gatewayAPI Gateway- Separate CloudFront distribution
Step 3: Test Both Environments
- Open dev CloudFront URL in one browser tab
- Open test CloudFront URL in another tab
- Have different conversations - they're completely isolated!
Part 7: Destroying Infrastructure
When you're done with an environment, you need to properly clean it up. Since S3 buckets must be empty before deletion, we'll create scripts to handle this automatically.
Step 1: Create Destroy Script for Mac/Linux
Create scripts/destroy.sh:
#!/bin/bash
set -e
# Check if environment parameter is provided
if [ $# -eq 0 ]; then
echo "❌ Error: Environment parameter is required"
echo "Usage: $0 <environment>"
echo "Example: $0 dev"
echo "Available environments: dev, test, prod"
exit 1
fi
ENVIRONMENT=$1
PROJECT_NAME=${2:-twin}
echo "🗑️ Preparing to destroy ${PROJECT_NAME}-${ENVIRONMENT} infrastructure..."
# Navigate to terraform directory
cd "$(dirname "$0")/../terraform"
# Check if workspace exists
if ! terraform workspace list | grep -q "$ENVIRONMENT"; then
echo "❌ Error: Workspace '$ENVIRONMENT' does not exist"
echo "Available workspaces:"
terraform workspace list
exit 1
fi
# Select the workspace
terraform workspace select "$ENVIRONMENT"
echo "📦 Emptying S3 buckets..."
# Get AWS Account ID for bucket names
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Get bucket names with account ID
FRONTEND_BUCKET="${PROJECT_NAME}-${ENVIRONMENT}-frontend-${AWS_ACCOUNT_ID}"
MEMORY_BUCKET="${PROJECT_NAME}-${ENVIRONMENT}-memory-${AWS_ACCOUNT_ID}"
# Empty frontend bucket if it exists
if aws s3 ls "s3://$FRONTEND_BUCKET" 2>/dev/null; then
echo " Emptying $FRONTEND_BUCKET..."
aws s3 rm "s3://$FRONTEND_BUCKET" --recursive
else
echo " Frontend bucket not found or already empty"
fi
# Empty memory bucket if it exists
if aws s3 ls "s3://$MEMORY_BUCKET" 2>/dev/null; then
echo " Emptying $MEMORY_BUCKET..."
aws s3 rm "s3://$MEMORY_BUCKET" --recursive
else
echo " Memory bucket not found or already empty"
fi
echo "🔥 Running terraform destroy..."
# Run terraform destroy with auto-approve
if [ "$ENVIRONMENT" = "prod" ] && [ -f "prod.tfvars" ]; then
terraform destroy -var-file=prod.tfvars -var="project_name=$PROJECT_NAME" -var="environment=$ENVIRONMENT" -auto-approve
else
terraform destroy -var="project_name=$PROJECT_NAME" -var="environment=$ENVIRONMENT" -auto-approve
fi
echo "✅ Infrastructure for ${ENVIRONMENT} has been destroyed!"
echo ""
echo "💡 To remove the workspace completely, run:"
echo " terraform workspace select default"
echo " terraform workspace delete $ENVIRONMENT"Make it executable:
chmod +x scripts/destroy.shStep 2: Create Destroy Script for Windows
Create scripts/destroy.ps1:
param(
[Parameter(Mandatory=$true)]
[string]$Environment,
[string]$ProjectName = "twin"
)
# Validate environment parameter
if ($Environment -notmatch '^(dev|test|prod)$') {
Write-Host "Error: Invalid environment '$Environment'" -ForegroundColor Red
Write-Host "Available environments: dev, test, prod" -ForegroundColor Yellow
exit 1
}
Write-Host "Preparing to destroy $ProjectName-$Environment infrastructure..." -ForegroundColor Yellow
# Navigate to terraform directory
Set-Location (Join-Path (Split-Path $PSScriptRoot -Parent) "terraform")
# Check if workspace exists
$workspaces = terraform workspace list
if (-not ($workspaces | Select-String $Environment)) {
Write-Host "Error: Workspace '$Environment' does not exist" -ForegroundColor Red
Write-Host "Available workspaces:" -ForegroundColor Yellow
terraform workspace list
exit 1
}
# Select the workspace
terraform workspace select $Environment
Write-Host "Emptying S3 buckets..." -ForegroundColor Yellow
# Get AWS Account ID for bucket names
$awsAccountId = aws sts get-caller-identity --query Account --output text
# Define bucket names with account ID
$FrontendBucket = "$ProjectName-$Environment-frontend-$awsAccountId"
$MemoryBucket = "$ProjectName-$Environment-memory-$awsAccountId"
# Empty frontend bucket if it exists
try {
aws s3 ls "s3://$FrontendBucket" 2>$null | Out-Null
Write-Host " Emptying $FrontendBucket..." -ForegroundColor Gray
aws s3 rm "s3://$FrontendBucket" --recursive
} catch {
Write-Host " Frontend bucket not found or already empty" -ForegroundColor Gray
}
# Empty memory bucket if it exists
try {
aws s3 ls "s3://$MemoryBucket" 2>$null | Out-Null
Write-Host " Emptying $MemoryBucket..." -ForegroundColor Gray
aws s3 rm "s3://$MemoryBucket" --recursive
} catch {
Write-Host " Memory bucket not found or already empty" -ForegroundColor Gray
}
Write-Host "Running terraform destroy..." -ForegroundColor Yellow
# Run terraform destroy with auto-approve
if ($Environment -eq "prod" -and (Test-Path "prod.tfvars")) {
terraform destroy -var-file="prod.tfvars" -var="project_name=$ProjectName" -var="environment=$Environment" -auto-approve
} else {
terraform destroy -var="project_name=$ProjectName" -var="environment=$Environment" -auto-approve
}
Write-Host "Infrastructure for $Environment has been destroyed!" -ForegroundColor Green
Write-Host ""
Write-Host " To remove the workspace completely, run:" -ForegroundColor Cyan
Write-Host " terraform workspace select default" -ForegroundColor White
Write-Host " terraform workspace delete $Environment" -ForegroundColor WhiteStep 3: Using the Destroy Scripts
To destroy a specific environment:
Mac/Linux:
# Destroy dev environment
./scripts/destroy.sh dev
# Destroy test environment
./scripts/destroy.sh test
# Destroy prod environment
./scripts/destroy.sh prodWindows (PowerShell):
# Destroy dev environment
.\scripts\destroy.ps1 -Environment dev
# Destroy test environment
.\scripts\destroy.ps1 -Environment test
# Destroy prod environment
.\scripts\destroy.ps1 -Environment prodWhat Gets Destroyed
The destroy scripts will:
- Empty S3 buckets (frontend and memory)
- Delete all AWS resources created by Terraform:
- Lambda functions
- API Gateway
- S3 buckets
- CloudFront distributions
- IAM roles and policies
- Route 53 records (if custom domain)
- ACM certificates (if custom domain)
Important Notes
- CloudFront: Distributions can take 5-15 minutes to fully delete
- Workspaces: The scripts destroy resources but keep the workspace. To fully remove a workspace:bash
terraform workspace select default terraform workspace delete dev # or test, prod - Cost Savings: Always destroy unused environments to avoid charges
Part 8: OPTIONAL - Add a Custom Domain
If you want a professional domain for your production twin, follow these steps.
Step 1: Register a Domain (if needed)
Important: Domain registration requires billing permissions, so you'll need to sign in as the root user for this step.
Option A: Register through AWS Route 53
- Sign out of your IAM user account
- Sign in to AWS Console as the root user
- Go to Route 53 in AWS Console
- Click Registered domains → Register domain
- Search for your desired domain
- Add to cart and complete purchase (typically $12-40/year depending on domain)
- Wait for registration (5-30 minutes)
- Once registered, sign back in as your IAM user (
aiengineer) to continue
Option B: Use existing domain
- If you already own a domain elsewhere:
- Transfer DNS to Route 53, or
- Create a hosted zone and update nameservers at your registrar
Step 2: Create Hosted Zone (if not auto-created)
If Route 53 didn't auto-create a hosted zone:
- Go to Route 53 → Hosted zones
- Click Create hosted zone
- Enter your domain name
- Type: Public hosted zone
- Click Create
Step 3: Create Production Configuration
Create terraform/prod.tfvars:
project_name = "twin"
environment = "prod"
bedrock_model_id = "amazon.nova-lite-v1:0" # Use better model for production
lambda_timeout = 60
api_throttle_burst_limit = 20
api_throttle_rate_limit = 10
use_custom_domain = true
root_domain = "yourdomain.com" # Replace with your actual domainStep 4: Deploy Production with Domain
Mac/Linux:
./scripts/deploy.sh prodWindows (PowerShell):
.\scripts\deploy.ps1 -Environment prodThe deployment will:
- Create SSL certificate in ACM
- Validate domain ownership via DNS
- Configure CloudFront with your domain
- Set up Route 53 records
Note: Certificate validation can take 5-30 minutes. The script will wait.
Step 5: Test Your Custom Domain
Once deployed:
- Visit
https://yourdomain.com - Visit
https://www.yourdomain.com - Both should show your Digital Twin!
Understanding Terraform Workspaces
How Workspaces Isolate Environments
Each workspace maintains its own state file:
terraform.tfstate.d/
├── dev/
│ └── terraform.tfstate
├── test/
│ └── terraform.tfstate
└── prod/
└── terraform.tfstateManaging Workspaces
List workspaces:
terraform workspace listSwitch workspace:
terraform workspace select devShow current workspace:
terraform workspace showResource Naming
Resources are named with environment prefix:
- Dev:
twin-dev-api,twin-dev-memory - Test:
twin-test-api,twin-test-memory - Prod:
twin-prod-api,twin-prod-memory
Cost Optimization
Environment-Specific Settings
Our configuration uses different settings per environment:
Development:
- Nova Micro model (cheapest)
- Lower API throttling
- No custom domain
Test:
- Nova Micro model
- Standard throttling
- No custom domain
Production:
- Nova Lite model (better quality)
- Higher throttling limits
- Custom domain with SSL
Cost-Saving Tips
- Destroy unused environments - Don't leave test running
- Use appropriate models - Nova Micro for dev/test
- Set API throttling - Prevent runaway costs
- Monitor with tags - All resources tagged with environment
Troubleshooting
Terraform State Issues
If Terraform gets confused about resources:
# Refresh state from AWS
terraform refresh
# If resource exists in AWS but not state
terraform import aws_lambda_function.api twin-dev-apiDeployment Script Failures
"Lambda package not found"
- Ensure Docker is running
- Run
cd backend && uv run deploy.pymanually
"S3 bucket already exists"
- Bucket names must be globally unique
- Change project_name in terraform.tfvars
"Certificate validation timeout"
- Check Route 53 has the validation records
- Wait longer (can take up to 30 minutes)
Frontend Not Updating
After deployment, CloudFront may cache old content:
# Get distribution ID
aws cloudfront list-distributions --query "DistributionList.Items[?Comment=='twin-dev'].Id" --output text
# Create invalidation
aws cloudfront create-invalidation --distribution-id YOUR_ID --paths "/*"Best Practices
1. Version Control
Always commit your Terraform files:
git add terraform/*.tf terraform/*.tfvars
git commit -m "Add Terraform infrastructure"Never commit:
terraform.tfstatefiles.terraform/directory- AWS credentials
2. Plan Before Apply
Review changes before applying:
terraform plan3. Use Variables
Don't hardcode values - use variables:
# Good
bucket = "${local.name_prefix}-memory"
# Bad
bucket = "twin-dev-memory"4. Tag Everything
Our configuration tags all resources:
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}What You've Accomplished Today!
- ✅ Learned Infrastructure as Code with Terraform
- ✅ Automated entire AWS deployment
- ✅ Created multiple isolated environments
- ✅ Implemented one-command deployment
- ✅ Set up professional deployment scripts
- ✅ Optional: Configured custom domain with SSL
Architecture Summary
Your Terraform manages:
Terraform Configuration
├── S3 Buckets (Frontend + Memory)
├── Lambda Function with IAM Role
├── API Gateway with Routes
├── CloudFront Distribution
└── Optional: Route 53 + ACM Certificate
Managed via Workspaces:
├── dev/ (Development environment)
├── test/ (Testing environment)
└── prod/ (Production with custom domain)Next Steps
Tomorrow (Day 5), we'll add CI/CD with GitHub Actions:
- Automated testing on pull requests
- Deployment pipelines for each environment
- Infrastructure change reviews
- Automated rollbacks
- Complete infrastructure teardown
Your Digital Twin now has professional Infrastructure as Code that any team can deploy and manage!
Resources
Congratulations on automating your infrastructure deployment! 🚀