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 ChangeThe 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 instanceEach layer has its own state.
That gives us a useful production boundary:
Foundation state
│
└── network ownership
Application state
│
└── application ownershipThe 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.mdThe 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.tfstateThe 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.jsonRemember 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-foundationInitialize:
terraform initThen format and validate:
terraform fmt -recursive
terraform validateGenerate a plan:
terraform planReview the plan carefully before applying.
The apply command makes a real AWS change:
terraform applyConfirm the outputs:
terraform outputInspect the state:
terraform state listYou should see the network module’s resources represented in the foundation state.
Initialize And Validate The Application
Change into the application root:
cd ../production-applicationInitialize:
terraform initThen:
terraform fmt -recursive
terraform validate
terraform planThe plan should show the application resources and their relationship to the foundation state.
Apply only after reviewing the plan:
terraform applyThen:
terraform output
terraform state listAt this point the architecture is:
Foundation state
│
│ VPC + subnet outputs
▼
Application state
│
└── EC2 + security groupThis 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 behaviorThe 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 reviewThe 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 planDo 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 planDo 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 applyThis is the central production workflow:
Configuration change
↓
Plan
↓
Review
↓
Approval
↓
ApplyDemonstrate 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.smallNow return to the application root and run:
terraform planTerraform 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 reconciledIf the manual change was intentional:
Drift detected
↓
Approve new behavior
↓
Update Terraform configuration
↓
terraform plan
↓
Apply if requiredThe second path is important. Otherwise the next Terraform run can attempt to undo the intentional change.
Inspect Production State
Use:
terraform state listto see what the application state manages.
Then inspect a resource:
terraform state show module.application.aws_instance.thisYou can also inspect the Terraform outputs:
terraform outputThese 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
↓
ApplyThe 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 destroyThen destroy the foundation:
cd ../production-foundation
terraform destroyDestroying 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 TerraformThe 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
DisciplineThat is the difference between knowing Terraform commands and operating Terraform as production infrastructure.