Project: Three-Tier Reference Architecture

Build a complete modular three-tier stack — network, compute, load balancer, database, artifacts — with remote state and locking, then migrate it from local state to a remote backend without recreating anything.

advanced 45 min lesson hands-on task included

Everything in this path, assembled once. A three-tier architecture is the standard shape for a web application and exercises nearly every concept you have learned: modules, outputs wiring modules together, count over availability zones, security group chaining, instance profiles, remote state and locking.


Topic 1: The Architecture

TierPlacementComponents
WebPublic subnetsAuto-scaling group, launch template, load balancer
ApplicationPrivate subnetsAuto-scaling group, launch template, instance profile
DatabasePrivate subnetsManaged relational instance, dedicated subnet group
AccessPublic subnetBastion host, SSH restricted to a named source range
State—Object storage bucket + lock table
Artifacts—Object storage bucket, public access blocked
                          ┌──────────┐
                          │ Internet │
                          └────┬─────┘
                               ▼
                     ┌───────────────────┐
                     │ Internet Gateway  │◀────────────┐
                     └─────┬───────┬─────┘             │
                           │       │                   │
                           ▼       ▼                   │
              ┌────────────────┐  ┌──────────────┐     │
              │ Load Balancer  │  │   Bastion    │     │
              └───────┬────────┘  │ public subnet│     │
                      │           └──────┬───────┘     │
                      ▼                  ┆ SSH         │
        ┌──────────────────────────┐     ┆             │
        │  WEB TIER — ASG          │◀────┘             │
        │  public subnets, 2 AZs   │──────┐            │
        └───────────┬──────────────┘      │            │
                    ▼                     ▼            │
        ┌──────────────────────────┐  ┌──────────────┐ │
        │  APP TIER — ASG          │  │   Artifact   │ │
        │  private subnets, 2 AZs  │  │    Bucket    │ │
        └───────┬──────────┬───────┘  └──────────────┘ │
                │          │                           │
                ▼          └──▶ ┌─────────────┐        │
      ┌──────────────────┐      │ NAT Gateway │────────┘
      │  DATABASE        │      └─────────────┘
      │  private subnets │
      └──────────────────┘

Topic 2: Layout

.
├── main.tf              # backend + module composition
├── variables.tf
├── outputs.tf
├── providers.tf
├── versions.tf
├── terraform.tfvars
├── install_web.sh
├── install_app.sh
└── modules/
    ├── networking/      # VPC, subnets, IGW, NAT, route tables, SGs, DB subnet group
    ├── compute/         # launch templates, ASGs, key pair
    ├── loadbalancer/    # LB, target group, listener
    ├── database/        # managed database instance
    └── artifacts/       # bucket, IAM policy, role, instance profile

Five modules, each with a single responsibility. The root module composes them and wires outputs to inputs — it should contain almost no resources of its own.


Topic 3: Root Composition

terraform {
  backend "s3" {
    bucket         = "org-tf-remote-backend"
    dynamodb_table = "org-tf-state-lock"
    key            = "three-tier/terraform.tfstate"
    encrypt        = true
    region         = "us-east-1"
  }
}

module "networking" {
  source        = "./modules/networking"
  vpc_cidr      = var.vpc_cidr
  subnets_cidrs = var.subnets_cidrs
  azs           = var.azs
  access_ip     = var.access_ip      # your IP — never 0.0.0.0/0
}

module "loadbalancer" {
  source            = "./modules/loadbalancer"
  alb_sg            = module.networking.alb_sg
  public_subnets    = module.networking.public_subnets
  vpc_id            = module.networking.vpc_id
  tg_port           = 80
  tg_protocol       = "HTTP"
  listener_port     = 80
  listener_protocol = "HTTP"
}

module "compute" {
  source           = "./modules/compute"
  key_name         = var.key_name
  ami_value        = var.ami_value
  instance_type    = var.instance_type
  bastion_sg       = module.networking.bastion_sg
  web_tier_sg      = module.networking.web_tier_sg
  app_tier_sg      = module.networking.app_tier_sg
  public_subnets   = module.networking.public_subnets
  private_subnets  = module.networking.private_subnets
  target_group_arn = module.loadbalancer.target_group_arn
}

module "database" {
  source          = "./modules/database"
  db_engine       = var.db_engine
  db_instance_class = var.db_instance_class
  db_username     = var.db_username
  db_password     = data.aws_ssm_parameter.db_password.value
  db_subnet_group = module.networking.db_subnet_group_name
  db_sg_id        = module.networking.db_tier_sg
}

