Production-style 3-tier AWS architecture provisioned end-to-end with Terraform — VPC, ALB, Auto Scaling Group, and RDS — using a modular, real-world layout.
This project provisions a classic 3-tier architecture on AWS:
- Web tier — public Application Load Balancer (ALB) accepting HTTP traffic
- App tier — EC2 Auto Scaling Group in private subnets, only reachable through the ALB
- Database tier — RDS instance in isolated private subnets, only reachable from the app tier
Everything is deployed across two Availability Zones for high availability and provisioned as code with Terraform.
| Layer | Service / Tool |
|---|---|
| IaC | Terraform >= 1.5, AWS Provider ~> 5.0 |
| Networking | VPC, public + private subnets, IGW, NAT Gateway |
| Web tier | Application Load Balancer |
| App tier | EC2, Launch Template, Auto Scaling Group |
| Database tier | RDS MySQL 8.0 |
| State management | S3 + DynamoDB (remote backend) |
| CI | GitHub Actions (fmt / validate / plan) |
- High availability across two Availability Zones for every tier
- Strict network segmentation with separate public, app, and DB subnets
- Defense in depth using security groups that reference each other rather than CIDR blocks
- No public IPs on app or DB instances — outbound internet only via NAT Gateway
- Auto Scaling Group with Launch Template and IMDSv2 enforced
- Automatic instance refresh on Launch Template changes
- Encryption at rest for RDS storage
- Resource tagging via provider
default_tags+ per-resource tags
The 3-tier pattern cleanly separates concerns: the web tier terminates public traffic, the app tier runs business logic in a security boundary, and the database tier is isolated even further. This enforces defense in depth at the network layer.
To save cost (~$32/month per NAT). The trade-off: if AZ-A goes down, instances in AZ-B lose outbound internet. Production should run one NAT per AZ.
Launch Configurations are deprecated. Launch Templates support IMDSv2 enforcement and mixed instance types — the only forward-compatible choice.
App SG references ALB SG; DB SG references App SG. Tighter, scales without rewriting CIDR lists, and survives subnet changes.
The same Terraform code runs in dev (single-AZ, no backups) and production (Multi-AZ, 7-day backups) by flipping two variables. This is exactly how production teams parameterize environment-specific settings.
By default, Terraform updates the Launch Template version but doesn't replace running instances — they keep serving the old user_data. Adding instance_refresh with triggers = ["launch_template"] rolls instances automatically on every apply.
# 1. Configure
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars (set db_username and db_password)
# 2. Provision
make init
make fmt
make validate
make plan
make apply
# 3. Test
curl $(terraform output -raw alb_dns_name)
# 4. Destroy when done
make destroyThe infrastructure was deployed end-to-end and validated before being destroyed to control costs.
az-1a and az-1b
For a single dev deployment in us-east-1 left running continuously:
| Resource | Approx. monthly cost |
|---|---|
| 2 × t3.micro EC2 (ASG) | ~$15 |
| Application Load Balancer | ~$22 |
| NAT Gateway | ~$32 |
| RDS db.t3.micro (single-AZ) | ~$15 |
| Total (idle) | ~$85 / month |
Always run make destroy between test sessions.
- Module boundaries matter. Picking what goes in
vpc/vsalb/vscompute/is the difference between reusable code and tangled code. - Security group references over CIDR blocks is a small decision that quietly makes infrastructure much safer and easier to evolve.
- IMDSv2 enforcement catches subtle bugs. When I enforced IMDSv2 on the Launch Template, my initial user_data (using IMDSv1 curl calls) silently failed to fetch instance metadata — taught me to validate user_data assumptions against the metadata service version in use.
- Launch Template updates don't auto-refresh ASG instances. Adding
instance_refreshwithtriggers = ["launch_template"]is the production-grade way to handle this — Terraform now rolls instances on every Launch Template change. - Free Plan constraints can push code in the right direction. Parameterizing Multi-AZ and backup retention was forced by the Free Plan but ended up being a more flexible design than hardcoding production-only values.
MIT
Terraform state is stored remotely in S3 with DynamoDB locking to support team collaboration and prevent state corruption.
By default, Terraform stores state locally in terraform.tfstate. This works
for solo projects but breaks immediately in a team or CI environment — two
engineers running apply simultaneously will corrupt each other's state.
This project uses:
- S3 as the single source of truth for state storage (with versioning, so every change is recoverable)
- DynamoDB for state locking — prevents concurrent applies from running at the same time
The S3 bucket and DynamoDB table cannot be managed by Terraform itself
(chicken-and-egg problem). Create them once manually before running
terraform init:
# Create S3 bucket
aws s3api create-bucket \
--bucket <your-bucket-name> \
--region us-east-1
# Enable versioning
aws s3api put-bucket-versioning \
--bucket <your-bucket-name> \
--versioning-configuration Status=Enabled
# Block public access
aws s3api put-public-access-block \
--bucket <your-bucket-name> \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Create DynamoDB lock table
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-1Update backend.tf with your bucket name, then run:
terraform init -migrate-stateTerraform will prompt you to confirm the migration. Type yes. After this,
state lives in S3 — your local terraform.tfstate is no longer the source
of truth.
See backend.tf for the full configuration.
terraform {
backend "s3" {
bucket = "your-bucket-name"
key = "aws-3tier-architecture/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}After running terraform init -migrate-state, verify the state file landed
in S3:
aws s3 ls s3://<your-bucket-name>/aws-3tier-architecture/You should see terraform.tfstate listed. Run terraform plan to confirm
state integrity — output should reflect your current infrastructure with no
unexpected changes.
Set up a fully automated CI/CD pipeline using GitHub Actions so that Terraform never runs manually from a laptop again.
Two workflows run automatically inside .github/workflows/terraform.yaml:
- On every Pull Request →
terraform plan— formats check, validates config, runs a plan, and posts the output as a PR comment so you can review exactly what will change before it hits AWS - On merge to main →
terraform apply— automatically provisions the infrastructure on AWS
Developer pushes code to a feature branch
│
Opens Pull Request
│
GitHub Actions triggers:
✅ terraform fmt -check (is the code formatted correctly?)
✅ terraform validate (is the syntax valid?)
✅ terraform plan (what will change on AWS?)
📝 Plan posted as PR comment
│
Review the plan → merge when happy
│
GitHub Actions triggers:
🚀 terraform apply (29 resources created on AWS automatically)
Before CI/CD, the risk with Terraform is that anyone can run terraform apply from their laptop at any time — even with untested or broken code. In a team, two people could run apply at the same time and corrupt the state file.
This pipeline solves three real problems:
1. Visibility — the plan-as-PR-comment means you see exactly what Terraform will create, change, or destroy before it happens. No surprises.
2. Safety — the fmt and validate checks catch formatting errors and syntax issues automatically. Bad code never reaches AWS.
3. Consistency — infrastructure changes only happen through the pipeline. No one applies from their local machine. Every change is traceable to a commit and a PR.
Sensitive values are stored as GitHub repository secrets, never hardcoded in code:
| Secret | Purpose |
|---|---|
AWS_ACCESS_KEY_ID |
Authenticate to AWS |
AWS_SECRET_ACCESS_KEY |
Authenticate to AWS |
TF_VAR_db_username |
RDS master username |
TF_VAR_db_password |
RDS master password |
After merging the PR, the Apply job ran automatically and provisioned the full 3-tier infrastructure:
Apply complete! Resources: 29 added, 0 changed, 0 destroyed.
Outputs:
alb_dns_name = "aws-3tier-dev-alb-2117024030.us-east-1.elb.amazonaws.com"
asg_name = "aws-3tier-dev-asg"
vpc_id = "vpc-05a54d76a457b969e"
29 AWS resources — VPC, subnets, ALB, ASG, RDS, security groups — created automatically with zero manual steps.
Containerized the Node.js application using Docker and integrated it with AWS ECR (Elastic Container Registry). Extended the GitHub Actions CI/CD pipeline to automatically build Docker images and push them to ECR. Updated EC2 instances to pull and run the containerized app on startup. Debugged and resolved multiple infrastructure issues to achieve a fully automated app deployment pipeline. App is now live on ALB DNS! 🎉
What I Built This Week:
- Simple server returning an HTML webpage
- Displays instance metadata (Instance ID, Availability Zone)
- Listens on port 3000
- Location: app/server.js
- Multi-stage Docker image
- Based on Node.js runtime
- Installs dependencies from package.json
- Exposes port 3000
- Location: app/Dockerfile
- Private Docker registry in AWS
- Stores containerized app images
- Tagged with commit hash and latest
- Created via GitHub Actions automation
- Automatically builds Docker image on push to main
- Authenticates with AWS ECR
- Pushes image with commit hash tag
- Pushes latest tag for easy reference
- File: .github/workflows/terraform.yaml
- Instances install Docker on startup
- Pull image from ECR using IAM role credentials
- Run container with proper port mapping (80→3000)
- Pass environment variables (Instance ID, AZ)
- Location: modules/compute/main.tf (user_data locals)
- EC2 role with ECR read-only permissions
- EC2 instances can authenticate to ECR without hardcoded credentials
- SSM permissions for Session Manager access
- Location: modules/compute/main.tf
- Load balancer routing traffic to healthy targets
- Instances pulling latest image from ECR
- Docker container running the app
- App accessible at ALB DNS name 🚀
- Final result - App live!
Step 1: Write Code
- app/server.js (Node.js app)
- app/Dockerfile (Docker image)
- Infrastructure changes (Terraform)
Step 2: git push to feature branch
Step 3: Create Pull Request
Step 4: GitHub Actions runs on PR:
- terraform fmt -check
- terraform validate
- terraform plan (shows what will change)
- Posts plan as PR comment
- (Docker build step runs on merge only)
Step 5: Review PR and plan
Step 6: Merge to main
Step 7: GitHub Actions automatically runs:
- 🚀 terraform apply (deploys infrastructure)
- 🐳 docker build (builds Docker image)
- 📦 docker push (pushes image to ECR with :latest tag)
Step 8: ASG launches new instances:
- Instances boot
- Docker installs
- aws ecr get-login-password (authenticates via IAM)
- docker pull :latest (pulls your image)
- docker run (starts container on port 80)
Step 9: Health checks pass
- ALB checks port 80 ✅
- Container responds ✅
- Target becomes Healthy ✅
Step 10: App Live!
- ALB routes traffic → App running 🎉🎉
- Problem: docker logs app failed with permission error
- Solution: Added usermod -a -G docker ec2-user to user_data script
- Problem: Instances had no IAM credentials to pull from ECR
- Symptoms:
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ returned empty ECR image pull failed silently Docker container never started Health checks failed App was unreachable
- Root Cause: Launch template was using name instead of arn for IAM instance profile
- Solution: Changed to ARN reference (this was the critical fix!)
# ❌ This didn't work:
iam_instance_profile {
name = aws_iam_instance_profile.ec2_profile.name
}
# ✅ This worked:
iam_instance_profile {
arn = aws_iam_instance_profile.ec2_profile.arn
}- Problem: Instances killed before Docker finished starting
- Solution:
health_check_grace_period = 300 # from 60 seconds- Problem: User data pulled :latest but tag didn't exist in ECR
- Symptoms:
docker pull failed with "manifest not found" Container never started Instance kept getting replaced by ASG
- Symptoms: Updated GitHub Actions to push both commit hash AND latest tag
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest- IAM Instance Profiles: Always use arn not name in launch templates
- Docker Tags: Tag images with both commit hash AND latest
- Health Checks: Give instances enough time to start (300+ seconds)
- Session Manager: Better than SSH for secure instance access
- Metadata Queries: Always verify IAM role with: curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
- User Data Scripts: Test startup scripts locally before deploying
- ECR Permissions: EC2 role needs AmazonEC2ContainerRegistryReadOnly
- Infrastructure as Code ✅
- Remote state management ✅
- Automated CI/CD pipeline ✅
- Containerized application ✅
- Zero-manual deployments ✅
- 🐳 Running in Docker containers
- 🚀 Deployed automatically via GitHub Actions
- 📦 Stored in AWS ECR
- ⚖️ Load-balanced via ALB
- 🔄 Auto-scaling across availability zones
- 🎉 LIVE and accessible at your ALB DNS!
In Week 5, I enhanced my 3‑tier AWS application by adding production‑grade monitoring, logging, and auto‑scaling. Building on Weeks 1–4, this phase focused on observability, reliability, and automated instance lifecycle management.
- Deployed CloudWatch monitoring across all EC2 instances
- Created 5 active log streams with real‑time ingestion
- Configured 6 CloudWatch alarms (critical + warning)
- Implemented auto‑scaling with intelligent health checks
- Integrated Docker container logs with CloudWatch
- Automated deployments through GitHub Actions
- Built custom metrics and a CloudWatch dashboard
- Debugged and resolved multiple infrastructure issues
- Centralized logging for EC2 + Docker
- CloudWatch Agent installed via
user_data - Log groups for application, system, and container logs
- Custom metrics for request count, latency, errors, CPU, and memory
SNS Topics configured for alerting — separate channels for critical and warning CloudWatch alarms.
CloudWatch Log Groups created for application logs, setup logs, and system logs — enabling full observability.
Active log streams from EC2 + Docker containers — real‑time log ingestion verified.
CloudWatch alarms monitoring EC2, ALB, and RDS — covering CPU, memory, latency, unhealthy targets, and DB connections.
-
Terraform Plan
- Validates modules, variables, and syntax
-
Terraform Apply
- Deploys/updates EC2, ALB, RDS, IAM, CloudWatch
-
Docker Build & Push
- Builds image → pushes to ECR
-
Instance Refresh (Rolling Update)
- ASG launches new instance
- Waits for health checks
- Terminates old instance
-
Deployment Summary
- Posts results to PR/commit
End‑to‑end CI/CD pipeline completed successfully — Terraform Apply, Docker Build, ASG Refresh, Health Checks, and Deployment Summary.
Pull request merged after fixing GitHub token permissions for posting deployment comments.
- Instance launches
user_dataexecutes- Installs Docker, AWS CLI, CloudWatch Agent
- Logs into ECR
- Pulls latest Docker image
- Starts container
- CloudWatch Agent streams logs
- Application listens on port 80
- ALB health checks
/health - Marks instance HEALTHY
- Application responding successfully
- 5 CloudWatch log streams active
- 5 custom metrics publishing
- 6 alarms monitoring system health
- Auto‑scaling ready
- Zero failed health checks
- Database connectivity verified
- 2/2 EC2 instances running
- 2/2 ALB targets healthy
- 5 CloudWatch log streams active
- 5 custom metrics publishing
- 6 CloudWatch alarms configured
- 0 failed health checks
- Application fully operational
Both EC2 instances healthy across AZ‑1a and AZ‑1b — ALB routing, CloudWatch monitoring, and multi‑AZ redundancy fully verified.
![]() |
![]() |
| AZ-1a | AZ-1b |
-
Heredoc Syntax
<<-EOFstrips tabs, not spaces — caused invalid shebang.- Fix: Switched to
<<EOF. - Lesson: Use the correct heredoc type when formatting matters.
-
Missing Tools
- Amazon Linux 2023 doesn’t include
wget. - Fix: Replaced with
curl. - Lesson: Always verify tool availability on AMIs.
- Amazon Linux 2023 doesn’t include
-
Exec Redirect Failure
- Redirecting logs to
/var/logfailed andset -ekilled the script silently. - Fix: Removed redirect; rely on Docker + CloudWatch logs.
- Lesson: Boot‑time redirects are fragile.
- Redirecting logs to
-
Disk Space
- 8GB root volume too small for Docker + logs.
- Fix: Increased to 30GB.
- Lesson: Size volumes for real workloads.
-
IAM Policies
- Assumed AWS managed policies existed.
- Fix: Created inline least‑privilege policies.
- Lesson: Never assume — define explicitly.
-
Health Check Timing
- ALB killed instances before user_data finished.
- Fix: Switched to EC2 health checks + 600s grace period.
- Lesson: Grace period must exceed boot time.
-
Security Best Practices
- Updated
.gitignoreto avoid committing sensitive files. - Lesson: Protect infrastructure repos.
- Updated
-
Infrastructure Refreshes
- ASG refresh triggered too early.
- Fix: Tuned refresh strategy + lifecycle hooks.
- Lesson: Rolling updates must match app boot behavior.
-
CloudWatch Monitoring
- Added logs, metrics, alarms, dashboard.
- Lesson: Observability must be built before scaling.
Week 5 brought the entire 3‑tier application to a production‑ready state, backed by full observability, automated scaling, and a reliable CI/CD pipeline. With CloudWatch monitoring, custom metrics, alarms, and real‑time log streaming in place, the system can now detect issues early, self‑heal through auto‑scaling, and support continuous deployments with zero downtime.
This week solidified the environment as a stable, monitored, and scalable production setup, ready to handle real traffic and future enhancements.
In Week 6, I migrated the entire application from EC2/ASG to Amazon EKS (Elastic Kubernetes Service), completing the journey from raw infrastructure to fully orchestrated container workloads. Building on Weeks 1–5, this phase focused on container orchestration, zero-downtime deployments, and Kubernetes-native auto scaling.
- Provisioned a production-grade EKS cluster with Terraform across us-east-1a and us-east-1b
- Replaced EC2 Auto Scaling Group with Kubernetes Deployment (2 pods, always running)
- Configured Horizontal Pod Autoscaler (HPA) — scales pods from 2 to 6 at 70% CPU
- Registered EKS nodes with ALB target group on NodePort 30080
- Injected DB credentials securely via Kubernetes Secret (no plaintext in manifests)
- Updated CI/CD pipeline — replaced instance-refresh with kubectl rollout (~30 second deploys)
- Validated multi-AZ pod distribution — pods running across us-east-1a and us-east-1b
- Debugged and resolved multiple EKS networking and security group issues
- Managed EKS control plane provisioned via Terraform
- Node group with 2x t3.micro EC2 instances in private subnets
- Kubernetes namespace
aws-3tier-devfor logical isolation - IAM roles for cluster and node group with least-privilege permissions
- CloudWatch Container Insights for pod-level observability
| Resource | Purpose |
|---|---|
| Namespace | Isolated environment aws-3tier-dev |
| Deployment | Keeps 2 pods running always, rolling update strategy |
| Service | Exposes pods to ALB on NodePort 30080 |
| HPA | Auto scales pods 2→6 based on CPU utilization |
| Secret | Injects DB password securely into pods |
- EKS nodes in private subnets — no public IP
- ALB security group allows only port 80 from internet
- NodePort rule added directly to EKS-managed security group
- DB credentials stored in Kubernetes Secret, never in YAML
- IAM roles handle ECR authentication — no stored credentials
Push to main
│
▼
JOB 1: Terraform Apply
├── Provisions VPC (if not exists)
├── Provisions ALB (if not exists)
├── Provisions EKS cluster (~12 mins first time)
├── Provisions EKS node group (2x t3.micro)
├── Creates Kubernetes namespace + secret
├── Provisions RDS (if not exists)
└── Provisions CloudWatch monitoring
│
▼
JOB 2: Docker Build + ECR Push
├── docker build
├── docker tag with git SHA
└── docker push to ECR (latest + SHA tag)
│
▼
JOB 3: kubectl Deploy
├── aws eks update-kubeconfig
├── kubectl apply deployment, service, hpa
└── kubectl rollout status (~30 seconds)
│
▼
JOB 4: Health Check
├── wait 30 seconds for pods to stabilize
├── curl http://<alb-dns>/health until 200 OK
└── test /, /health, /metrics endpoints
│
▼
JOB 5: Deployment Summary
└── posts summary comment on commit
- Terraform provisions EKS cluster and node group
- Kubernetes schedules pods on nodes across AZs
- Nodes pull Docker image from ECR using IAM role
- Pod starts — Node.js app listens on port 3000
- Readiness probe hits
/health— pod marked Ready - Service routes traffic from NodePort 30080 → pod port 3000
- ALB forwards requests to healthy nodes on port 30080
- App responds with HEALTHY status
| Week 5 (EC2/ASG) | Week 6 (EKS) | |
|---|---|---|
| Compute | EC2 instances | Kubernetes pods |
| Deploy time | 5–10 minutes | ~30 seconds |
| Self healing | ASG replaces EC2 (minutes) | K8s restarts pod (seconds) |
| Scaling | Scale EC2 instances | Scale pods via HPA |
| Deploy method | Instance refresh | kubectl rollout |
| Credentials | Shell script env vars | Kubernetes Secrets |
| Boot sequence | user_data shell script | Container image |
- Application responding HEALTHY on EKS
- 2 pods running across us-east-1a and us-east-1b
- HPA active — min 2 / max 6 pods at 70% CPU
- ALB routing traffic to both nodes on port 30080
- RDS connectivity verified from pods
- CloudWatch monitoring active
- Zero-downtime rolling deployments verified
- 2/2 EKS nodes Ready
- 2/2 pods Running
- 2/2 ALB targets healthy on port 30080
- HPA configured (min 2 / max 6 replicas)
- Rolling deploy completed in ~30 seconds
- Application fully operational on Kubernetes
![]() |
![]() |
| Pod in us-east-1a | Pod in us-east-1b |
Kubernetes namespace not found:
- Terraform tried to create the secret before the namespace existed.
- Fix: Created namespace via
kubernetes_namespace_v1in Terraform before the secret. - Lesson: Resource ordering matters — use
depends_onexplicitly.
ALB target group 0 registered targets:
- EKS nodes were not registered with the ALB target group.
- Fix: Added
aws_autoscaling_attachmentto register EKS node group ASG with ALB. - Lesson: EKS doesn't auto-register nodes with existing ALBs — must wire explicitly.
ALB health checks timing out:
- Port mismatch — ALB target group was on port 80, nodes expose port 30080.
- Fix: Updated target group port to 30080 and health check path to
/health. - Lesson: ALB port must match the NodePort defined in the Kubernetes Service.
EKS managed security group not updated:
- Our custom security group had the right rules but EKS attached its own managed SG to nodes.
- Fix: Added
aws_security_group_ruledirectly tocluster_security_group_id. - Lesson: EKS manages its own SG — always add rules to the cluster SG, not a custom one.
Rolling deploy timeout on t3.micro:
maxSurge: 1tried to run 3 pods on 2 tiny nodes simultaneously — not enough resources.- Fix: Changed to
maxSurge: 0, maxUnavailable: 1and reduced resource requests. - Lesson: Rolling update strategy must match available node capacity.
AZ showing unknown in app:
fieldRefcannot read node labels liketopology.kubernetes.io/zone.- Fix: Fetched AZ directly from EC2 metadata API in
server.jsat startup. - Lesson: Use EC2 metadata API for instance-level info inside pods.
k8s manifests in wrong directory:
k8s/folder was accidentally created insidemodules/instead of repo root.- Fix: Moved to root with
mv modules/k8s ./k8s. - Lesson: CI/CD runner uses repo root — always verify file paths match workflow commands.
Week 6 completed the migration from EC2-based container hosting to Kubernetes orchestration. The same Node.js application that started on raw EC2 instances in Week 1 now runs as Kubernetes pods with automatic scheduling, self-healing, rolling deployments, and pod-level auto scaling.
The full 6-week progression tells a complete cloud engineering story:
- Week 1: Built it — 3-tier AWS architecture with Terraform
- Week 2: Secured it — remote state with S3 + DynamoDB
- Week 3: Automated it — GitHub Actions CI/CD pipeline
- Week 4: Containerized it — Docker + ECR
- Week 5: Monitored it — CloudWatch, alarms, dashboards
- Week 6: Orchestrated it — EKS, HPA, rolling deployments
Infrastructure as Code → CI/CD → Containers → Observability → Kubernetes.















































