Building a Multi-Environment Terraform Architecture on AWS
Github repo: https://github.com/HashimZaffar/Asignment-1
In modern DevOps, infrastructure is no longer managed only by clicking around in the AWS Console. Infrastructure is now written as code, reviewed through Git, reused through modules, and deployed consistently across multiple environments. This approach is called Infrastructure as Code, and Terraform is one of the most widely used tools for it.
In this assignment, I designed a multi-environment Terraform architecture for AWS. The goal was to create reusable Terraform code that can deploy separate environments such as dev, staging, and prod while keeping each environment isolated, configurable, and professionally structured.
This project is suitable for a junior to intermediate DevOps, Cloud DevOps, DevSecOps, Platform Engineering, or SRE portfolio because it demonstrates real infrastructure practices, not just basic Terraform syntax.
Project Objective
The objective of this assignment was to design Terraform code that meets the following requirements:
Use reusable Terraform modules.
Create separate environments for dev, staging, and prod.
Use separate Terraform state files for each environment.
Store Terraform state remotely in an S3 bucket.
Use DynamoDB for Terraform state locking.
Use either Terraform workspaces or a folder-based environment strategy.
Implement a consistent tagging strategy.
Use environment-specific variables.
For this project, I used a folder-based strategy because it is easier to understand, easier to review, and commonly used in production-style Terraform repositories.
What This Project Deploys
Each environment can deploy its own basic AWS infrastructure, including a VPC, public subnet, internet gateway, route table, security group, and EC2 instance.
The purpose of this project is not to create a very complex production platform. The purpose is to demonstrate how Terraform should be structured properly when supporting multiple environments.
Each environment uses the same reusable modules, but with different variable values.
For example, the dev environment may use smaller resources, while staging and prod may use different CIDR ranges, instance types, or access rules.
Project Setup
I started the project on an AWS EC2 instance running Ubuntu 24.04.
The base folder name for the project was:
Asignment-1
I created the project folder and initialized Git:
mkdir Asignment-1
cd Asignment-1
git init
Using Git is important because Terraform code should be treated like application code. It should be version-controlled, reviewed, and improved over time.
Final Project Structure
The project follows this structure:
Asignment-1/
├── README.md
├── .gitignore
├── modules/
│ ├── network/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── security_group/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ec2/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── dev/
│ ├── backend.tf
│ ├── provider.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── outputs.tf
├── staging/
│ ├── backend.tf
│ ├── provider.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── outputs.tf
└── prod/
├── backend.tf
├── provider.tf
├── main.tf
├── variables.tf
├── terraform.tfvars
└── outputs.tf
This structure separates reusable infrastructure code from environment-specific configuration.
The modules folder contains reusable building blocks.
The environments folder contains separate configurations for dev, staging, and prod.
Why I Used a Folder-Based Strategy
Terraform supports workspaces, but for this assignment I used a folder-based strategy.
In a folder-based strategy, each environment has its own directory:
environments/dev
environments/staging
environments/prod
This makes the project easier to understand because each environment has its own backend file, variable file, and state path.
For beginners, this is much clearer than using workspaces because the separation is visible in the repository structure.
It also reduces confusion when reviewing code because the dev, staging, and prod configurations are clearly separated.
Understanding Terraform Modules
A Terraform module is reusable Terraform code.
Instead of writing the same VPC, subnet, security group, and EC2 configuration three times, I created reusable modules and called them from each environment.
For example, the network module creates the VPC, subnet, internet gateway, route table, and route table association.
The security group module creates firewall rules for SSH and HTTP access.
The EC2 module creates the virtual machine.
This approach avoids duplication and makes the code easier to maintain.
Network Module
The network module is responsible for creating basic networking resources.
It creates:
VPC
Public subnet
Internet gateway
Public route table
Route table association
Example:
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.common_tags, {
Name = "${var.environment}-vpc"
})
}
This creates a VPC using the CIDR block provided by the environment.
For example, dev may use:
vpc_cidr = "10.10.0.0/16"
Staging may use:
vpc_cidr = "10.20.0.0/16"
Prod may use:
vpc_cidr = "10.30.0.0/16"
This keeps each environment isolated at the network level.
Security Group Module
The security group module controls traffic to the EC2 instance.
It allows SSH traffic on port 22 and HTTP traffic on port 80.
Example:
resource "aws_security_group" "this" {
name = "${var.environment}-web-sg"
description = "Security group for ${var.environment} EC2 instance"
vpc_id = var.vpc_id
ingress {
description = "Allow SSH from trusted IP range"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_allowed_cidr]
}
ingress {
description = "Allow HTTP traffic"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = var.http_allowed_cidrs
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(var.common_tags, {
Name = "${var.environment}-web-sg"
})
}
From a DevSecOps perspective, SSH should not be open to the whole internet in a real production environment. For learning, 0.0.0.0/0 may be used temporarily, but in a professional setup it should be restricted to a trusted IP range.
EC2 Module
The EC2 module creates the compute instance.
Example:
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [var.security_group_id]
tags = merge(var.common_tags, {
Name = "${var.environment}-web-server"
})
}
The EC2 module does not hardcode the AMI ID, instance type, subnet ID, or security group ID. These values are passed as variables.
This makes the module reusable across different environments.
Environment-Specific Variables
Each environment has its own terraform.tfvars file.
For example, the dev environment may use:
aws_region = "us-east-1"
environment = "dev"
vpc_cidr = "10.10.0.0/16"
public_subnet_cidr = "10.10.1.0/24"
availability_zone = "us-east-1a"
ami_id = "ami-020cba7c55df1f615"
instance_type = "t2.micro"
ssh_allowed_cidr = "0.0.0.0/0"
common_tags = {
Project = "Asignment-1"
Environment = "dev"
Owner = "Muhammad Virk"
ManagedBy = "Terraform"
CostCenter = "Learning"
}
The staging environment can use a different VPC range and instance type:
environment = "staging"
vpc_cidr = "10.20.0.0/16"
public_subnet_cidr = "10.20.1.0/24"
instance_type = "t3.micro"
The prod environment can use another separate range:
environment = "prod"
vpc_cidr = "10.30.0.0/16"
public_subnet_cidr = "10.30.1.0/24"
instance_type = "t3.micro"
This is the main benefit of reusable Terraform code. The same module can be used across environments, but each environment can behave differently.
Remote Backend with S3
Terraform state is very important. It tracks the infrastructure Terraform manages.
If Terraform creates an EC2 instance, the state file records that resource. The next time Terraform runs, it compares the code with the state file and decides what needs to be created, updated, or destroyed.
In a team or professional environment, storing state locally is risky. If the local machine is lost, the state may be lost. If multiple people run Terraform from different machines, they may create conflicts.
To solve this, I used an S3 remote backend.
Each environment has its own backend configuration.
Example for dev:
terraform {
backend "s3" {
bucket = "asignment-1-terraform-state-34-238-244-58"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "asignment-1-terraform-locks"
encrypt = true
}
}
The important part is the key.
For dev:
key = "dev/terraform.tfstate"
For staging:
key = "staging/terraform.tfstate"
For prod:
key = "prod/terraform.tfstate"
This means each environment has a separate state file.
Why Separate State Files Matter
Separate state files reduce the risk of accidental changes across environments.
Dev state only tracks dev resources.
Staging state only tracks staging resources.
Prod state only tracks prod resources.
This is very important because production infrastructure should not be affected by changes made while testing in dev.
For example, if I run Terraform inside the dev folder, Terraform only uses the dev backend and dev state file. It does not touch staging or prod state.
This is a key safety principle in infrastructure management.
DynamoDB State Locking
State locking prevents two Terraform operations from modifying the same state file at the same time.
For example, if two engineers run terraform apply against the same environment at the same time, they could corrupt the state or create unexpected infrastructure changes.
DynamoDB locking helps prevent this by allowing only one operation to hold the lock at a time.
For this project, I created a DynamoDB table called:
asignment-1-terraform-locks
The table uses LockID as the primary key.
This is a common Terraform backend locking pattern used in AWS-based infrastructure projects.
Creating Backend Resources
Before using the remote backend, I created the S3 bucket and DynamoDB table.
Create the S3 bucket:
aws s3api create-bucket \
--bucket asignment-1-terraform-state-34-238-244-58 \
--region us-east-1
Enable versioning:
aws s3api put-bucket-versioning \
--bucket asignment-1-terraform-state-34-238-244-58 \
--versioning-configuration Status=Enabled
Enable encryption:
aws s3api put-bucket-encryption \
--bucket asignment-1-terraform-state-34-238-244-58 \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}
]
}'
Create the DynamoDB table:
aws dynamodb create-table \
--table-name asignment-1-terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
This gives Terraform a remote backend for state storage and locking.
Provider Configuration
Each environment has a provider configuration.
Example:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = var.common_tags
}
}
The provider tells Terraform that it will manage AWS resources.
The default_tags block helps apply common tags across supported AWS resources.
This improves consistency and makes resources easier to track.
Tagging Strategy
A good tagging strategy is important in cloud environments.
Tags help with cost tracking, ownership, governance, automation, and auditing.
In this project, I used the following common tags:
common_tags = {
Project = "Asignment-1"
Environment = "dev"
Owner = "Muhammad Virk"
ManagedBy = "Terraform"
CostCenter = "Learning"
}
The Environment tag changes depending on the environment.
For dev:
Environment = "dev"
For staging:
Environment = "staging"
For prod:
Environment = "prod"
This makes it easy to identify which resources belong to which environment.
Calling Modules from an Environment
Each environment calls the reusable modules.
Example:
module "network" {
source = "../../modules/network"
environment = var.environment
vpc_cidr = var.vpc_cidr
public_subnet_cidr = var.public_subnet_cidr
availability_zone = var.availability_zone
common_tags = var.common_tags
}
module "security_group" {
source = "../../modules/security_group"
environment = var.environment
vpc_id = module.network.vpc_id
ssh_allowed_cidr = var.ssh_allowed_cidr
http_allowed_cidrs = ["0.0.0.0/0"]
common_tags = var.common_tags
}
module "ec2" {
source = "../../modules/ec2"
environment = var.environment
ami_id = var.ami_id
instance_type = var.instance_type
subnet_id = module.network.public_subnet_id
security_group_id = module.security_group.security_group_id
common_tags = var.common_tags
}
The network module creates the VPC and subnet.
The security group module uses the VPC ID from the network module.
The EC2 module uses the subnet ID from the network module and the security group ID from the security group module.
This shows how Terraform modules can pass values between each other using outputs.
Outputs
Outputs show useful information after Terraform creates infrastructure.
Example:
output "vpc_id" {
value = module.network.vpc_id
}
output "ec2_instance_id" {
value = module.ec2.instance_id
}
output "ec2_public_ip" {
value = module.ec2.public_ip
}
After running terraform apply, Terraform can display important values such as the VPC ID, EC2 instance ID, and public IP address.
Outputs are useful for verification, documentation, and connecting infrastructure components.
Terraform Workflow
The standard Terraform workflow is:
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
terraform init initializes the working directory and downloads provider plugins.
terraform fmt formats Terraform files.
terraform validate checks whether the configuration is valid.
terraform plan previews what Terraform will create, update, or delete.
terraform apply applies the changes and creates the infrastructure.
For the dev environment:
cd environments/dev
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
For staging:
cd environments/staging
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
For prod:
cd environments/prod
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
Each environment is deployed separately.
Why This Architecture Is Useful
This architecture is useful because it follows important DevOps principles.
It separates environments clearly.
It avoids code duplication through modules.
It stores state remotely instead of locally.
It uses locking to reduce the risk of state corruption.
It allows each environment to have different variables.
It applies a consistent tagging strategy.
It supports Git-based infrastructure workflows.
These are the same ideas used in real infrastructure repositories.
DevSecOps Considerations
From a DevSecOps perspective, this project can be improved further by adding stronger security controls.
For example, SSH access should be restricted to a trusted IP address instead of being open to the internet.
The S3 backend bucket should have versioning and encryption enabled.
Public access to the state bucket should be blocked.
IAM permissions should follow least privilege.
Terraform code can later be scanned using tools such as Checkov, tfsec, or Trivy.
Secrets should not be committed to Git.
Production environments should have stricter controls than dev environments.
Even though this assignment is beginner-friendly, these security considerations make the project more professional.
Cleanup
After testing, resources should be destroyed to avoid unnecessary AWS charges.
Destroy dev:
cd environments/dev
terraform destroy
Destroy staging:
cd environments/staging
terraform destroy
Destroy prod:
cd environments/prod
terraform destroy
It is important not to delete the S3 backend bucket before destroying the infrastructure, because Terraform needs the state files to know which resources it manages.
What I Learned
This assignment helped me understand that Terraform is not only about creating cloud resources. The real value of Terraform is in how the code is structured, reused, secured, and operated.
I learned how to separate infrastructure into reusable modules, how to manage different environments, how remote state works, why state locking matters, and how variables make Terraform flexible.
I also learned that good Terraform architecture should be predictable, repeatable, and safe.
Portfolio Value
This project demonstrates practical skills that are relevant to DevOps and cloud engineering roles in 2026.
It shows understanding of Infrastructure as Code, AWS fundamentals, Terraform modules, remote state management, environment separation, tagging, and basic DevSecOps practices.
For a beginner, this is a strong foundation. For a junior to intermediate portfolio, it shows that the project is structured with real-world engineering practices in mind.
Conclusion
Building a multi-environment Terraform architecture is an important step in learning professional DevOps. It teaches how infrastructure should be organized when multiple environments are involved.
The key lesson from this assignment is simple: infrastructure should be reusable, version-controlled, environment-aware, and safely managed.
By using Terraform modules, separate state files, S3 remote backend, DynamoDB locking, environment-specific variables, and consistent tags, this project provides a strong foundation for building more advanced cloud infrastructure projects in the future.
Comments
Join the discussionNo comments yet. Be the first to comment.