Skip to content

Capstone Project


A production Terraform project is not defined by how many cloud resources it creates. It is defined by how safely a team can understand, review, test, change, and recover the infrastructure it manages.

This capstone brings the course together into one small AWS-oriented production simulation. The infrastructure is intentionally modest, but the workflow is not:

Local Terraform Project
        ↓
Git Repository
        ↓
Modules + Environment Structure
        ↓
Remote State
        ↓
Validation + Testing
        ↓
Pull Request
        ↓
Terraform Plan
        ↓
Review / Approval
        ↓
Terraform Apply
        ↓
Production Infrastructure
        ↓
Drift Detection
        ↓
Controlled Change

The example uses AWS because the course has already covered AWS remote-state architecture. The exact cloud resources are kept small enough to understand without turning the capstone into an AWS architecture course.

Warning

This example creates real AWS resources and can incur charges. The example uses a small VPC and a small EC2 instance, but even small cloud resources can cost money. Destroy the environment when you finish experimenting.

The Architecture

We will use two Terraform layers:

production-foundation
        │
        └── VPC + subnet

production-application
        │
        └── EC2 instance

Each layer has its own state.

That gives us a useful production boundary:

Foundation state
    │
    └── network ownership

Application state
    │
    └── application ownership

The application consumes the network information produced by the foundation.

The repository will look like:

terraform-production-example/
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── application/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
│
├── environments/
│   ├── production-foundation/
│   │   ├── backend.tf
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   ├── providers.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   │
│   └── production-application/
│       ├── backend.tf
│       ├── main.tf
│       ├── outputs.tf
│       ├── providers.tf
│       ├── variables.tf
│       └── terraform.tfvars
│
├── tests/
│   └── application.tftest.hcl
│
├── .gitignore
└── README.md

The important idea is that modules provide reusable implementation, while the environment directories are actual deployment roots.

Build The Modules

Network Module

Create modules/network/main.tf:

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = var.name
    Environment = var.environment
  }
}

resource "aws_subnet" "this" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.subnet_cidr
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.name}-subnet"
    Environment = var.environment
  }
}

Create modules/network/variables.tf:

variable "name" {
  type = string
}

variable "environment" {
  type = string
}

variable "vpc_cidr" {
  type = string
}

variable "subnet_cidr" {
  type = string
}

Create modules/network/outputs.tf:

output "vpc_id" {
  value = aws_vpc.this.id
}

output "subnet_id" {
  value = aws_subnet.this.id
}

This module deliberately does not create a complicated network stack. It gives us a VPC and subnet so the application layer has something meaningful to consume.

Application Module

Create modules/application/main.tf:

resource "aws_security_group" "this" {
  name   = "${var.name}-sg"
  vpc_id = var.vpc_id

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

  tags = {
    Name        = "${var.name}-sg"
    Environment = var.environment
  }
}

resource "aws_instance" "this" {
  ami                         = var.ami_id
  instance_type               = var.instance_type
  subnet_id                   = var.subnet_id
  vpc_security_group_ids     = [aws_security_group.this.id]
  associate_public_ip_address = true

  tags = {
    Name        = var.name
    Environment = var.environment
  }
}

Create modules/application/variables.tf:

variable "name" {
  type = string
}

variable "environment" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "subnet_id" {
  type = string
}

variable "ami_id" {
  type = string
}

variable "instance_type" {
  type = string
}

Create modules/application/outputs.tf:

output "instance_id" {
  value = aws_instance.this.id
}

output "public_ip" {
  value = aws_instance.this.public_ip
}

The security group allows outbound traffic but does not open inbound ports. That keeps the example smaller and avoids exposing a demonstration server unnecessarily.

Create The Production Foundation

The foundation has its own state.

Create environments/production-foundation/backend.tf:

terraform {
  backend "s3" {
    bucket = "REPLACE-WITH-YOUR-TERRAFORM-STATE-BUCKET"
    key    = "production/foundation/terraform.tfstate"
    region = "us-east-1"
  }
}

The bucket name genuinely depends on your AWS environment, so this is one value you must supply. Create the bucket using the AWS architecture you established earlier in the course, with encryption and restricted access.

Warning

Do not put a state bucket name from somebody else’s environment into your project. The backend points to real infrastructure and should be chosen deliberately.

Create environments/production-foundation/providers.tf:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Create environments/production-foundation/variables.tf:

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "environment" {
  type    = string
  default = "production"
}

variable "vpc_cidr" {
  type    = string
  default = "10.20.0.0/16"
}

variable "subnet_cidr" {
  type    = string
  default = "10.20.1.0/24"
}

Create environments/production-foundation/main.tf:

module "network" {
  source = "../../modules/network"

  name        = "devstuffs-production"
  environment = var.environment
  vpc_cidr    = var.vpc_cidr
  subnet_cidr = var.subnet_cidr
}

Create environments/production-foundation/outputs.tf:

output "vpc_id" {
  value = module.network.vpc_id
}

output "subnet_id" {
  value = module.network.subnet_id
}

