Automate AWS Infrastructure with Terraform

Provision a full VPC, EC2 web server, and S3 bucket using Terraform IaC.

Introduction

30 Second Summary

Every time a company needs a new server, database, or network, someone has to set it up. Clicking through a cloud console works once, but doing it repeatedly across environments is slow, error-prone, and impossible to track.

In this project, you will write Terraform code that automatically provisions a full AWS networking environment, deploys a web server, and creates storage. All from your terminal, all repeatable, all version-controlled.

What You'll Build

A complete Infrastructure as Code project that provisions a custom VPC with public and private subnets, an EC2 instance running nginx, and an S3 bucket with versioning enabled.

By the end of this project, you'll have:

  • A professional Terraform project with 7 organized files that provisions a full VPC networking layer across two Availability Zones.
  • A running nginx web server on EC2 that you can visit in your browser, deployed entirely through code.
  • A GitHub repository demonstrating AWS networking and IAM knowledge in a structure used by real DevOps teams.
  • Secret Mission: Implement environment-aware resource tagging for cost tracking and create multi-environment variable files.

Are there any prerequisites?

You need an AWS Free Tier account, the AWS CLI installed, and VS Code with Git Bash set up on your machine. You should also have basic familiarity with terraform init, terraform plan, terraform apply, and terraform destroy.

Install Terraform and Configure AWS Credentials

Before you can automate AWS infrastructure, your tools need to talk to each other. Terraform needs to know which cloud provider to target, and AWS needs to trust that commands are coming from your account.

In this step, you will verify that Terraform and the AWS CLI are properly connected, then write the configuration files that tell Terraform how to talk to AWS.

In this step, get ready to:
  • Verify your Terraform and AWS CLI installations.
  • Write the Terraform provider configuration.
  • Declare project variables and set their values.
Verify your Terraform and AWS setup

Your Terraform CLI and AWS CLI are installed and configured. Let's confirm both tools are working correctly before writing any infrastructure code.

  • In Git Bash, navigate to your project directory by running this command:
cd ~/Desktop/terraform-aws-infra
  • Check that Terraform is accessible by running:
terraform -version

You should see output starting with Terraform v1.15.6 followed by your platform information.

  • Verify your AWS credentials are working by running:
aws sts get-caller-identity

You should see a JSON response containing your UserId, Account (a 12-digit number), and Arn.

Seeing an error from either command?

If terraform -version returns "command not found", confirm that the folder containing terraform.exe (e.g., C:\terraform) is in your system PATH.

If aws sts get-caller-identity returns a credentials error, run aws configure again and re-enter your Access Key ID and Secret Access Key.

Help me troubleshoot my Terraform or AWS CLI setup

Write the provider configuration

Every Terraform project starts with a provider configuration. This file tells Terraform which cloud platform to connect to and which version of the provider plugin to download.

  • In VS Code, open providers.tf from the terraform-aws-infra folder in the Explorer sidebar.
  • Add the following code to providers.tf:
# Pin the minimum Terraform version and declare the AWS provider
terraform {
  required_version = ">= 1.0"

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

# Configure the AWS provider to use the region from our variables
provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.0" ensures anyone running this project has at least Terraform 1.0 installed.
  • required_providers tells Terraform to download the AWS plugin from HashiCorp's registry. The ~> 6.0 constraint allows any 6.x version but not 7.0.
  • provider "aws" sets the region using var.aws_region. This keeps the value flexible instead of hardcoded.
  • Save providers.tf.

Seeing red squiggles in VS Code?

VS Code may not recognize .tf files by default. This does not affect Terraform. The code will work correctly when you run it from the terminal.

Help me fix syntax highlighting for Terraform files in VS Code

✔️ Awesome, I've got everything!

Great. Make sure you have saved providers.tf before continuing.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.0"

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

provider "aws" {
  region = var.aws_region
}
Declare your project variables

Terraform uses variables to keep your configuration flexible. Instead of hardcoding values like region names or CIDR blocks, you declare variables in one file and set their values in another.

  • Open variables.tf in VS Code and add the following variable declarations:
# The AWS region where all resources will be created
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

# CIDR block that defines the IP address range for the VPC
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

# Name prefix used across all resource tags
variable "project_name" {
  description = "Project name used for resource naming and tagging"
  type        = string
  default     = "devops-project"
}

# Environment label for tagging (dev, staging, prod)
variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  default     = "dev"
}

