DEV Community

Mo Rizal
Mo Rizal

Posted on

Building AWS Infrastructure The Right Way With Terraform

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:

  1. VPC

  2. Public and private subnets

  3. Internet gateway

  4. Route tables

  5. Security group

  6. EC2 instance

  7. IAM Role

  8. S3 bucket

  9. 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:

  1. Modularization

  2. Environment Separation

  3. Variables Instead of Hardcoding

  4. Outputs

  5. 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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

contains the logic for creating the VPC and its networking components.

Meanwhile:

modules/ec2/
Enter fullscreen mode Exit fullscreen mode

contains the logic for creating the EC2 instance.

Project Structure

The module files follow Terraform conventional structure:

The environment directories additionally contain:

  • backend.tf — remote state configuration

  • terraform.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

The important part is the key:

key = "dev/terraform.tfstate"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then run:

terraform init
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

The bucket name is provided by the environment rather than being hardcoded inside the module.

bucket_name = "dev-server-bucket"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

The dev environment can then consume the module:

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

  bucket_name = var.bucket_name
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This keeps the Terraform configuration consistently formatted.

Validate the Configuration

terraform validate
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Review the Execution Plan

terraform plan
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Terraform will display the execution plan again and ask for confirmation.

Enter:

yes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Instead of duplicating the VPC, EC2, IAM, and S3 implementation, each environment provides its own configuration.

  1. If the network implementation changes, we know where to look.

  2. If production needs a larger EC2 instance, we change the production configuration rather than duplicating the EC2 module.

  3. 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:

  1. Modularization — infrastructure was divided into reusable modules for networking, security, compute, IAM, and storage instead of keeping everything in a single configuration.

  2. Environment Separation — development and production have their own configurations while sharing the same reusable modules.

  3. Variables Instead of Hardcoding — values such as CIDR ranges, instance types, AMIs, SSH access, and resource names are provided through variables.

  4. Outputs — modules expose the values that other parts of the infrastructure need, allowing resources to be connected without tightly coupling their implementations.

  5. 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:

Github Repository

Top comments (0)