Every cross-module reference goes through an output. That is what creates the dependency graph — networking is built before the load balancer, which is built before compute.


Topic 4: Security Group Chaining

The pattern worth internalising: each tier accepts traffic only from the tier above, referenced by security group rather than by CIDR.

resource "aws_security_group" "web_tier" {
  name   = "web-tier-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    description     = "HTTP from load balancer only"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  ingress {
    description     = "SSH from bastion only"
    from_port       = 22
    to_port         = 22
    protocol        = "tcp"
    security_groups = [aws_security_group.bastion.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Referencing a security group instead of a CIDR means the rule stays correct when instances scale, restart or change address. A CIDR-based rule is a snapshot of a moment; a group-based rule is a statement about identity.


Topic 5: Subnets from One Indexed List

One variable drives three tiers across two availability zones:

resource "aws_subnet" "web_public" {
  count                   = length(var.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.subnets_cidrs[count.index]
  availability_zone       = var.azs[count.index]
  map_public_ip_on_launch = true
}

resource "aws_subnet" "app_private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnets_cidrs[count.index + 2]
  availability_zone = var.azs[count.index]
}

resource "aws_subnet" "db_private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnets_cidrs[count.index + 4]
  availability_zone = var.azs[count.index]
}
subnets_cidrs = [
  "10.0.1.0/24", "10.0.2.0/24",   # web
  "10.0.3.0/24", "10.0.4.0/24",   # app
  "10.0.5.0/24", "10.0.6.0/24",   # db
]

This is count used correctly — the instances are genuinely identical and position carries meaning. Note the offset arithmetic is brittle if someone reorders the list; a map keyed by tier and AZ would be safer, and is a worthwhile refactor once the stack works.

The private route table must use nat_gateway_id, not gateway_id. gateway_id is for internet gateways, and the mistake produces a route that silently fails to work.


Topic 6: Instance Profile, Not Credentials

resource "aws_iam_role" "artifact_access" {
  name = "artifact-access-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}

resource "aws_iam_policy" "artifact_access" {
  name = "artifact-access-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
      Resource = [
        aws_s3_bucket.artifacts.arn,
        "${aws_s3_bucket.artifacts.arn}/*"
      ]
    }]
  })
}

resource "aws_iam_instance_profile" "artifact_access" {
  name = "artifact-access-profile"
  role = aws_iam_role.artifact_access.name
}

Note the Resource list references the bucket’s real ARN. A hardcoded "arn:aws:s3:::*/*" grants access to every object in every bucket in the account — a mistake that appears in a lot of copied example code and passes every test you are likely to run.


Topic 7: The Migration Sequence

The part of this project worth doing carefully:

  1. Start with the backend block commented out. State is local; iterate quickly.
  2. Apply and verify the stack works — hit the load balancer endpoint, SSH through the bastion to the app tier, connect to the database.
  3. Create the state bucket and lock table (by hand or in a separate bootstrap project).
  4. Uncomment the backend block and run terraform init. Terraform detects local state and offers to migrate. Confirm with yes.
  5. Verify: terraform.tfstate is gone locally; the object exists in the bucket.
  6. Run terraform plan. It must report no changes. This is the proof the remote backend sees the same infrastructure. If it proposes creating everything, stop — you are one apply away from a duplicate estate.

Topic 8: Cleaning Up

terraform destroy

Then verify the account is actually empty. Things that commonly survive a destroy:

  • Objects in buckets that block deletion of a non-empty bucket — set force_destroy = true on scratch buckets.
  • Final database snapshots, if skip_final_snapshot = false (which is correct for production and inconvenient here).
  • Elastic IPs and NAT gateways, which bill hourly regardless of traffic.
  • Log groups, which have no cost signal until they do.

This check is the practical version of the teardown problem from lesson 1 — Terraform computes the order for you, but only for what it manages.


Try it yourself: After the stack is running, change one attribute inside the networking module and read the plan before applying. Trace how a change in one module propagates through the outputs into compute and the load balancer. That propagation is the payoff for wiring modules through outputs rather than duplicating values.

Common mistake: Setting access_ip = "0.0.0.0/0" to make the bastion easier to reach during development, then shipping it. That opens SSH to the entire internet on a host with network access to your private subnets — which is precisely the blast radius a bastion is supposed to contain.