What does this code do?

Each variable block declares an input that Terraform accepts. The description documents its purpose, type enforces the data type, and default provides a fallback value if none is explicitly set.

You will reference these variables throughout your Terraform files using the var. prefix (like var.aws_region in providers.tf).

  • Save variables.tf.

Now you need to provide the actual values. Terraform reads a file called terraform.tfvars automatically and uses it to fill in your declared variables.

  • Open terraform.tfvars in VS Code and add the following values:
aws_region   = "us-east-1"
vpc_cidr     = "10.0.0.0/16"
project_name = "devops-project"
environment  = "dev"

Why a separate .tfvars file?

The variables.tf file declares what inputs exist and their types. The terraform.tfvars file sets the actual values for this specific environment.

This separation means you can reuse the same Terraform code for different environments (dev, staging, prod) just by swapping the .tfvars file. You will see this in action during the Secret Mission.

  • Save terraform.tfvars.
  • Confirm all three files are saved by checking for the dot indicator on each tab in VS Code. No dot means the file is saved.

Not sure if you typed something wrong?

Double check that terraform.tfvars uses the exact variable names from variables.tf. A mismatch (like region instead of aws_region) causes Terraform to ignore the value silently.

Help me check my Terraform variables configuration

✔️ Awesome, I've got everything!

Great. Confirm all three files are saved before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.0"

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

provider "aws" {
  region = var.aws_region
}
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "project_name" {
  description = "Project name used for resource naming and tagging"
  type        = string
  default     = "devops-project"
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  default     = "dev"
}
aws_region   = "us-east-1"
vpc_cidr     = "10.0.0.0/16"
project_name = "devops-project"
environment  = "dev"

Your project is now configured to connect to AWS. Next up, you will write the networking layer that creates a full VPC with public and private subnets across multiple Availability Zones.

Create the VPC Networking Layer

Your Terraform project has a provider configured and variables declared. Now it's time to write the actual infrastructure code that provisions real AWS resources.

In this step, you'll define a complete VPC networking layer in code. This includes the VPC itself, four subnets spread across two Availability Zones, an Internet Gateway, and route tables that connect your public subnets to the internet.

In this step, get ready to:
  • Write the VPC and subnet configuration in main.tf.
  • Add an Internet Gateway and route tables for public internet access.
  • Initialize Terraform, preview the plan, and apply the infrastructure to AWS.
Write the VPC and subnet configuration

A VPC is your own isolated network inside AWS. You carve it into smaller pieces called subnets, each placed in a specific Availability Zone for resilience.

  • In VS Code, open main.tf from the Explorer sidebar.
  • Add the data source and VPC resource by pasting this code:
data "aws_availability_zones" "available" {
  state = "available"
}

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

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

The data "aws_availability_zones" block queries AWS for the currently available AZs in your region. This avoids hardcoding zone names like us-east-1a.

The aws_vpc resource creates a VPC with the CIDR block from your variable (10.0.0.0/16). DNS support is enabled so that instances inside the VPC can resolve domain names.

  • Below the VPC resource, add all four subnets by pasting this code:
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-public-1"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-public-2"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.3.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name        = "${var.project_name}-private-1"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "private_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.4.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name        = "${var.project_name}-private-2"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

You're creating 2 public subnets and 2 private subnets. Public subnets have map_public_ip_on_launch = true, which automatically assigns a public IP to any instance launched in them. Private subnets don't get this setting.

Each pair of subnets is placed in a different Availability Zone (names[0] and names[1]). This gives you resilience if one data center has an outage.

You won't see a change yet. The next chunk adds routing so your subnets can actually reach the internet.

Add the Internet Gateway and route tables

Subnets alone can't reach the internet. An Internet Gateway connects your VPC to the public internet, and a route table tells traffic where to go.

  • Below the subnet resources in main.tf, add the Internet Gateway, route table, and route table associations by pasting this code:
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-igw"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.project_name}-public-rt"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

