Terraform — Layer 3 Architecture as Code
| Builds | The WAF + ALB + Auto Scaling + MariaDB architecture in this section |
| Structure | Root config + three modules (vpc, app, database) |
| Skills | Terraform modules, VPC design, security-group tiering, WAF, RDS, S3 |
This is the reference architecture expressed as reusable Terraform — a root configuration wiring
together three modules: vpc (network foundation), app (ALB, WAF, Auto
Scaling), and database (MariaDB RDS + S3). Every module takes variables in and returns
outputs; nothing is hardcoded across module boundaries.
Project structure
terraform-aws-architecture/
├── modules/
│ ├── vpc/ # VPC, subnets (public/app/db), IGW, NAT, DB subnet group
│ ├── app/ # Security groups, ALB, WAFv2, target group, listeners, ASG
│ └── database/ # DB security group, MariaDB RDS, S3 bucket
├── main.tf # wires the modules together
├── variables.tf
├── outputs.tf
└── terraform.tfvars
What I fixed & hardened from the first draft. The original was structurally sound; these are the corrections and improvements made before it was production-ready:
- Syntax bug:
variable "x" { type = string, default = "y" }is invalid HCL — the comma betweentypeanddefaultfailsterraform validate. Split onto separate lines (affectedinstance_typeanddb_password). - Missing HTTPS:443 listener: the diagram shows an HTTPS:443 listener but the draft only created HTTP:80. Added a 443 listener (conditional on an ACM certificate ARN).
- Encryption everywhere: added EBS encryption on the launch template,
storage_encryptedon RDS, and SSE-KMS + versioning + public-access-block on the S3 bucket. - IMDSv2 enforced (
http_tokens = "required") on the launch template — a real, free hardening control. - Secret removed from tfvars: the draft hardcoded
db_passwordinterraform.tfvars. Removed — pass it viaTF_VAR_db_passwordor Secrets Manager, never commit it. - ELB health-check type and a target-tracking scaling policy (keep CPU at 60%) added to the ASG so it actually scales.
The security-group tiering (the heart of the design)
Each tier's security group references the previous tier's SG by ID, not by CIDR. That's what makes the "Firewall 80+443 / 443 / 3306" boundaries in the diagram real: the app tier accepts traffic only from the ALB, and the database accepts 3306 only from the app tier. The database is never reachable from the internet.
# --- Security Groups (Firewalls) ---
# 1. External Firewall (Ports 80 + 443) — the only public-facing tier
resource "aws_security_group" "alb_sg" {
name = "${var.environment}-alb-fw-80-443"
description = "Firewall 80+443: Allows public HTTP/HTTPS traffic to ALB"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
tags = { Name = "${var.environment}-alb-fw-80-443" }
}
# 2. App Firewall (Port 8080) — accepts traffic from the ALB SG only
resource "aws_security_group" "app_sg" {
name = "${var.environment}-app-fw-443"
description = "Firewall 443: Allows traffic from ALB only"
vpc_id = var.vpc_id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.environment}-app-fw-443" }
}
Root configuration — wiring the modules
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "./modules/vpc"
environment = var.environment
vpc_cidr = var.vpc_cidr
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
app_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
db_subnet_cidrs = ["10.0.20.0/24", "10.0.21.0/24"]
availability_zones = ["${var.aws_region}a", "${var.aws_region}b"]
}
module "app" {
source = "./modules/app"
environment = var.environment
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
app_subnet_ids = module.vpc.app_subnet_ids
ami_id = var.ami_id
instance_type = "t3.micro"
certificate_arn = var.certificate_arn
}
module "database" {
source = "./modules/database"
environment = var.environment
vpc_id = module.vpc.vpc_id
db_subnet_group_name = module.vpc.db_subnet_group_name
app_security_group_id = module.app.app_security_group_id
db_password = var.db_password
}
Database module — MariaDB + S3, hardened
# DB Security Group (Firewall 3306) — accepts 3306 from the app tier only
resource "aws_security_group" "db_sg" {
name = "${var.environment}-db-fw-3306"
description = "Firewall 3306: Allows MySQL/MariaDB access from Application Tier"
vpc_id = var.vpc_id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [var.app_security_group_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.environment}-db-fw-3306" }
}
# S3 Bucket for Database Exports/Backups (encrypted, private, versioned)
resource "aws_s3_bucket" "db_storage" {
bucket = "${var.environment}-mariadb-storage-s3-bucket"
force_destroy = true
tags = { Name = "${var.environment}-mariadb-storage" }
}
resource "aws_s3_bucket_public_access_block" "db_storage" {
bucket = aws_s3_bucket.db_storage.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "db_storage" {
bucket = aws_s3_bucket.db_storage.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_versioning" "db_storage" {
bucket = aws_s3_bucket.db_storage.id
versioning_configuration {
status = "Enabled"
}
}
# MariaDB RDS Instance — encrypted at rest, private, not publicly accessible
resource "aws_db_instance" "mariadb" {
identifier = "${var.environment}-mariadb-db"
engine = "mariadb"
engine_version = "10.11"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = "layer3db"
username = var.db_username
password = var.db_password
db_subnet_group_name = var.db_subnet_group_name
vpc_security_group_ids = [aws_security_group.db_sg.id]
publicly_accessible = false
skip_final_snapshot = true
tags = { Name = "${var.environment}-mariadb" }
}
Full source: all twelve files (root + three modules) are available as a downloadable
archive alongside this portfolio. Every file passes HCL validation and the module inputs/outputs
cross-check cleanly.
How to run
# 1. Provide the DB password out-of-band (never in tfvars)
export TF_VAR_db_password='your-strong-password'
# 2. Initialise, review, apply
terraform init
terraform plan # read the diff before applying
terraform apply
# 3. (optional) supply an ACM cert to enable the HTTPS:443 listener
terraform apply -var="certificate_arn=arn:aws:acm:...:certificate/xxxx"
Production notes. For real HA, run one NAT gateway per AZ (the sample uses one for cost).
Prefer a pre-baked golden AMI over
user_data package installs so scale-out is fast. And put
the RDS credentials in Secrets Manager with rotation rather than a plain variable.