Provisioning AWS infrastructure is easy when there are only a few resources.
Open the AWS Console, create a VPC, add Subnets, configure Security Group, launch EC2 instance, etc.
The problem starts when the infrastructure grows.
A few manually created resources can quickly become dozens of resources with different configurations. One environment may have different network settings, another may contain a slightly different security rule.
Over time, the infrastructure becomes difficult to understand and even harder to reproduce.
The problem is not only how to create AWS resources, but how to structure them so the infrastructure does not become a mess.
This is where Terraform becomes valuable.
Instead of managing infrastructure through the AWS Console, we can define it as code, store it in Git, review changes, and reproduce the same architecture across environments.
In this project, we will build a small production style AWS setup using Terraform.
This project will include:
VPC
Public and private subnets
Internet gateway
Route tables
Security group
EC2 instance
IAM Role
S3 bucket
Terraform remote state
But the infrastructure itself is only part of the project.
The main goal is to apply several Terraform design principles that become increasingly important as infrastructure grows:
Modularization
Environment Separation
Variables Instead of Hardcoding
Outputs
State Management
The goal is simple:
Instead of managing AWS infrastructure manually, define it as reusable Terraform code that can be reviewed, reproduced, and maintained.
What We Are Building
Before writing Terraform code, let's define the architecture we want to build.
The final environment contains a VPC with public and private subnets. The EC2 instance runs in the public subnet so we can connect to it for validation.
The private subnet is included to demonstrate basic network separation and provide a foundation for expanding the architecture later if needed.
Final Architecture
The EC2 instance will be placed in the public subnet while the private subnet is included to demonstrate a basic network separation pattern
The network uses:
VPC 10.0.0.0/16
Public Subnet 10.0.1.0/24
Private Subnet 10.0.2.0/24
The EC2 instance is also associated with an IAM role so workloads can interact with AWS services without storing long lived AWS credentials on the server.
Terraform Architecture
The Terraform configuration is divided into three layers:
Bootstrap
The bootstrap directory is responsible for creating the resources required before the main Terraform environments can use remote state.
In this project, that means creating the S3 bucket used for Terraform state.
Environments
The environments directory contains environment specific configurations.
The environments are not responsible for implementing every AWS resource themselves. Instead, they will compose reusable modules.
For example, the dev environment can use:
The same modules can then be reused by the prod environment with different variables.
This allows the same infrastructure modules to be reused with different values for development and production.
Modules
The modules directory contains reusable infrastructure components.
Each module has a specific responsibility.
For example:
modules/vpc/
contains the logic for creating the VPC and its networking components.
Meanwhile:
modules/ec2/
contains the logic for creating the EC2 instance.
Project Structure
The module files follow Terraform conventional structure:
main.tf— resourcesvariables.tf— inputsoutputs.tf— values exposed for othe configurations
The environment directories additionally contain:
backend.tf— remote state configurationterraform.tfvars— environment values
The rest of the article will use the dev environment to provision the infrastructure.
The code snippets in this article focus on the important parts of the implementation. For the complete Terraform configuration you can find the full source code in the repository below.
GitHub Repository: https://github.com/muhammadyulasfipahrizal/terraform-setup
Configure Remote State
Before provisioning the AWS infrastructure, we need to solve one problem
Where should Terraform store its state?
Terraform uses a state file to keep track of the infrastructure it manages. By default, this state is stored locally as:
terraform.tfstate
For a small personal project, local state may be enough.
However, once infrastructure is shared between environments, machines, and engineers, relying on a local state file becomes difficult to manage.
A better approach is to store the state remotely in AWS S3.
In this project, we use a separate bootstrap configuration to create the S3 bucket that will later be used as the Terraform backend.
Bootstrap
The purpose of this configuration is to create the S3 bucket required by the Terraform environments.
The bootstrap configuration can be initialized and applied independently:
cd bootstrap
terraform init
terraform plan
terraform apply
After the apply completes, the S3 bucket required for remote state exists.
We can now configure the dev environment to use it.
Configure the Dev Backend
The dev environment contains a backend.tf file:
The backend configuration tells Terraform where its state should be stored.
terraform {
backend "s3" {
bucket = "terraform-right-way"
key = "dev/terraform.tfstate"
region = "ap-southeast-3"
}
}
The important part is the key:
key = "dev/terraform.tfstate"
This allows different environments to maintain separate state locations within the same bucket.
Initialize the Dev Environment
Now move into the dev environment:
cd environments/dev
Then run:
terraform init
Terraform reads backend.tf and configures the S3 backend.
If Terraform was previously using local state, it may ask whether the existing state should be migrated to the new backend.
For a new environment with no existing infrastructure, there is normally no local state to migrate.
After initialization, Terraform is ready to use the S3 backend.
Why Bootstrap Is Separate
There is a small dependency problem when using S3 as the Terraform backend:
We solve this by creating the state bucket through a separate bootstrap configuration.
VPC
The first infrastructure component is the VPC, which provides the network boundary for our AWS resources.
VPC 10.0.0.0/16
Public Subnet 10.0.1.0/24
Private Subnet 10.0.2.0/24
The module creates the VPC, subnets, Internet Gateway, and route tables.
The public subnet is associated with a route table that sends Internet bound traffic through the Internet Gateway. The private subnet does not have a direct Internet route.
The module exposes the values required by other parts of the infrastructure:
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_id" {
value = aws_subnet.public.id
}
output "private_subnet_id" {
value = aws_subnet.private.id
}
The dev environment then consumes the module:
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
public_subnet_cidr = var.public_subnet_cidr
private_subnet_cidr = var.private_subnet_cidr
availability_zone = var.availability_zone
}
The important design decision is that the CIDR ranges are variables, rather than being hardcoded directly into the environment's resources.
Create the Security Group
The Security Group will be attached to the EC2 instance and will define the allowed inbound traffic.
The main rule we need is SSH access to the EC2 instance.
Instead of hardcoding an IP address inside the module, the allowed SSH source is provided as a variable:
variable "allowed_ssh_cidr" {
description = "CIDR block allowed to access SSH"
type = string
}
The Security Group can then use that value for its SSH rule:
ingress {
description = "Allow SSH"
protocol = "tcp"
from_port = 22
to_port = 22
cidr_blocks = [var.allowed_ssh_cidr]
}
This is an important design decision.
We don't want the module to assume that SSH should always be accessible from a particular IP address. The module defines the rule, while the environment decides who should be allowed to use it.
For example, the dev environment can provide:
allowed_ssh_cidr = "YOUR_IP/32"
Using /32 limits SSH access to a single public IP address.
The Security Group also needs to allow outbound traffic so the EC2 instance can communicate with external services when required.
Once created, the module exposes the Security Group ID:
output "security_group_id" {
value = aws_security_group.this.id
}
The dev environment can then pass the output to the EC2 module:
module "ec2" {
source = "../../modules/ec2"
subnet_id = module.vpc.public_subnet_id
security_group_id = module.security_group.security_group_id
}
This creates another simple dependency between our modules without coupling their implementations.
Deploy EC2
The modules/ec2/ configuration provides the values required by the module, such as the AMI, instance type, subnet, Security Group, and SSH key.
The module then connects the EC2 instance to the infrastructure we created earlier:
module "ec2" {
source = "../../modules/ec2"
subnet_id = module.vpc.public_subnet_id
security_group_id = module.security_group.security_group_id
ami_id = var.ami_id
instance_type = var.instance_type
key_name = var.key_name
}
The important part here is how the dependencies are connected.
The EC2 instance does not need to know how the VPC or Security Group is implemented. It only consumes their outputs:
module.vpc.public_subnet_id
module.security_group.security_group_id
The actual EC2 resource remains inside the module:
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]
key_name = var.key_name
associate_public_ip_address = true
tags = {
Name = var.instance_name
}
}
The instance is placed in the public subnet and receives a public IP so that we can connect to it and validate the infrastructure after deployment.
The values that can vary between environments are kept outside the module.
For example:
instance_type = "t3.micro"
The same EC2 module can later be used by production with a different instance type without changing the module itself.
The module also exposes useful information through outputs:
output "instance_id" {
value = aws_instance.this.id
}
output "public_ip" {
value = aws_instance.this.public_ip
}
This allows Terraform to display the instance information after deployment and gives other parts of the configuration a clean interface to consume it.
Create the IAM Role
The EC2 instance may need to interact with other AWS services. A common but unsafe approach would be to store AWS access keys directly on the server.
Instead, AWS provides IAM roles for EC2, allowing applications running on the instance to obtain temporary credentials automatically.
The module creates an IAM role with an EC2 trust policy:
resource "aws_iam_role" "this" {
name = var.role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
The important part is the trust relationship:
This allows the EC2 service to assume the role on behalf of the instance.
The role is then associated with an instance profile:
resource "aws_iam_instance_profile" "this" {
name = var.instance_profile_name
role = aws_iam_role.this.name
}
The instance profile is what allows the IAM role to be attached to the EC2 instance.
The EC2 module can then receive the instance profile:
resource "aws_instance" "this" {
iam_instance_profile = var.iam_instance_profile
}
This keeps AWS credentials out of the server's configuration.
For this project, the role does not need broad permissions simply because the EC2 instance exists. Permissions should be added according to the actual AWS operations the workload requires.
Create the S3 Bucket
It is important to distinguish this bucket from the S3 bucket created during the bootstrap stage.
The bootstrap bucket is used by Terraform to store remote state, while this S3 bucket is part of the infrastructure managed by the dev environment.
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Name = var.bucket_name
}
}
The bucket name is provided by the environment rather than being hardcoded inside the module.
bucket_name = "dev-server-bucket"
The module exposes the bucket information through outputs:
output "bucket_id" {
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
The dev environment can then consume the module:
module "s3" {
source = "../../modules/s3"
bucket_name = var.bucket_name
}
This follows the same pattern used by the other infrastructure components: the module contains the implementation, while the environment provides the configuration.
Validate and Deploy
We have now defined the infrastructure as Terraform code. Before creating anything in AWS, we should validate the configuration and review the changes Terraform intends to make.
For this project, we will deploy the dev environment from:
environments/dev/
Format the Configuration
cd environments/dev
terraform fmt -recursive
This keeps the Terraform configuration consistently formatted.
Validate the Configuration
terraform validate
Terraform checks whether the configuration is syntactically valid and whether the configuration can be successfully loaded.
A successful validation should return:
Success! The configuration is valid.
Review the Execution Plan
terraform plan
Terraform compares the desired configuration with the current state and shows which resources it intends to create, change, or destroy.
For a new development environment, we should expect Terraform to plan the creation of our infrastructure.
This step is important because terraform plan gives us an opportunity to review the changes before they are applied.
Apply the Infrastructure
Once the plan looks correct, apply it:
terraform apply
Terraform will display the execution plan again and ask for confirmation.
Enter:
yes
Terraform will then create the resources defined by the dev environment.
Why This Structure Matters
At first, this structure may look more complicated than putting everything into one main.tf.
For a small project, that may be true.
The benefit becomes clearer when the infrastructure grows.
A new environment can reuse the existing modules:
dev → modules
prod → modules
Instead of duplicating the VPC, EC2, IAM, and S3 implementation, each environment provides its own configuration.
If the network implementation changes, we know where to look.
If production needs a larger EC2 instance, we change the production configuration rather than duplicating the EC2 module.
If another environment is introduced, it can reuse the existing modules.
Conclusion
Terraform is not difficult because creating an AWS resource is complicated.
The real challenge is managing infrastructure as it grows.
A single main.tf can work for a small experiment, but infrastructure becomes harder to maintain when environments, networking, compute, IAM, storage, and state management are all mixed together.
In this project, we applied five core concepts:
Modularization — infrastructure was divided into reusable modules for networking, security, compute, IAM, and storage instead of keeping everything in a single configuration.
Environment Separation — development and production have their own configurations while sharing the same reusable modules.
Variables Instead of Hardcoding — values such as CIDR ranges, instance types, AMIs, SSH access, and resource names are provided through variables.
Outputs — modules expose the values that other parts of the infrastructure need, allowing resources to be connected without tightly coupling their implementations.
State Management — Terraform state is stored remotely in S3, with separate state paths for each environment.
These concepts may seem unnecessary for a small infrastructure project. However, they become increasingly important as the number of resources and environments grows.
You can find the source code for this article in my github repository:








Top comments (0)