Skip to content

10 · Capstone — Multi-Environment Platform

This capstone combines every Level 3 module: directory-per-environment structure (module 06), remote state read across environments (Level 2 module 03 plus module 07's graph reasoning), state locking (module 01), a policy check (module 05), and a terraform test suite (module 04) — built around the Level 2 capstone's network/app modules. As with every module in this course, this is reasoned through against documented behavior and was not run against a real cloud account.

Directory layout

platform/
  modules/
    network/        (from Level 2 capstone)
    app/            (from Level 2 capstone)
  envs/
    dev/
      main.tf
      backend.tf
    prod/
      main.tf
      backend.tf
  policy/
    instance-type-allowlist.sentinel
  tests/
    app_monitoring.tftest.hcl

envs/prod/backend.tf — isolated, locked remote state

terraform {
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "platform/prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-terraform-locks"
    encrypt        = true
  }
}

envs/dev/backend.tf is identical except for key = "platform/dev/terraform.tfstate" — same bucket, same lock table, disjoint keys, so a lock held for a dev apply can never block or interfere with a prod apply, exactly module 01's locking model applied per-environment.

envs/prod/main.tf — composing the Level 2 modules per-environment

locals {
  environment = "prod"
  name_prefix = "acme-${local.environment}"
}

module "network" {
  source     = "../../modules/network"
  name       = local.name_prefix
  cidr_block = "10.2.0.0/16"
  az_count   = 3
}

module "app" {
  source            = "../../modules/app"
  name              = local.name_prefix
  vpc_id            = module.network.vpc_id
  subnet_ids        = module.network.public_subnet_ids
  instance_count    = 4
  enable_monitoring = true
}

output "vpc_id"           { value = module.network.vpc_id }
output "app_instance_ids" { value = module.app.instance_ids }

envs/dev/main.tf calls the identical modules with az_count = 2, instance_count = 1, enable_monitoring = false — the same directory-per-environment structure from module 06, now with each environment's state independently locked and encrypted.

policy/instance-type-allowlist.sentinel — enforced on every environment's plan

import "tfplan/v2" as tfplan

allowed_types = ["t3.micro", "t3.small", "t3.medium", "t3.large"]

main = rule {
  all tfplan.resource_changes as _, rc {
    rc.type != "aws_instance" or
    rc.change.after.instance_type in allowed_types
  }
}

Attached (in a real Terraform Cloud organization) as hard-mandatory to both the dev and prod workspaces — the same policy governs both environments, since the allowlist is an organizational rule, not an environment-specific one, directly reusing module 05's pattern.

tests/app_monitoring.tftest.hcl — regression-testing the conditional

mock_provider "aws" {}

run "dev_has_no_monitoring" {
  command = plan
  module {
    source = "../modules/app"
  }
  variables {
    name              = "acme-dev"
    vpc_id            = "vpc-mock"
    subnet_ids        = ["subnet-mock"]
    instance_count    = 1
    enable_monitoring = false
  }
  assert {
    condition     = length(aws_cloudwatch_metric_alarm.high_cpu) == 0
    error_message = "Dev should not create a monitoring alarm"
  }
}

run "prod_has_monitoring" {
  command = plan
  module {
    source = "../modules/app"
  }
  variables {
    name              = "acme-prod"
    vpc_id            = "vpc-mock"
    subnet_ids        = ["subnet-mock"]
    instance_count    = 4
    enable_monitoring = true
  }
  assert {
    condition     = length(aws_cloudwatch_metric_alarm.high_cpu) == 1
    error_message = "Prod should create a monitoring alarm"
  }
}

Testing modules/app directly (via the module { source = ... } block inside the test file) rather than a whole environment means this test suite runs in seconds against a mock provider and catches a regression in the monitoring conditional regardless of which environment's main.tf would have exercised it.

The full promotion workflow

cd platform
terraform test                        # fast, no cloud creds needed

cd envs/dev
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json               # or: policy check runs automatically in TFC
terraform apply tfplan

# ... validate dev ...

cd ../prod
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json
terraform apply tfplan

How It Actually Works

Every mechanism this capstone combines operates at a different stage of the same underlying plan/apply pipeline, which is exactly why they compose without interfering with each other. State locking (module 01) guards the read-plan-apply window; policy evaluation (module 05) inspects the completed plan's JSON before any ApplyResourceChange RPC fires; terraform test (module 04) runs the identical graph-walk machinery against an isolated, throwaway state and a mock provider. None of these share a code path with each other at the point they intervene, which is why adding a policy check doesn't slow down or change what terraform test verifies, and why per-environment state locking doesn't need any special-casing to also work correctly under a policy-gated Terraform Cloud workflow — each layer only ever sees the interface (a plan, a lock request, a mock RPC) appropriate to its own stage.

Per-environment backend keys sharing one DynamoDB lock table produce independent locks because the lock's identity is derived from the state path, not the table — module 01's compare-and-swap PutItem writes a lock record keyed by path (the S3 object key, here platform/dev/terraform.tfstate vs. platform/prod/terraform.tfstate), so dev and prod applying concurrently acquire two entirely distinct lock records in the same table with no contention between them at all — sharing the table is purely an operational convenience (one resource to provision and manage), not a source of cross-environment coupling.

The module { source = ... } block inside a .tftest.hcl file causes terraform test to build a graph rooted at that module directly, with the test's own variables block standing in for what a calling root configuration would normally supply — this is precisely why the same modules/app can be tested once, here, and reused unmodified by both envs/dev and envs/prod: the test's mock inputs and each environment's real inputs are just two different callers of the identical module contract (inputs in, outputs out) established back in Level 2 module 01, and neither caller has any way to observe which kind of caller the other is.

Exercise

Add a third environment, envs/staging, reusing the same modules/network and modules/app, with az_count = 2, instance_count = 2, and enable_monitoring = true — write its backend.tf (choosing an appropriate key) and its main.tf's module "app" block, then state which of the two existing tests/app_monitoring.tftest.hcl run blocks would need a new sibling to specifically cover staging's monitoring behavior, if any.