The Internet Gateway is the door between your VPC and the public internet. The route table has a route saying "any traffic going to 0.0.0.0/0 (anywhere) should go through the Internet Gateway."

The two aws_route_table_association resources link the public subnets to this route table. Private subnets are intentionally left without internet routing.

  • Save main.tf.

You won't see a change yet. In the next substep, you'll initialize Terraform and see all 9 resources in the plan output.

Seeing red squiggly lines in VS Code?

VS Code may show syntax warnings for .tf files if you don't have a Terraform extension installed. These are cosmetic. The code will still work when you run terraform plan in the next substep.

help me debug red squiggly lines in my Terraform files in VS Code

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_availability_zones" "available" {
  state = "available"
}

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

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-public-1"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-public-2"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.3.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name        = "${var.project_name}-private-1"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "private_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.4.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name        = "${var.project_name}-private-2"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-igw"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.project_name}-public-rt"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}
Initialize, plan, and apply the infrastructure

Terraform needs to download the AWS provider plugin before it can create resources. Then you'll preview the plan and apply it.

  • In your Git Bash terminal, initialize Terraform by running this command:
terraform init

What should I see?

Terraform downloads the AWS provider plugin and creates a .terraform/ directory in your project folder. You should see a message ending with "Terraform has been successfully initialized!"

Seeing a provider error?

Make sure your providers.tf has the correct source (hashicorp/aws) and version constraint (~> 6.0). Also confirm your internet connection is active since Terraform needs to download the plugin.

help me fix my terraform init error

  • Preview what Terraform will create by running this command:
terraform plan

What should I see?

The plan output lists every resource Terraform will create, marked with a + symbol. At the bottom, you should see a summary line:

Plan: 9 to add, 0 to change, 0 to destroy.

That's 1 VPC + 4 subnets + 1 Internet Gateway + 1 route table + 2 route table associations.

  • Apply the infrastructure to your AWS account by running this command:
terraform apply
  • Terraform will show the plan again and ask for confirmation. Type yes and press Enter.

What should I see?

Terraform creates each resource one by one. After about 30-60 seconds, you should see a green success message saying "Apply complete! Resources: 9 added, 0 changed, 0 destroyed."

  • Open the AWS Management Console in your browser.
  • In the search bar at the top, type VPC and select VPC from the results.
  • Click Your VPCs in the left sidebar.
  • Confirm the region in the top-right corner says US East (N. Virginia) us-east-1.
  • You should see a VPC named devops-project-vpc with IPv4 CIDR 10.0.0.0/16.
  • Click Subnets in the left sidebar and verify all 4 subnets appear (devops-project-public-1, devops-project-public-2, devops-project-private-1, devops-project-private-2).

Terraform apply failed?

If you see an "Invalid CIDR block" error, double check the CIDR values in your subnets match exactly: 10.0.1.0/24, 10.0.2.0/24, 10.0.3.0/24, 10.0.4.0/24.

If you see an "UnauthorizedOperation" error, verify your AWS credentials are configured correctly by running aws sts get-caller-identity in Git Bash.

help me fix my terraform apply error

Why individual resources instead of a VPC module?

The Terraform community offers a pre-built VPC module that can create all of this in a few lines. However, using individual resources forces you to understand how each piece connects.

Interviewers at DevOps roles test your understanding of Internet Gateways, route tables, and subnet associations directly. The module abstracts that knowledge away.

Your VPC networking layer is live. Next up, you'll add security groups and an IAM role so your future EC2 instance has controlled access to traffic and AWS services.

Configure Security Groups and IAM Role

Your VPC network is live, but right now nothing can connect to it. Security groups act as virtual firewalls that control which traffic is allowed in and out of your resources.

In this step, you will create a security group that allows web traffic to reach your server. You will also set up an IAM role so your EC2 instance can securely read from S3 without hardcoded credentials.

In this step, get ready to:
  • Create a security group with HTTP and SSH inbound rules.
  • Configure an IAM role that grants EC2 read-only access to S3.
  • Apply the security configuration to your AWS account.
Write the security group configuration

