A practical, end-to-end workshop for building a real-world, production-ready 3-tier AWS architecture using Terraform — covering networking, compute, database, security, and infrastructure best practices.
- About This Workshop
- Architecture Overview
- Prerequisites
- Tech Stack
- Project Folder Structure
- Step 1 — Creating the S3 Backend Bucket
- Step 2 — Creating Custom Modules
- Step 3 — Creating the Dev Environment
- Best Practices
- Key Takeaways
- Future Improvements
- Get in Touch
- References and Resources
This hands-on workshop walks you through building a production-grade, 3-tier AWS architecture entirely with Terraform — from a blank directory to a fully functional, secured, and environment-aware cloud infrastructure.
You will learn how to:
- Structure Terraform projects for real-world maintainability
- Build and reuse custom Terraform modules for VPC and EC2
- Leverage community modules from the Terraform Registry for security groups and RDS
- Configure remote state with S3 backend and state locking
- Apply least-privilege security between tiers using security groups
- Manage multiple environments (dev, staging, prod) from a single codebase
This is not a click-through tutorial — you will write, reason about, and debug real Terraform code.
The architecture follows a classic 3-tier model:
| Tier | AWS Service | Description |
|---|---|---|
| Presentation (Web) | EC2 (Public Subnet) | Public-facing instances, accessible from the internet |
| Application (App) | EC2 (Private Subnet) | Business logic layer, no direct internet exposure |
| Data | RDS PostgreSQL (Private Subnet) | Managed database, accessible only from the app tier |
Networking highlights:
- A custom VPC spanning 2 Availability Zones for resilience
- Public subnets with an Internet Gateway for the web tier
- Private subnets with a NAT Gateway for outbound-only access from the app/data tiers
- Security groups enforcing strict, tier-to-tier traffic rules
Before starting this workshop, ensure you have the following:
- An AWS account with IAM permissions to create VPCs, EC2 instances, RDS instances, S3 buckets, and security groups
- Terraform v1.5+ installed — Install Terraform
- AWS CLI v2 installed and configured — Install AWS CLI
- Git installed
- Basic familiarity with Terraform HCL syntax and AWS core services
Configure your AWS credentials before running any Terraform commands:
aws configure
# or use environment variables:
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-east-1"- Infrastructure as Code: Terraform ~> 1.5
- Cloud Provider: AWS (
hashicorp/aws~> 5.0) - Compute: Amazon EC2
- Database: Amazon RDS for PostgreSQL 16
- Networking: Amazon VPC, Subnets, Internet Gateway, NAT Gateway
- Security: AWS Security Groups
- State Management: Amazon S3
- Community Modules:
terraform-aws-modules/security-group,terraform-aws-modules/rds
terraform-3tier-workshop/
│
├── books/ # Supplementary reading and reference material
│
├── images/ # Architecture diagrams
│ ├── 3-tier-architecture.png
│ └── 3-tier-architecture-future-imp.png
│
├── state-bootstrap/ # One-time setup: S3 bucket
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── modules/ # Reusable custom Terraform modules
│ ├── vpc/ # Custom VPC module
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ec2/ # Custom EC2 module
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── environment/
│ └── dev/ # Dev environment root configuration
│ ├── backend.tf # Remote state backend config
│ ├── provider.tf # AWS provider config
│ ├── main.tf # Module calls and resource definitions
│ ├── variables.tf # Input variables
│ ├── terraform.tfvars # Variable values (optional) NOTE:(do not commit secrets)
│ └── outputs.tf # Environment outputs
│
├── .gitignore
└── Readme.md
Convention: Each module contains exactly three files —
main.tf(resources),variables.tf(inputs), andoutputs.tf(outputs). This keeps modules predictable and easy to navigate.
Terraform state tracks everything it manages. By default, state is stored locally — which is unsafe for teams and risky for production. We bootstrap a remote backend using S3 (storage) before writing any environment code.
Note: The S3 bucket can be created in three ways: using the
state-bootstrapTerraform config in this repo (recommended), via the AWS Console, or via the AWS CLI. All three approaches are shown below.
Navigate to the bootstrap directory and apply:
cd state-bootstrap
terraform init
terraform applyThe bootstrap config creates:
# state-bootstrap/main.tf
resource "aws_s3_bucket" "terraform_state" {
bucket = "${var.project_name}-terraform-state"
force_destroy = false
tags = {
Name = "${var.project_name}-terraform-state"
Environment = "global"
ManagedBy = "terraform"
}
}
# Enable versioning — allows recovery of previous state files
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# Enable AES-256 server-side encryption
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"
}
}
}
# Block all public access — state files must never be public
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
BUCKET_NAME="my-project-terraform-state"
REGION="us-east-1"
# Create the bucket
aws s3api create-bucket \
--bucket $BUCKET_NAME \
--region $REGION
# Enable versioning
aws s3api put-bucket-versioning \
--bucket $BUCKET_NAME \
--versioning-configuration Status=Enabled
# Enable AES-256 encryption
aws s3api put-bucket-encryption \
--bucket $BUCKET_NAME \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
# Block all public access
aws s3api put-public-access-block \
--bucket $BUCKET_NAME \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
- Navigate to S3 → Create bucket
- Set a globally unique name and select your region
- Under Bucket Versioning, select Enable
- Under Default encryption, select SSE-S3 (AES-256)
- Under Block Public Access, ensure all four options are checked
- Create the bucket, then create a DynamoDB table named
<project>-terraform-lockswithLockID(String) as the partition key
Custom modules allow you to encapsulate, reuse, and version your infrastructure building blocks. We build two custom modules: VPC and EC2.
A Terraform module is simply a directory containing .tf files. The three-file pattern (main.tf, variables.tf, outputs.tf) is the community standard.
The VPC module provisions the entire network layer: VPC, public/private subnets across 2 AZs, Internet Gateway, and route tables and route table associations.
modules/vpc/
├── main.tf # VPC, subnets, IGW, NAT GW, route tables, DB subnet group
├── variables.tf # Input: CIDR blocks, AZs, env name, tags
└── outputs.tf # Output: vpc_id, public_subnet_ids, private_subnet_ids, azs
Key design decisions in the VPC module:
- Two AZs minimum — required by RDS and recommended for all production workloads
- Private subnets for EC2 app tier and RDS — no direct internet exposure
- DB subnet group created within the module so RDS can be deployed immediately
# modules/vpc/variables.tf (example)
variable "env_name" { type = string }
variable "vpc_cidr" { type = string }
variable "public_subnets" { type = list(string) }
variable "private_subnets" { type = list(string) }
variable "azs" { type = list(string) }# modules/vpc/outputs.tf (example)
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 }
output "availability_zones" { value = var.azs }
output "db_subnet_group_name" { value = aws_db_subnet_group.this.name }The EC2 module provisions compute instances with configurable AMI, instance type, subnet placement, security group associations.
modules/ec2/
├── main.tf # aws_instance resource, optional EBS volumes
├── variables.tf # Input: ami, instance_type, subnet_id, sg_ids, tags
└── outputs.tf # Output: instance_id, private_ip, public_ip
Key design decisions in the EC2 module:
- Accepts
subnet_idandvpc_security_group_idsas inputs — subnet placement is controlled by the calling environment, not hardcoded in the module - Supports
user_datafor bootstrapping (e.g. installing packages on first boot) - IMDSv2 enforced for instance metadata security
# modules/ec2/variables.tf (example)
variable "env_name" { type = string }
variable "ami" { type = string }
variable "instance_type" { type = string default = "t3.micro" }
variable "subnet_id" { type = string }
variable "vpc_security_group_ids" { type = list(string) }
variable "tags" { type = map(string) default = {} }The environment/dev/ directory is the root Terraform configuration for the dev environment. It wires together all custom and community modules into a working, deployable stack.
cd environment/dev
terraform init
terraform plan
terraform applyRemote state is configured in backend.tf. This file cannot use variables — all values must be literals.
# environment/dev/backend.tf
terraform {
required_version = ">= 0.12"
backend "s3" {
bucket = "YOUR_BUCKET_NAME
key = "dev/terraform.tfstate"
region = "REGION"
encrypt = true
use_lockfile = true
}
}Multi-environment tip: Each environment uses a different
key(dev/terraform.tfstate,staging/terraform.tfstate,prod/terraform.tfstate) but the same bucket. This keeps state files isolated while sharing a single backend.
# environment/dev/provider.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Environment = var.env_name
Project = "terraform-3tier-workshop"
ManagedBy = "terraform"
}
}
}Pro tip: Use
default_tagsin the provider block to tag every resource automatically without repeating tag blocks in every module call.
# environment/dev/main.tf
# --- VPC (custom module) ---
module "dev_vpc" {
source = "../../modules/vpc"
}
# --- EC2 Private Instance (custom module) ---
module "web_server" {
source = "../../modules/ec2"
availability_zone = module.dev_vpc.availability_zones[0]
subnet_id = module.dev_vpc.public_subnet_id
security_groups = [module.ec2_public_security_group.security_group_id]
}
module "app_server" {
source = "../../modules/ec2"
availability_zone = module.dev_vpc.availability_zones[0]
subnet_id = module.dev_vpc.private_app_subnet_ids
security_groups = [module.ec2_private_security_group.security_group_id]
}
Community modules from the Terraform Registry let you adopt battle-tested patterns without writing everything from scratch.
# ===== Security Group for public EC2 instance =============================================================
module "ec2_public_security_group" {
source = "terraform-aws-modules/security-group/aws//modules/http-80"
version = "~> 5.0"
name = "${var.env_name}-public-ec2-sg"
description = "Security group for public EC2 instances"
vpc_id = module.dev_vpc.vpc_id
# Allow HTTP and HTTPS from anywhere (public facing)
ingress_cidr_blocks = ["0.0.0.0/0"]
# Allow SSH only from your IP or a trusted CIDR
ingress_with_cidr_blocks = [
{
from_port = 22
to_port = 22
protocol = "tcp"
description = "SSH access"
cidr_blocks = var.vpc_cidr # replace with your IP
},
{
from_port = 443
to_port = 443
protocol = "tcp"
description = "HTTPS access"
cidr_blocks = "0.0.0.0/0"
}
]
egress_rules = ["all-all"] # allow all outbound
tags = {
Name = "${var.env_name}-public-ec2-sg"
}
}
# ===== Security Group for private EC2 instance ===============================================
module "ec2_private_security_group" {
source = "terraform-aws-modules/security-group/aws//modules/http-80"
version = "~> 5.0"
name = "${var.env_name}-private-ec2-sg"
description = "Security group for private EC2 instances"
vpc_id = module.dev_vpc.vpc_id
# Only allow HTTP traffic from the web security group
ingress_with_source_security_group_id = [
{
from_port = 80
to_port = 80
protocol = "tcp"
description = "HTTP from web server only"
source_security_group_id = module.ec2_public_security_group.security_group_id
},
{
from_port = 443
to_port = 443
protocol = "tcp"
description = "HTTPS from web server only"
source_security_group_id = module.ec2_public_security_group.security_group_id
}
]
# SSH from within VPC only
ingress_with_cidr_blocks = [
{
from_port = 22
to_port = 22
protocol = "tcp"
description = "SSH from VPC only"
cidr_blocks = var.vpc_cidr
}
]
egress_rules = ["all-all"]
tags = {
Name = "${var.env_name}-private-ec2-sg"
}
}
module "dev_rds" {
source = "terraform-aws-modules/rds/aws"
version = "~> 6.0"
identifier = "${var.env_name}-postgresql"
# Engine
engine = "postgres"
engine_version = "16"
family = "postgres16"
major_engine_version = "16"
instance_class = "db.t3.micro"
# Storage
allocated_storage = 20
max_allocated_storage = 100
storage_encrypted = true
# Credentials
db_name = var.db_name
username = var.db_username
password = var.db_password
port = 5432
# Network — same AZ as your EC2 instances
# db_subnet_group_name = "${var.env_name}-db-subnet-group"
db_subnet_group_name = module.dev_vpc.db_subnet_group_name
vpc_security_group_ids = [module.rds_postgresql_security_group.security_group_id]
availability_zone = module.dev_vpc.availability_zones[0] # same AZ as EC2s
multi_az = false
# Backups
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
# Monitoring
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
# Other settings
deletion_protection = false # set to true for production
skip_final_snapshot = true # set to false for production
delete_automated_backups = true
tags = {
Name = "${var.env_name}-postgresql"
}
}
# ===== Security Group for RDS instance ===============================================
module "rds_postgresql_security_group" {
source = "terraform-aws-modules/security-group/aws//modules/postgresql"
version = "~> 5.0"
name = "${var.env_name}-rds-postgresql-sg"
description = "Security group for RDS PostgreSQL - allows traffic from private EC2 only"
vpc_id = module.dev_vpc.vpc_id
ingress_rules = [] # ← add this to disable default rules
ingress_cidr_blocks = [] # ← add this to clear any default CIDRs
# ✅ Only allow PostgreSQL (5432) from the private EC2 security group
ingress_with_source_security_group_id = [
{
from_port = 5432
to_port = 5432
protocol = "tcp"
description = "PostgreSQL from private EC2 only"
source_security_group_id = module.ec2_private_security_group.security_group_id
}
]
egress_rules = ["all-all"]
tags = {
Name = "${var.env_name}-rds-postgresql-sg"
}
}
Critical: Always pass
db_subnet_group_nameas a resource reference (e.g.module.dev_vpc.db_subnet_group_name), never as a hardcoded string. A hardcoded string removes Terraform's ability to infer the dependency, which can causeDBSubnetGroupNotFounderrors duringapply.
The following practices are applied throughout this workshop and are recommended for any production Terraform project.
State management
- Always use remote state with S3 — never commit
.tfstatefiles to Git - Use a separate state key per environment (
dev/terraform.tfstate,prod/terraform.tfstate) - Enable S3 versioning so you can roll back to a previous state file if needed
Security
- Follow least-privilege for security groups: allow only the specific port, protocol, and source needed
- Never use
0.0.0.0/0for ingress unless the tier is explicitly public-facing - Store sensitive values (DB passwords, API keys) in AWS Secrets Manager or SSM Parameter Store — not in
.tfvarsfiles committed to Git - Add
.terraform/,*.tfstate,*.tfstate.backup, and*.tfvarscontaining secrets to.gitignore - Enable storage encryption for RDS (
storage_encrypted = true)
Module design
- Pass resource references between modules rather than hardcoded strings — this gives Terraform the dependency graph it needs to order operations correctly
- Keep modules single-purpose and composable
- Always define
outputs.tf— callers should never have to reach inside a module's internal resources
Tagging
- Use
default_tagsin the provider block to enforce consistent tags on every resource - At minimum, tag every resource with
Environment,Project, andManagedBy
Cost control (for dev/non-prod)
- Use
single_nat_gateway = true— one NAT Gateway instead of one per AZ - Use
db.t3.microandt3.microinstance types - Set
multi_az = falsefor RDS in dev - Set
skip_final_snapshot = trueanddelete_automated_backups = truein dev so teardown is clean
General
- Always run
terraform planbeforeterraform applyand review the diff - Use
-targetto create foundational resources (VPC, security groups) before dependent resources when troubleshooting ordering issues - Pin module and provider versions with
~>to avoid unexpected breaking changes
By completing this workshop, you will have hands-on experience with:
- Terraform project structure — how to organize code for maintainability across multiple environments
- Remote state — why it matters and how to set it up correctly with locking
- Custom module design — building reusable, composable infrastructure modules with clear inputs and outputs
- Community modules — how to evaluate and use the Terraform Registry responsibly
- Dependency management — using resource references instead of strings to let Terraform sequence operations correctly
- Security group design — enforcing zero-trust, tier-to-tier access with least-privilege rules
- Debugging Terraform errors — understanding eventual consistency, dependency ordering, and subnet AZ coverage requirements
- Cost-aware infrastructure — making deliberate choices for dev vs production configurations
The following improvements would take this architecture to full production readiness:
| Improvement | Description |
|---|---|
| Application Load Balancer | Distribute traffic across multiple EC2 instances in the web/app tier |
| Auto Scaling Groups | Automatically scale EC2 capacity based on load |
| RDS Multi-AZ | Enable multi_az = true for automatic failover and high availability |
| RDS Read Replica | Offload read traffic and improve database performance |
| AWS Secrets Manager | Rotate and inject database credentials securely at runtime |
| Staging Environment | Duplicate the dev environment config into environment/staging/ |
| prod Environment | Duplicate the dev environment config into environment/prod/ |
Have questions, suggestions, or found a bug? Feel free to reach out:
- GitHub Issues: Open an issue
- GitHub Discussions: Use the Discussions tab for questions and ideas
- Pull Requests: Contributions are welcome — fork the repo, make your changes, and open a PR
If this workshop was useful, consider giving the repo a ⭐ — it helps others find it.
Terraform
- Terraform Documentation
- Terraform Registry
- terraform-aws-modules/vpc
- terraform-aws-modules/security-group
- terraform-aws-modules/rds
- Terraform S3 Backend
- Terraform Module Best Practices
AWS
- Amazon VPC Documentation
- Amazon EC2 User Guide
- Amazon RDS for PostgreSQL
- AWS Security Group Best Practices
- AWS Well-Architected Framework
Further Learning
Built with ❤️ by kodcapsule