Create environments/production-foundation/terraform.tfvars:

aws_region  = "us-east-1"
environment = "production"
vpc_cidr    = "10.20.0.0/16"
subnet_cidr = "10.20.1.0/24"

The module source is local in this capstone:

source = "../../modules/network"

In a larger organization, the module could instead be versioned and consumed from a registry or another controlled module source.

Create The Production Application

Create environments/production-application/backend.tf:

terraform {
  backend "s3" {
    bucket = "REPLACE-WITH-YOUR-TERRAFORM-STATE-BUCKET"
    key    = "production/application/terraform.tfstate"
    region = "us-east-1"
  }
}

Notice the different state key:

production/foundation/terraform.tfstate
production/application/terraform.tfstate

The application and foundation therefore do not share one state file.

Create environments/production-application/providers.tf:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Create environments/production-application/variables.tf:

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "environment" {
  type    = string
  default = "production"
}

variable "ami_id" {
  type        = string
  description = "AMI ID for the production demonstration instance."
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

The AMI is intentionally an input because AMI IDs are region-specific and change over time. Rather than hard-code an obsolete AMI identifier, provide a current AMI ID appropriate for the selected region.

Create environments/production-application/main.tf:

data "terraform_remote_state" "foundation" {
  backend = "s3"

  config = {
    bucket = "REPLACE-WITH-YOUR-TERRAFORM-STATE-BUCKET"
    key    = "production/foundation/terraform.tfstate"
    region = var.aws_region
  }
}

module "application" {
  source = "../../modules/application"

  name          = "devstuffs-production-app"
  environment   = var.environment
  vpc_id        = data.terraform_remote_state.foundation.outputs.vpc_id
  subnet_id     = data.terraform_remote_state.foundation.outputs.subnet_id
  ami_id        = var.ami_id
  instance_type = var.instance_type
}

Create environments/production-application/outputs.tf:

output "instance_id" {
  value = module.application.instance_id
}

output "public_ip" {
  value = module.application.public_ip
}

Create environments/production-application/terraform.tfvars:

aws_region    = "us-east-1"
environment   = "production"
ami_id        = "REPLACE-WITH-A-CURRENT-AMI-ID"
instance_type = "t3.micro"

Again, the AMI ID genuinely depends on the AWS region and current image catalog. Obtain a current suitable AMI ID for us-east-1 rather than copying an old identifier from a tutorial.

Git Hygiene

Create .gitignore at the repository root:

.terraform/
*.tfstate
*.tfstate.*
*.tfvars
*.tfvars.json
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Remember to commit .terraform.lock.hcl after Terraform creates it.

If your team wants to commit non-secret .tfvars files, refine the ignore rule rather than blindly committing all variable files.

Initialize And Validate The Foundation

Change into the foundation root:

cd environments/production-foundation

Initialize:

terraform init

Then format and validate:

terraform fmt -recursive
terraform validate

Generate a plan:

terraform plan

Review the plan carefully before applying.

The apply command makes a real AWS change:

terraform apply

Confirm the outputs:

terraform output

Inspect the state:

terraform state list

You should see the network module’s resources represented in the foundation state.

Initialize And Validate The Application

Change into the application root:

cd ../production-application

Initialize:

terraform init

Then:

terraform fmt -recursive
terraform validate
terraform plan

The plan should show the application resources and their relationship to the foundation state.

Apply only after reviewing the plan:

terraform apply

Then:

terraform output
terraform state list

At this point the architecture is:

Foundation state
      │
      │ VPC + subnet outputs
      ▼
Application state
      │
      └── EC2 + security group

This is a small example of state isolation and cross-stack dependency management.

Add Terraform Testing

The production roots can be validated with the same layered approach used throughout the course.

A simple module test can protect application behavior without creating a real EC2 instance.

Create tests/application.tftest.hcl:

run "application_plan" {
  command = plan

  variables {
    name          = "test-application"
    environment   = "test"
    vpc_id        = "vpc-test"
    subnet_id     = "subnet-test"
    ami_id        = "ami-test"
    instance_type = "t3.micro"
  }

  assert {
    condition     = aws_security_group.this.name == "test-application-sg"
    error_message = "The application security group name is incorrect."
  }

  assert {
    condition     = aws_instance.this.instance_type == "t3.micro"
    error_message = "The application should use the expected test instance type."
  }
}

The test belongs with the module it tests in a real project. For clarity, this capstone keeps the test path visible at the repository level; if you execute it directly, move the test into the application module’s tests/ directory or otherwise run it against that module’s test context.

The conceptual pipeline remains:

Module
  ↓
terraform test
  ↓
Expected resource behavior

The test is not a replacement for a real deployment verification.

Build The CI Validation Flow

The capstone’s CI process should reflect the production workflow rather than simply running terraform apply automatically.

A conceptual CI sequence is:

Pull Request
     ↓
terraform fmt -check
     ↓
terraform init
     ↓
terraform validate
     ↓
terraform test
     ↓
terraform plan
     ↓
Human review

The plan should be treated as an artifact for review, while credentials and state remain protected.

Production apply should occur only after the appropriate approval boundary.

A minimal CI command sequence is:

terraform fmt -check -recursive
terraform init
terraform validate
terraform test
terraform plan

Do not put long-lived AWS access keys directly into the workflow file. Use the short-lived identity mechanism covered in the security chapter where your CI platform and AWS setup support it.

A Controlled Change

Suppose the production application currently uses:

instance_type = "t3.micro"

A controlled change might change it to another small instance type appropriate for your AWS account.

Edit:

instance_type = "t3.small"

Then run:

terraform plan

Do not immediately apply.

Review:

What resource changes?
Is replacement required?
Is the change expected?
What will it cost?
Does the application tolerate the change?

If the plan is acceptable:

terraform apply

This is the central production workflow:

Configuration change
      ↓
Plan
      ↓
Review
      ↓
Approval
      ↓
Apply

Demonstrate Drift

Drift is best demonstrated with a real provider-managed attribute.

After the EC2 instance exists, an authorized operator can change a supported attribute outside Terraform. For example, if the selected instance type is changed through the AWS console, Terraform’s next plan can detect the difference.

The important production workflow is:

Terraform configuration
        │
        ├── t3.micro
        │
        ▼
AWS instance
        │
        └── manually changed
              ↓
           t3.small

Now return to the application root and run:

terraform plan

Terraform should identify the difference if the changed attribute is managed and detectable by the provider.

The response depends on the resource and attribute, so do not assume every manual change will produce exactly the same plan.

The important lesson is to read the plan rather than blindly applying it.

Decide How To Reconcile Drift

If the manual change was accidental:

Drift detected
      ↓
Review plan
      ↓
Terraform configuration remains authoritative
      ↓
terraform apply
      ↓
Infrastructure reconciled

If the manual change was intentional:

Drift detected
      ↓
Approve new behavior
      ↓
Update Terraform configuration
      ↓
terraform plan
      ↓
Apply if required

The second path is important. Otherwise the next Terraform run can attempt to undo the intentional change.

Inspect Production State

Use:

terraform state list

to see what the application state manages.

Then inspect a resource:

terraform state show module.application.aws_instance.this

You can also inspect the Terraform outputs:

terraform output

These commands are useful during incident investigation, but remember that state itself is sensitive.

Do not paste complete production state into tickets, chat rooms, or public issue trackers.

Refactoring Safely

Suppose the application resource eventually needs to move into another module.

Do not casually rename resource addresses and accept a plan that destroys and recreates production infrastructure.

The safe workflow covered earlier in the course is:

Current address
      ↓
Plan
      ↓
Identify intended move
      ↓
Move/refactor resource address
      ↓
Plan
      ↓
Confirm no unintended destroy/create
      ↓
Apply

The exact state-move mechanism depends on the refactoring being performed, but the production rule is simple:

A refactor should change Terraform’s representation without accidentally changing the real infrastructure.

That is why plan review is essential during structural changes.

Cleanup

This example creates real AWS resources.

When you are finished, destroy the application first:

cd environments/production-application
terraform destroy

Then destroy the foundation:

cd ../production-foundation
terraform destroy

Destroying the application before the foundation respects the architecture’s dependency direction.

Finally, confirm that the example resources are gone.

Warning

Destroying the Terraform resources does not necessarily mean every related AWS resource or manually created backend resource is removed. Review your AWS environment and state backend separately.

The Production Mental Model

The project combines the course’s major ideas:

Configuration
    +
Modules
    +
State
    +
Providers
    +
Validation
    +
Security
    +
CI/CD
    +
Testing
    +
Drift Detection
    +
Operational Discipline
    =
Production Terraform

The important shift is that Terraform production work is not just writing .tf files.

You are designing:

  • what Terraform owns
  • where state lives
  • who can access that state
  • which identities can make changes
  • how modules are versioned
  • how changes are tested
  • how plans are reviewed
  • how production changes are approved
  • how drift is detected
  • how failures are investigated
  • how infrastructure is safely refactored

A small infrastructure stack can therefore demonstrate production-grade Terraform practices more effectively than a giant architecture with dozens of unrelated services.

The final mental model is:

                 ┌───────────────┐
                 │ Configuration │
                 └───────┬───────┘
                         │
                         ▼
                 ┌───────────────┐
                 │    Modules    │
                 └───────┬───────┘
                         │
                         ▼
                 ┌───────────────┐
                 │     State     │
                 └───────┬───────┘
                         │
                         ▼
                 ┌───────────────┐
                 │   Providers   │
                 └───────┬───────┘
                         │
                         ▼
                 ┌───────────────┐
                 │ Infrastructure│
                 └───────┬───────┘
                         │
            ┌────────────┼────────────┐
            ▼            ▼            ▼
        Validation    Testing     Drift checks
            │            │            │
            └────────────┼────────────┘
                         ▼
                  Operational
                   Discipline

That is the difference between knowing Terraform commands and operating Terraform as production infrastructure.

Last updated on