A security group defines which ports are open and who can connect. Your web server needs port 80 open for HTTP traffic and port 22 for SSH access.

  • In VS Code, open security.tf and add the following security group and traffic rules:
resource "aws_security_group" "web_sg" {
  name        = "${var.project_name}-web-sg"
  description = "Security group for web server"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-web-sg"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# Allow inbound HTTP traffic on port 80 from anywhere
resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

# Allow inbound SSH traffic on port 22 for remote access
resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

# Allow all outbound traffic so the instance can reach the internet
resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What does this code do?

The aws_security_group resource creates the firewall itself and attaches it to your VPC. The three rule resources define the actual traffic policies.

  • The http ingress rule opens port 80 so browsers can reach your web server.
  • The ssh ingress rule opens port 22 so you can connect remotely for debugging.
  • The all_outbound egress rule allows the instance to make outbound connections (for updates, downloads, etc.).
  • Setting ip_protocol = "-1" means all protocols are allowed outbound.
  • Save security.tf.

You won't see a change yet. The next chunk adds the IAM role to the same file, and then you will apply everything together.

Write the IAM role configuration

An IAM role lets an EC2 instance access other AWS services without storing credentials on the machine. You will create a role that grants read-only access to S3 buckets.

  • In security.tf, add the following IAM configuration below the egress rule you just wrote:
# Define which AWS service is allowed to assume this role
data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

# Create the IAM role that EC2 instances will use
resource "aws_iam_role" "ec2_role" {
  name               = "${var.project_name}-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json

  tags = {
    Name        = "${var.project_name}-ec2-role"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# Attach the AWS-managed S3 read-only policy to the role
resource "aws_iam_role_policy_attachment" "s3_read_only" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

# Wrap the role in an instance profile so EC2 can use it
resource "aws_iam_instance_profile" "ec2_profile" {
  name = "${var.project_name}-ec2-profile"
  role = aws_iam_role.ec2_role.name
}

What does this code do?

This creates a chain of trust between EC2 and S3.

  • The aws_iam_policy_document data source defines that only the EC2 service can assume this role.
  • The aws_iam_role creates the actual role with that trust policy.
  • The aws_iam_role_policy_attachment attaches the AWS-managed AmazonS3ReadOnlyAccess policy, giving the role permission to read S3 objects.
  • The aws_iam_instance_profile wraps the role so it can be attached to an EC2 instance. EC2 cannot use a role directly. It needs an instance profile as a bridge.

Why least-privilege access?

In production, you give each service the minimum permissions it needs. Your EC2 instance only needs to read S3 objects, so you attach a read-only policy rather than full S3 access. This limits the blast radius if the instance is ever compromised.

  • Save security.tf.

You won't see a change yet. The next substep runs terraform apply to create all these resources in AWS.

✔️ Awesome, I've got everything!

Great. Double check you have saved security.tf before moving on to the apply step.

ⓧ I'd like to double check the full code

Here is the complete security.tf file with both the security group and IAM configuration:

resource "aws_security_group" "web_sg" {
  name        = "${var.project_name}-web-sg"
  description = "Security group for web server"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-web-sg"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.web_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ec2_role" {
  name               = "${var.project_name}-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json

  tags = {
    Name        = "${var.project_name}-ec2-role"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_iam_role_policy_attachment" "s3_read_only" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

resource "aws_iam_instance_profile" "ec2_profile" {
  name = "${var.project_name}-ec2-profile"
  role = aws_iam_role.ec2_role.name
}
Plan and apply the security resources

Now that your security configuration is written, you will preview the changes and deploy them to AWS.

  • Preview the new resources by running this command in Git Bash:
terraform plan

What should I see?

The plan output should show 7 resources to add: 1 security group, 2 ingress rules, 1 egress rule, 1 IAM role, 1 role policy attachment, and 1 instance profile. Look for the line that says Plan: 7 to add, 0 to change, 0 to destroy. at the bottom.

  • Apply the changes by running:
terraform apply
  • Terraform will show the plan again and ask for confirmation. Type yes and press Enter.
  • Wait for the apply to complete. You should see Apply complete! Resources: 7 added, 0 changed, 0 destroyed. in the output.
  • In the AWS Console, navigate to EC2 then select Security Groups in the left sidebar.
  • Confirm that devops-project-web-sg appears in the list.
  • Click on the security group, then select the Inbound rules tab. You should see rules for port 80 (HTTP) and port 22 (SSH).
  • Navigate to IAM then select Roles in the left sidebar.
  • Search for devops-project-ec2-role and confirm it exists with AmazonS3ReadOnlyAccess attached under Permissions policies.

Seeing an error during apply?

  • If you see a syntax error, check that every opening brace { in security.tf has a matching closing brace }. Missing braces are the most common HCL mistake.
  • If you see a reference error mentioning aws_vpc.main.id, make sure your main.tf file still has the VPC resource from the previous step.
  • If you see an IAM permissions error, verify your AWS CLI credentials are still valid by running aws sts get-caller-identity in Git Bash.

Help me troubleshoot my terraform apply error for security groups and IAM

Your network is now locked down with proper access rules, and your EC2 instance has a secure identity for accessing S3. Next up, you will deploy the actual web server that uses these security controls.

Deploy EC2 Web Server with User Data

Your VPC network is built and secured. Now it is time to launch the actual compute resource that will serve web traffic.

In this step, you will deploy an EC2 instance running nginx into your public subnet. Terraform will automatically find the latest Amazon Linux 2023 AMI, launch the instance with your security group and IAM role, and run a startup script that installs the web server.

In this step, get ready to:
  • Write an EC2 configuration that dynamically finds the latest AMI and launches a web server.
  • Define Terraform outputs to surface key resource information.
  • Apply the configuration and verify your web server is live in a browser.
Write the AMI data source in ec2.tf

Hardcoding an AMI ID would tie your configuration to a single region and a single point in time. A data source lets Terraform query AWS for the latest matching image every time you run terraform apply.

  • Open ec2.tf in VS Code and add the following data source:
data "aws_ami" "amazon_linux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

What does this code do?

This data source queries AWS for AMIs matching two filters. The name filter uses a wildcard pattern to match any Amazon Linux 2023 x86_64 image. The most_recent = true flag ensures Terraform picks the newest release.

Because you are filtering by owners = ["amazon"], only official Amazon-published images are considered. This prevents accidentally launching a community or marketplace AMI.

  • Save ec2.tf.

You will not see a visible change yet. The next chunk uses this data source to launch the instance.

Write the EC2 instance resource

The instance resource ties together everything you have built so far: the public subnet for internet access, the security group for traffic rules, and the IAM instance profile for S3 permissions.

  • In ec2.tf, add the following resource below the data source you just wrote:
resource "aws_instance" "web" {
  ami                         = data.aws_ami.amazon_linux_2023.id
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public_1.id
  vpc_security_group_ids      = [aws_security_group.web_sg.id]
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  associate_public_ip_address = true

  user_data = <<-EOF
              #!/bin/bash
              dnf update -y
              dnf install -y nginx
              systemctl start nginx
              systemctl enable nginx
              echo "<h1>Deployed via Terraform - ${var.project_name}</h1>" > /usr/share/nginx/html/index.html
              EOF

  tags = {
    Name        = "${var.project_name}-web-server"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

The aws_instance resource launches a t3.micro (Free Tier eligible) EC2 instance. It references the AMI data source, your first public subnet, and the security group and IAM profile from the previous step.

The user_data block is a bash script that runs once when the instance first boots. It installs nginx, starts the web server, and writes a custom HTML page so you can confirm the deployment worked.

Setting associate_public_ip_address = true gives the instance a public IP so you can reach it from your browser.

  • Save ec2.tf.

You will not see a change yet. You need to define outputs and run terraform apply to create the instance.

Seeing a syntax error in your editor?

Make sure the <<-EOF heredoc block is indented consistently and that EOF sits on its own line with no trailing spaces.

Help me fix a syntax error in my ec2.tf user_data heredoc block.

✔️ Awesome, I've got everything!

Great. Double check you have saved ec2.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_ami" "amazon_linux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "web" {
  ami                         = data.aws_ami.amazon_linux_2023.id
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public_1.id
  vpc_security_group_ids      = [aws_security_group.web_sg.id]
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  associate_public_ip_address = true

  user_data = <<-EOF
              #!/bin/bash
              dnf update -y
              dnf install -y nginx
              systemctl start nginx
              systemctl enable nginx
              echo "<h1>Deployed via Terraform - ${var.project_name}</h1>" > /usr/share/nginx/html/index.html
              EOF

  tags = {
    Name        = "${var.project_name}-web-server"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}
Define Terraform outputs

Outputs let you surface important values after terraform apply completes. Instead of digging through the AWS Console, you can read the EC2 public IP directly from your terminal.

  • Open outputs.tf in VS Code and add the following output definitions:
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}

output "ec2_public_ip" {
  description = "Public IP address of the web server"
  value       = aws_instance.web.public_ip
}

output "ec2_instance_id" {
  description = "Instance ID of the web server"
  value       = aws_instance.web.id
}

output "s3_bucket_name" {
  description = "Name of the S3 artifacts bucket"
  value       = aws_s3_bucket.artifacts.bucket
}

What does this code do?

Each output block tells Terraform to print that value after every apply. The ec2_public_ip output is the one you will use to visit your web server in a browser.

The s3_bucket_name output references a resource you have not created yet. You will need to comment it out temporarily so terraform apply succeeds. You will uncomment it in the next step after creating the S3 bucket.

  • Comment out the s3_bucket_name output block by adding # at the start of each of its four lines.
  • Save outputs.tf.

Why comment instead of delete?

Leaving the output commented keeps it visible as a reminder. In the next step, you will create the S3 bucket and simply remove the comment characters to activate this output.

Apply and verify in your browser

With the EC2 configuration complete, you can now provision the instance. The user_data script needs a minute or two to install nginx after the instance launches.

  • Deploy the EC2 instance by running this command in Git Bash:
terraform apply
  • Terraform will show a plan with new resources to create. Type yes and press Enter to confirm.

What should I see?

Terraform will plan to add 2 new resources (the data source lookup and the EC2 instance). After you confirm, it takes 30-60 seconds to launch the instance. When complete, you will see the ec2_public_ip output printed with a public IP address.

  • Wait 1-2 minutes for the user_data script to finish installing nginx.
  • Copy the ec2_public_ip value from the terminal output.
  • Open your browser and paste the IP address into the address bar (use http://, not https://).

You should see a page displaying "Deployed via Terraform - devops-project" in large text.

Page not loading in your browser?

  • Make sure you are using http:// and not https://. Your security group only allows port 80 (HTTP).
  • Wait another minute. The user_data script needs time to run dnf update and install nginx.
  • Verify the security group in the AWS Console allows inbound HTTP on port 80 from 0.0.0.0/0.

Help me troubleshoot why my EC2 web server is not responding on the public IP address.

  • In the AWS Console, navigate to EC2 then Instances.
  • Confirm you see an instance named devops-project-web-server in the Running state.
  • Click the instance and check the Security tab to verify devops-project-ec2-profile is attached as the IAM instance profile.

Your web server is live and reachable from the internet. Next up, you will add an S3 bucket with versioning and push everything to GitHub.

Create S3 Bucket and Push to GitHub

Your web server is live and responding to HTTP requests. The last piece of infrastructure is an S3 bucket for storing project artifacts like deployment packages and logs.

In this step, you will create an S3 bucket with versioning enabled, apply it through Terraform, and then push your entire project to GitHub so you have a portfolio-ready repository.

In this step, get ready to:
  • Write the S3 bucket configuration with versioning and a unique name.
  • Apply the infrastructure changes and verify in the AWS Console.
  • Commit all Terraform files and push to GitHub.
Write the S3 bucket configuration

S3 bucket names must be globally unique across all AWS accounts. To guarantee uniqueness without hardcoding a name, you will use Terraform's random_id resource to generate a short hex suffix that gets appended to the bucket name automatically.

  • In VS Code, open s3.tf and paste in the following configuration:
resource "random_id" "bucket_suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "artifacts" {
  # Use project name + random suffix for a globally unique bucket name
  bucket        = "${var.project_name}-artifacts-${random_id.bucket_suffix.hex}"
  force_destroy = true

  tags = {
    Name        = "${var.project_name}-artifacts"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "artifacts" {
  # Enable versioning so previous versions of objects are preserved
  bucket = aws_s3_bucket.artifacts.id

  versioning_configuration {
    status = "Enabled"
  }
}

What does this code do?

This file creates three resources that work together:

  • random_id generates a 4-byte random hex string (e.g. a3f2c1b4) to make the bucket name unique.
  • aws_s3_bucket creates the bucket with force_destroy = true so Terraform can delete it later even if it contains objects.
  • aws_s3_bucket_versioning enables versioning on the bucket. This is a production best practice that preserves previous versions of any object you upload.

The random_id resource comes from the hashicorp/random provider, which you need to register in your provider configuration. This is a separate step from the AWS provider you already have.

  • Open providers.tf and find the required_providers block. It currently looks like this:
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
  • Add the random provider below the aws block so the full required_providers section looks like this:
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }

Why a separate provider?

Terraform providers are plugins that manage different types of resources. The hashicorp/random provider handles random value generation, while hashicorp/aws handles AWS resources. Declaring both tells Terraform to download both plugins during initialization.

  • Save both s3.tf and providers.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete s3.tf:

resource "random_id" "bucket_suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "artifacts" {
  bucket        = "${var.project_name}-artifacts-${random_id.bucket_suffix.hex}"
  force_destroy = true

  tags = {
    Name        = "${var.project_name}-artifacts"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id

  versioning_configuration {
    status = "Enabled"
  }
}

And the complete providers.tf:

terraform {
  required_version = ">= 1.0"

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

provider "aws" {
  region = var.aws_region
}
Initialize, apply, and verify in the Console

Because you added a new provider, Terraform needs to download it before it can plan or apply. The -upgrade flag tells Terraform to re-evaluate all providers and download any new ones that match your version constraints.

  • In your Git Bash terminal, run this command to reinitialize with the new provider:
terraform init -upgrade

You should see output confirming that hashicorp/random was installed alongside the existing AWS provider.

Seeing a provider error?

Make sure you saved providers.tf before running the command. Terraform reads from the file on disk, not the unsaved editor buffer.

help me fix a provider not found error after terraform init -upgrade

  • Apply the new S3 resources by running:
terraform apply
  • Terraform will show 3 new resources to add (random_id, S3 bucket, bucket versioning). Type yes and press Enter to confirm.

Once the apply completes, you should see the s3_bucket_name output now populated with a value like devops-project-artifacts-a3f2c1b4.

  • Switch to the AWS Console in your browser.
  • Search for S3 in the top search bar and open the S3 service.
  • Confirm your bucket (starting with devops-project-artifacts-) appears in the bucket list.
  • Click the bucket name to open it.
  • Click the Properties tab.
  • Scroll down to Bucket Versioning and confirm the status shows Enabled.

Bucket not showing up?

Make sure you are viewing the us-east-1 region in the Console (check the region selector in the top right). S3 buckets are listed globally, but if terraform apply failed, the bucket was never created.

help me troubleshoot why my S3 bucket is not appearing after terraform apply

  • Back in Git Bash, confirm all outputs are populated by running:
terraform output

You should see all 5 outputs listed: vpc_id, public_subnet_ids, ec2_public_ip, ec2_instance_id, and s3_bucket_name.

Commit and push to GitHub

Your infrastructure is fully deployed. The final step is to version-control all your Terraform files and push them to a GitHub repository so you have a clean, shareable record of the project.

  • Stage and commit all your files by running these commands:
git add .
git commit -m "feat: initial AWS infrastructure with VPC, EC2, and S3"

What does this commit message mean?

The feat: prefix follows conventional commit format, which is a standard in DevOps teams. It signals that this commit adds a new feature rather than fixing a bug or updating docs.

  • Open GitHub in your browser and create a new repository. Name it terraform-aws-infra (or any name you prefer).
  • Leave the repository empty (do not initialize with a README or .gitignore).
  • Copy the repository URL that GitHub shows you after creation.
  • Back in Git Bash, connect your local repo to GitHub and push by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/terraform-aws-infra.git"]]
git push -u origin main

What if Git asks for authentication?

GitHub may prompt you to authenticate. If you see a browser pop-up or a credential prompt, sign in with your GitHub account. If you use a personal access token instead of a password, paste it when prompted for the password.

  • Refresh your GitHub repository page in the browser. You should see all 7 .tf files plus terraform.tfvars listed in the repository.

Push failed or rejected?

If you see a "rejected" error, make sure you created an empty repository on GitHub (no README or .gitignore). If you accidentally initialized it, delete the repo on GitHub and create a fresh empty one.

help me fix a git push rejected error when pushing to a new GitHub repo

Secret mission

Environment Tagging and Multi-Environment Variables

Real companies tag every resource for cost tracking and run identical infrastructure across dev, staging, and prod environments. Can you implement a locals block for consistent tagging and create a multi-environment variable file?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The EC2 instance consumes your AWS Free Tier credits while it runs, so deleting is recommended once you have verified everything works and pushed to GitHub.

Cost warning

Your t3.micro EC2 instance uses credits at approximately $0.0104/hour. That works out to about $0.25/day.

If you are done with the project, run terraform destroy to stop all charges immediately.

Resources you used:

  • AWS VPC with 4 subnets, 1 Internet Gateway, and 1 route table (free).
  • 1 EC2 t3.micro instance running nginx (consumes credits while running).
  • 1 S3 bucket with versioning enabled (free at this scale).
  • 1 IAM role and instance profile (free).
  • 1 security group with ingress and egress rules (free).
  • Local terraform-aws-infra project folder on your Desktop.
  • GitHub repository containing your Terraform files.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the nginx server accessible for demonstration purposes.

  • Your EC2 instance will continue consuming credits at approximately $0.25/day.
  • All other resources (VPC, subnets, IGW, route tables, security groups, IAM role, S3 bucket) are free.
  • Your Terraform state file tracks the current infrastructure, so you can still manage it with terraform plan and terraform apply later.
  • Terraform CLI remains installed at C:\terraform on your machine.

Pause - I'll come back to this later

Stop the EC2 instance to stop credit usage while keeping your infrastructure definition and state file intact.

  • In the AWS Console, navigate to EC2 > Instances.
  • Select the devops-project-web-server instance.
  • Click Instance state > Stop instance.
  • A stopped instance does not consume compute credits. Your public IP address may change when you start it again.
  • When you are ready to resume, select the instance and click Instance state > Start instance. Then run terraform refresh to update your state file with the new public IP.

Delete - I don't want to use this again

Remove all AWS resources and local project files. This is the recommended option once you have verified your infrastructure works and pushed your code to GitHub.

  • In Git Bash, make sure you are inside the terraform-aws-infra directory.
  • Destroy all AWS resources by running:
terraform destroy
  • Terraform will show a plan of all resources to be destroyed. Type yes and press Enter to confirm.
  • Wait for the destroy to complete. The S3 bucket will be emptied automatically because the configuration includes force_destroy = true.
  • Verify the destroy succeeded by checking the AWS Console. Your VPC, EC2 instance, and S3 bucket should no longer appear.
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf terraform-aws-infra
  • If you also want to delete the GitHub repository, go to your repository page on GitHub.
  • Click Settings (the tab at the top of the repository page).
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Follow the confirmation prompts by typing the repository name and confirming.

Nice Work!

Nice Work!

Nice work! You just built and deployed a complete AWS infrastructure entirely through code using Terraform.

You've learned how to:

  • Built a complete VPC with public and private subnets across two Availability Zones, including an Internet Gateway and route table associations.
  • Deployed a running nginx web server on EC2, fully automated through Infrastructure as Code with proper IAM roles and security groups.
  • Pushed a portfolio-ready GitHub repository with professional file organization (providers.tf, variables.tf, main.tf, security.tf, ec2.tf, s3.tf, outputs.tf) matching real-world Terraform projects.
  • Secret Mission: Implemented environment-aware tagging with a locals block for cost tracking and created multi-environment variable files.

Ready to quiz yourself?