Multi-Environment AWS Infrastructure

Deploy parameterized AWS infrastructure across dev and prod with Terraform.

Introduction

30 Second Summary

Think about how companies manage hundreds of servers across testing and production environments. They don't click through a console for each one. They write code that describes their infrastructure, and a tool deploys it automatically.

In this project, you will write Terraform configuration that deploys a full AWS networking and compute stack. You'll use different variable files to deploy the same infrastructure to dev and prod environments, exactly how platform engineering teams work at real companies.

What You'll Build

A production-style Terraform project that provisions a VPC, subnet, internet gateway, security group, and EC2 web server on AWS, parameterized for multi-environment deployment.

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

  • A reusable Terraform configuration with proper file structure (providers.tf, variables.tf, main.tf, outputs.tf) that deploys networking and compute resources to AWS.
  • A deployed EC2 web server accessible via public IP, with a production tagging strategy applied to every resource.
  • Environment-specific .tfvars files for dev and prod that deploy distinct environments from the same codebase.
  • Secret Mission: A GitHub Actions CI/CD pipeline that validates Terraform formatting and syntax on every pull request.

Are there any prerequisites?

You'll need an AWS free tier account with the AWS CLI configured, Terraform CLI installed, a GitHub account, and VS Code.

Set Up Your Terraform Project Structure

Every Terraform project follows a standard file structure. Each file has a specific responsibility: one for provider configuration, one for variables, one for resource definitions, and one for outputs.

In this step, you'll create your project directory, set up this file structure, and initialize Terraform so it can download the AWS provider plugin.

In this step, get ready to:
  • Create the project directory and standard Terraform files.
  • Write the providers.tf configuration to connect Terraform to AWS.
  • Run terraform init to download the AWS provider plugin.
Create your project directory and files

Terraform automatically loads and merges all .tf files in the current directory. Splitting configuration across multiple files keeps things organized as your project grows.

  • Open your terminal.

How to open your terminal

Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or PowerShell (Windows) and press Enter.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-multi-env
cd terraform-multi-env
  • Create the six Terraform files your project needs by running this command:

macOS

touch providers.tf variables.tf main.tf outputs.tf dev.tfvars prod.tfvars

Windows

New-Item providers.tf, variables.tf, main.tf, outputs.tf, dev.tfvars, prod.tfvars
  • Confirm the files exist by running:
ls

You should see providers.tf, variables.tf, main.tf, outputs.tf, dev.tfvars, and prod.tfvars in the output.

What is each file for?

  • providers.tf tells Terraform which cloud provider to use (AWS in our case).
  • variables.tf defines the input parameters your configuration accepts.
  • main.tf holds the actual infrastructure resources you want to create.
  • outputs.tf defines values Terraform displays after deploying (like an IP address).
  • dev.tfvars and prod.tfvars store environment-specific values so you can reuse the same code for different environments.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the terraform-multi-env folder, then click Open.

You should see all six files listed in the Explorer sidebar on the left.

Don't see the files in VS Code?

Make sure you opened the terraform-multi-env folder itself, not the Desktop folder. The Explorer sidebar should show the six .tf and .tfvars files at the top level.

Help me find my terraform-multi-env folder in VS Code.

Write the providers configuration

The providers configuration tells Terraform two things: which version of Terraform is required, and which cloud provider plugin to download.

  • In VS Code, click providers.tf in the Explorer sidebar to open it.
  • Paste the following configuration into providers.tf:
terraform {
  required_version = ">= 1.2"

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

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.2" ensures anyone running this project has at least Terraform 1.2 installed.
  • The required_providers block tells Terraform to download the official AWS provider from HashiCorp's registry, pinned to any version in the 6.x range.
  • The provider "aws" block configures the AWS plugin. The region comes from a variable you'll define in the next step.
  • Save providers.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete providers.tf file:

terraform {
  required_version = ">= 1.2"

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

provider "aws" {
  region = var.aws_region
}
Initialize the project with terraform init

Terraform needs to download the AWS provider plugin before it can do anything. The terraform init command reads your providers configuration and fetches the required plugins.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, below Escape).
  • Initialize your Terraform project by running this command:
terraform init

You should see output ending with Terraform has been successfully initialized! along with a message about the installed provider version.

What did terraform init do?

It created a hidden .terraform/ directory containing the downloaded AWS provider plugin. It also created a .terraform.lock.hcl file that locks the exact provider version for reproducibility.

Seeing an error during init?

If you see Error: Inconsistent dependency lock file, delete the .terraform.lock.hcl file and run terraform init again.

If you see Error: Failed to query available provider packages, check your internet connection. Terraform needs to reach the HashiCorp registry to download the provider.

Help me fix my terraform init error.

Your project structure is ready and Terraform can communicate with AWS. Next up, you'll define the variables that let you deploy the same infrastructure to different environments.

Define Variables and Environment Configuration

Your Terraform project is initialized and ready to go. The next challenge is making your configuration flexible enough to deploy to multiple environments without duplicating code.

Terraform uses variables to parameterize your configuration. You define a variable once, then pass different values for dev or prod through separate .tfvars files.

In this step, get ready to:
  • Define input variables for region, environment, networking, and compute settings.
  • Write a dev.tfvars file with development-specific values.
  • Write a prod.tfvars file with production-specific values.
Define your input variables

Input variables are the knobs and dials of your Terraform configuration. Each variable you declare can be assigned a different value per environment, keeping your infrastructure code DRY (Don't Repeat Yourself).

  • Open variables.tf in VS Code and paste the following code:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name (dev, prod)"
  type        = string
}

variable "project_name" {
  description = "Name of the project"
  type        = string
  default     = "terraform-multi-env"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "public_subnet_cidr" {
  description = "CIDR block for the public subnet"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

What does this code do?

Each variable block declares an input your configuration can accept. The type attribute constrains what values are valid, and default provides a fallback when no value is explicitly passed.

  • aws_region and project_name have defaults, so they are optional in your .tfvars files.
  • environment, vpc_cidr, and public_subnet_cidr have no defaults. Terraform will require you to provide these every time you run a command.
  • instance_type defaults to t2.micro, which is AWS Free Tier eligible.
  • Save variables.tf.
  • Confirm your syntax is valid by running this command in your terminal:
terraform validate

You should see Success! The configuration is valid.

Seeing a validation error?

  • Check that every opening brace { has a matching closing brace }.
  • Make sure all string values like "us-east-1" are wrapped in double quotes.
  • Verify you saved the file before running the command.

Help me fix my Terraform validation error

✔️ Awesome, I've got everything!

Great. Double check you saved your file and move on to the next chunk.

ⓧ I'd like to double check the full code

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

variable "environment" {
  description = "Environment name (dev, prod)"
  type        = string
}

variable "project_name" {
  description = "Name of the project"
  type        = string
  default     = "terraform-multi-env"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "public_subnet_cidr" {
  description = "CIDR block for the public subnet"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}
Configure the dev environment

A .tfvars file assigns concrete values to your variables for a specific environment. This is how teams deploy the same infrastructure code to dev and prod without changing a single line of HCL.

  • Open dev.tfvars in VS Code and paste the following:
environment        = "dev"
project_name       = "terraform-multi-env"
aws_region         = "us-east-1"
vpc_cidr           = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
instance_type      = "t2.micro"

What does this code do?

This file provides values for every variable you declared. When you run Terraform with -var-file="dev.tfvars", these values flow into your configuration.

  • vpc_cidr uses the 10.0.0.0/16 range, giving your dev VPC over 65,000 available IP addresses.
  • public_subnet_cidr carves out a smaller 10.0.1.0/24 block (256 addresses) within that VPC for public-facing resources.
  • Save dev.tfvars.

You will not see a change yet. The next chunk writes the matching prod configuration, then you will verify both files together.

Configure the prod environment

The prod file uses a different CIDR range so the two environments can never overlap. This is a best practice that prevents routing conflicts if you ever peer the VPCs together.

  • Open prod.tfvars in VS Code and paste the following:
environment        = "prod"
project_name       = "terraform-multi-env"
aws_region         = "us-east-1"
vpc_cidr           = "10.1.0.0/16"
public_subnet_cidr = "10.1.1.0/24"
instance_type      = "t2.micro"

What's different from dev?

Only the environment name and the CIDR ranges change. The VPC uses 10.1.0.0/16 and the subnet uses 10.1.1.0/24. Everything else (region, instance type, project name) stays identical.

  • Save prod.tfvars.
  • Confirm both tfvars files exist by running:
ls *.tfvars

You should see dev.tfvars and prod.tfvars listed in the output.

Not seeing both files?

  • Make sure your terminal is in the terraform-multi-env directory. Run pwd to check.
  • Confirm the file names are exactly dev.tfvars and prod.tfvars with no typos or extra extensions.

Help me find my tfvars files

✔️ Awesome, I've got everything!

Great. Double check you saved both files and move on.

ⓧ I'd like to double check the full code

Here is the complete dev.tfvars:

environment        = "dev"
project_name       = "terraform-multi-env"
aws_region         = "us-east-1"
vpc_cidr           = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
instance_type      = "t2.micro"

Here is the complete prod.tfvars:

environment        = "prod"
project_name       = "terraform-multi-env"
aws_region         = "us-east-1"
vpc_cidr           = "10.1.0.0/16"
public_subnet_cidr = "10.1.1.0/24"
instance_type      = "t2.micro"

Why use -var-file instead of Terraform workspaces?

Terraform workspaces share state files and can confuse beginners. Separate .tfvars files are the most common pattern on real teams because each environment's values are visible, explicit, and easy to review in a pull request.

Your variables and environment files are locked in. Next up, you will write the networking layer that creates a VPC, subnet, internet gateway, and route table using these variables.

Build the Networking Layer

Your variables and environment files are ready. Now it's time to write the actual infrastructure code that Terraform will use to create real AWS resources.

Every cloud application needs a network to live in. In this step, you'll build a VPC (Virtual Private Cloud) with a public subnet that can reach the internet. This is the foundation that your web server will sit on in the next step.

In this step, get ready to:
  • Define a reusable tagging strategy with a locals block.
  • Create networking resources (VPC, internet gateway, subnet, route table) in main.tf.
  • Run terraform plan to preview the infrastructure before deploying.
Define a tagging strategy with locals

In real AWS environments, every resource needs tags so teams can track costs, identify ownership, and filter resources by environment. Instead of repeating the same tags on every resource, you'll define them once in a locals block.

  • Open main.tf in VS Code and add the following tagging locals block:
locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "terraform"
  }
}

What does this code do?

The locals block defines a reusable map of tags that you'll attach to every resource in this project.

  • Environment tracks whether the resource belongs to dev or prod.
  • Project groups all resources under the same project name.
  • ManagedBy tells your team this resource is controlled by Terraform, not created manually.
  • Save main.tf.
  • You won't see any output yet. The networking resources you add next will use these tags.
Add the VPC and internet gateway

A VPC is an isolated network inside AWS where your resources live. An internet gateway connects your VPC to the public internet so users can reach your web server.

  • In main.tf, add the following code below your locals block:
# --- Networking ---

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-vpc"
  })
}

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

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-igw"
  })
}

What does this code do?

  • aws_vpc.main creates an isolated network with the CIDR range from your .tfvars file. For dev, that's 10.0.0.0/16 (65,536 IP addresses).
  • aws_internet_gateway.main attaches an internet gateway to the VPC. Without it, nothing inside the VPC can communicate with the outside world.
  • merge(local.common_tags, {...}) combines your shared tags with a resource-specific Name tag. The name includes the environment so you can tell dev and prod resources apart.
  • Save main.tf.
  • You won't see output yet. The next chunk adds the subnet and routing that complete the network.
Add the public subnet and route table

A subnet is a smaller segment of your VPC's IP range. A route table tells traffic where to go. By pointing the default route at your internet gateway, any instance in this subnet can reach the internet.

  • In main.tf, add the following code below your aws_internet_gateway resource:
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  map_public_ip_on_launch = true

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-subnet"
  })
}

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 = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-rt"
  })
}

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

What does this code do?

  • aws_subnet.public carves out a smaller IP range within your VPC. map_public_ip_on_launch = true means any instance launched here automatically gets a public IP address.
  • aws_route_table.public defines a rule that sends all internet-bound traffic (0.0.0.0/0) to the internet gateway.
  • aws_route_table_association.public connects the route table to the subnet. Without this association, the subnet would use the VPC's default route table (which has no internet access).
  • Save main.tf.

Seeing red squiggles in VS Code?

If the Terraform extension shows errors, make sure every opening brace has a matching closing brace. HCL uses nested braces heavily, so a missing one can cascade errors across the file.

Also confirm that your variable references (var.vpc_cidr, var.public_subnet_cidr) match the names in variables.tf exactly.

Help me fix errors in my main.tf networking resources.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on to the plan.

ⓧ I'd like to double check the full code

Here's the complete main.tf file so far. Make sure yours matches exactly:

locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "terraform"
  }
}

# --- Networking ---

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-vpc"
  })
}

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

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-igw"
  })
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  map_public_ip_on_launch = true

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-subnet"
  })
}

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 = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-rt"
  })
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
Preview the infrastructure with terraform plan

Before Terraform creates anything in AWS, you can preview exactly what it will do. The terraform plan command reads your configuration and shows you what resources it would create, modify, or destroy.

  • Run the plan command with your dev variables by running:
terraform plan -var-file="dev.tfvars"

What does this command do?

  • terraform plan compares your configuration against the current state (empty, since nothing is deployed yet) and calculates what changes are needed.
  • -var-file="dev.tfvars" tells Terraform to use the dev environment values. This is how you target different environments from the same code.
  • Look at the output in your terminal. You should see Plan: 5 to add, 0 to change, 0 to destroy at the bottom.

The five resources are your VPC, internet gateway, subnet, route table, and route table association. Terraform shows each one with a green + symbol indicating it will be created.

Seeing errors instead of a plan?

  • If you see Error: Reference to undeclared resource, check that your resource names match exactly (e.g. aws_vpc.main not aws_vpc.vpc).
  • If you see Error: Reference to undeclared input variable, make sure the variable names in main.tf match those in variables.tf (e.g. var.vpc_cidr not var.cidr).
  • If you see Error: Missing required argument, a resource is missing a mandatory field. Compare your code against the double-check tab above.

Help me fix my terraform plan errors.

Your networking layer is defined and validated. Next up, you'll add a security group and an EC2 instance that serves a live web page from inside this network.

Add Security Group and EC2 Instance

Your networking layer is built. Traffic can flow from the internet through your gateway and into your public subnet.

Now you need something to actually receive that traffic. In this step, you will create a security group to control which ports are open, then launch an EC2 instance running a web server.

In this step, get ready to:
  • Create a security group that allows HTTP and SSH inbound traffic.
  • Add a data source and EC2 instance with a web server startup script.
  • Define outputs to expose the instance's public IP and VPC ID.
Create the security group

A security group acts as a virtual firewall for your EC2 instance. It controls which traffic is allowed in (ingress) and out (egress).

  • In VS Code, open main.tf.
  • Find the closing brace of the aws_route_table_association resource at the bottom of the file.
  • Add the security group resource below it by copying this code:
# --- Security ---

resource "aws_security_group" "web" {
  name        = "${var.project_name}-${var.environment}-web-sg"
  description = "Allow HTTP and SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-web-sg"
  })
}

What does this code do?

This security group defines three traffic rules for your EC2 instance.

  • The first ingress block allows HTTP traffic on port 80 from any IP address. This is how users will reach your web server.
  • The second ingress block allows SSH traffic on port 22 for remote access.
  • The egress block with protocol = "-1" allows all outbound traffic so the instance can download packages.

Why allow SSH from anywhere?

Opening SSH to 0.0.0.0/0 is fine for a learning project. In production, you would restrict this to your team's IP range or use AWS Session Manager instead.

  • Save main.tf.

You won't see a change yet. The EC2 instance in the next chunk needs this security group to control its traffic.

Add the AMI data source and EC2 instance

Your instance needs an operating system image (AMI) to boot from. Instead of hardcoding an AMI ID that changes per region and becomes outdated, you will use a data source to always fetch the latest Amazon Linux 2023 image automatically.

  • In main.tf, add the data source and instance resource below the security group by copying this code:
# --- Compute ---

data "aws_ami" "amazon_linux" {
  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.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
              #!/bin/bash
              yum update -y
              yum install -y httpd
              systemctl start httpd
              systemctl enable httpd
              echo "<h1>Hello from ${var.environment} environment!</h1><p>Managed by Terraform</p>" > /var/www/html/index.html
              EOF

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-web-server"
  })

  depends_on = [aws_internet_gateway.main]
}

What does this code do?

The data "aws_ami" block queries AWS for the most recent Amazon Linux 2023 AMI owned by Amazon. The filters narrow it to x86_64 architecture with HVM virtualization.

The aws_instance resource launches a server using that AMI. The user_data script runs once at boot to install Apache (httpd) and write a simple HTML page showing the environment name.

The depends_on line tells Terraform to create the internet gateway before the instance. Without it, the instance could launch before the gateway is ready and fail to reach the internet.

  • Save main.tf.

You won't see a change yet. The outputs file in the next chunk will give you a way to verify everything compiles together.

Seeing red underlines in VS Code?

If VS Code highlights the <<-EOF block as an error, that is likely your editor's HCL extension struggling with heredoc syntax. The code is valid. You will confirm it compiles when you run plan shortly.

help me fix a syntax highlighting issue with heredoc in my Terraform file

Define outputs

Terraform outputs let you extract values from your deployed infrastructure. After apply, these values print to your terminal so you can immediately use them.

  • Open outputs.tf in VS Code (it should be empty).
  • Add the following output definitions:
output "instance_public_ip" {
  description = "Public IP address of the web server"
  value       = aws_instance.web.public_ip
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "web_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

What does this code do?

  • instance_public_ip prints the IP address you will use to access your web server in a browser.
  • vpc_id prints the VPC identifier so you can find it in the AWS Console.
  • web_url constructs the full HTTP URL for convenience so you can copy-paste it directly into your browser.
  • Save outputs.tf.
  • Verify the full configuration compiles by running this command in your terminal:
terraform plan -var-file="dev.tfvars"

You should see Plan: 8 to add, 0 to change, 0 to destroy at the bottom. This confirms your security group, AMI data source, EC2 instance, and outputs are all valid.

Seeing fewer than 8 resources?

If the plan shows fewer resources, check that you pasted the security group and instance code after the existing networking resources, not inside them. Each resource block needs its own opening and closing braces.

If you see an error about an unknown variable or resource, confirm that variables.tf still has all six variables from the previous step.

help me debug my terraform plan output

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete main.tf file with all networking, security, and compute resources:

locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "terraform"
  }
}

# --- Networking ---

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-vpc"
  })
}

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

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-igw"
  })
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  map_public_ip_on_launch = true

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-subnet"
  })
}

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 = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-public-rt"
  })
}

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

# --- Security ---

resource "aws_security_group" "web" {
  name        = "${var.project_name}-${var.environment}-web-sg"
  description = "Allow HTTP and SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-web-sg"
  })
}

# --- Compute ---

data "aws_ami" "amazon_linux" {
  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.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
              #!/bin/bash
              yum update -y
              yum install -y httpd
              systemctl start httpd
              systemctl enable httpd
              echo "<h1>Hello from ${var.environment} environment!</h1><p>Managed by Terraform</p>" > /var/www/html/index.html
              EOF

  tags = merge(local.common_tags, {
    Name = "${var.project_name}-${var.environment}-web-server"
  })

  depends_on = [aws_internet_gateway.main]
}

And the complete outputs.tf file:

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

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "web_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

Your infrastructure configuration is complete. Next up, you will deploy this entire stack to AWS and see your web server running live.

Deploy the Dev Environment

Your Terraform configuration is complete. You have networking, security, and compute resources defined, all parameterized with variables.

Now it is time to deploy real infrastructure to AWS. You will run a single command that creates every resource defined in your configuration and get a live web server you can visit in your browser.

In this step, get ready to:
  • Deploy the full infrastructure stack to AWS with a single command.
  • Verify your web server is accessible over the internet.
  • Inspect your resources in the AWS Console to confirm proper tagging.
Deploy infrastructure with terraform apply

The terraform apply command reads your configuration, compares it against the current state (which is empty since you have not deployed anything yet), and creates all the resources.

  • Deploy your dev environment by running this command in your terminal:
terraform apply -var-file="dev.tfvars"

What does this command do?

The -var-file="dev.tfvars" flag tells Terraform to load your dev-specific values (the 10.0.0.0/16 CIDR range, t2.micro instance type, and environment = "dev" label).

Terraform will show you a plan of everything it intends to create, then ask you to confirm before making any changes to your AWS account.

Terraform will ask you to confirm

After showing the plan, Terraform displays Do you want to perform these actions? and waits for your input.

Type yes and press Enter to proceed. Typing anything else cancels the deployment.

Terraform will create 7 resources. This takes about 1-2 minutes to complete.

  • Wait for Terraform to finish. You should see a message like Apply complete! Resources: 7 added, 0 changed, 0 destroyed.
  • Below that message, Terraform displays your outputs: instance_public_ip, vpc_id, and web_url.

Seeing an error during apply?

If you see Error: No valid credential sources found, your AWS CLI is not configured. Run aws configure and enter your access key ID and secret access key.

If you see a VPC or subnet limit error, you may have too many VPCs in your region. Delete unused VPCs in the AWS Console first.

Help me troubleshoot my terraform apply error

Verify the web server in your browser

Your EC2 instance is now running a web server. The user_data script you wrote installed Apache (httpd) and created an index.html file showing the environment name.

  • Find the web_url output from your terminal. It looks like http://X.X.X.X where X.X.X.X is your instance's public IP.
  • Copy that URL and paste it into your browser's address bar.
  • Press Enter to load the page.

You should see a page that says "Hello from dev environment!" with "Managed by Terraform" below it.

Wait 30-60 seconds if the page does not load

The EC2 instance needs time to boot, install Apache, and start the web server. If you see a connection timeout, wait a minute and refresh the page.

Page still not loading after a minute?

Make sure you are using http:// and not https:// in the URL. Your server only listens on port 80 (HTTP).

If the page still does not load, check that your security group has port 80 open. Run terraform plan -var-file="dev.tfvars" to see whether any resources are out of sync.

Help me debug why my EC2 web server is not reachable

Inspect resource tags in the AWS Console

Tags are how teams track ownership, cost allocation, and environment membership across hundreds of resources. Your locals block applied three tags to every resource automatically.

  • Open the AWS Console in your browser and sign in.
  • Navigate to the EC2 service by typing EC2 in the top search bar and selecting it.
  • Click Instances (running) in the left sidebar.
  • Find the instance named terraform-multi-env-dev-web-server and click on its instance ID.
  • Click the Tags tab at the bottom of the page.

You should see four tags on your instance:

  • Environment = dev.
  • Project = terraform-multi-env.
  • ManagedBy = terraform.
  • Name = terraform-multi-env-dev-web-server.

Why does tagging matter?

In a real company, hundreds of resources run across multiple environments. Tags let you filter billing reports by environment, identify which team owns a resource, and quickly find all infrastructure managed by Terraform versus manually created resources.

Your locals block guarantees every resource gets the same base tags without repeating yourself in each resource block.

What about the security group allowing SSH from anywhere?

Your security group allows SSH (port 22) from 0.0.0.0/0, meaning anyone on the internet can attempt to connect. This is acceptable for a short-lived learning project.

In production, you would restrict SSH access to specific IP ranges or remove it entirely in favor of AWS Systems Manager Session Manager.

Your dev environment is live and properly tagged. Next, you will tear it down and redeploy as production to prove that the same configuration works for multiple environments.

Destroy and Redeploy as Prod

Your dev environment is live and verified. Now comes the moment that shows why parameterized infrastructure matters.

In this step, you will destroy the dev environment and redeploy the exact same code as a prod environment. The only thing that changes is the .tfvars file you pass to Terraform. This is how real platform engineering teams manage multiple environments from a single codebase.

In this step, get ready to:
  • Tear down the dev environment with a single command.
  • Deploy the same infrastructure as prod using different variable values.
  • Verify prod is live and correctly tagged, then clean up.
Destroy the dev environment

Before deploying prod, you need to remove the dev resources. Terraform tracks everything it created in the state file, so it knows exactly what to delete.

  • Destroy all dev resources by running this command:
terraform destroy -var-file="dev.tfvars"
  • Terraform will show you a plan of everything it will delete. Type yes when prompted and press Enter.

What does this command do?

The destroy command reads your state file to find every resource Terraform manages, then deletes them in the correct order. It respects resource dependencies, so the EC2 instance is terminated before the subnet it lives in is removed.

Passing -var-file="dev.tfvars" ensures Terraform uses the same variable values that were used during creation. This is important because some resource names and identifiers are built from those variables.

You should see output ending with Destroy complete! Resources: 7 destroyed. (the exact count may vary slightly).

Seeing an error during destroy?

If you see a dependency error, run the command again. Occasionally AWS takes a moment to release network interfaces before a security group or subnet can be deleted.

If you see an authentication error, confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity.

Help me troubleshoot a terraform destroy error.

Deploy the prod environment

Now you will deploy the same infrastructure with prod-specific values. Your code stays exactly the same. The only difference is the file you pass to the -var-file flag.

  • Deploy the prod environment by running this command:
terraform apply -var-file="prod.tfvars"
  • Terraform will show a plan of all resources it will create. Type yes when prompted and press Enter.

What changed between dev and prod?

Compare the two .tfvars files. The VPC CIDR changed from 10.0.0.0/16 to 10.1.0.0/16, the subnet changed from 10.0.1.0/24 to 10.1.1.0/24, and the environment label changed from dev to prod.

Every resource name, tag, and network range automatically adjusts because your main.tf uses variables instead of hardcoded values. This is the core pattern behind multi-environment infrastructure.

Wait for the apply to finish. You should see output ending with Apply complete! followed by your outputs including instance_public_ip and web_url.

Apply failed with a conflict error?

If you see an error about a resource already existing, the previous destroy may not have fully completed. Run terraform destroy -var-file="dev.tfvars" again, wait for it to finish, then retry the apply.

Help me fix a terraform apply conflict error for my prod deployment.

Verify the prod deployment and clean up

Just like you verified the dev environment, you will now confirm prod is running correctly before tearing it down.

  • Copy the web_url value from the Terraform output.
  • Paste it into your browser.

You should see a page displaying Hello from prod environment! instead of the dev message you saw earlier.

Wait 30-60 seconds if the page doesn't load

The EC2 instance needs time to boot up and start the web server. If the page times out on your first try, wait a minute and refresh.

  • Open the AWS Console and navigate to EC2, then Instances.
  • Click on your running instance and check the Tags tab.

You should see Environment=prod, Project=terraform-multi-env, and ManagedBy=terraform on the instance. The Name tag should read terraform-multi-env-prod-web-server.

Now that you have confirmed prod works, destroy it to avoid any ongoing charges.

  • Destroy the prod environment by running this command:
terraform destroy -var-file="prod.tfvars"
  • Type yes when prompted and press Enter.

You should see Destroy complete! confirming all prod resources have been removed from AWS.

Why destroy after verifying?

The EC2 t2.micro instance is free-tier eligible for 750 hours per month. Destroying promptly is a good habit because in real projects you will often have resources that cost money when left running.

Your Terraform code remains intact on disk. You can redeploy at any time by running terraform apply again with either .tfvars file.

Your infrastructure code is proven and reusable. Next up, you will push this project to GitHub so it is version-controlled and shareable.

Push to GitHub

You just proved the power of parameterized infrastructure by deploying both dev and prod environments from the same codebase. Your Terraform project is complete and working.

Now it is time to put your code under version control. Every professional Infrastructure as Code project lives in a Git repository so teams can track changes, review pull requests, and roll back if something breaks.

In this step, get ready to:
  • Create a .gitignore to keep sensitive and generated files out of your repository.
  • Initialize a Git repository and commit your Terraform code.
  • Push your project to GitHub.
Create a .gitignore file

Terraform generates local files you should never commit to version control. The .terraform/ directory contains downloaded provider binaries, and *.tfstate files can contain sensitive resource details.

  • In VS Code, create a new file called .gitignore in the root of your terraform-multi-env folder.
  • Add the following content to .gitignore:
# Terraform provider binaries
.terraform/

# State files contain sensitive resource info
*.tfstate
*.tfstate.backup

# Lock file is environment-specific
.terraform.lock.hcl

What does this file do?

A .gitignore tells Git which files to skip when tracking changes. Any file matching a pattern in this list will be completely invisible to Git.

  • .terraform/ contains downloaded provider plugins (large, machine-specific binaries).
  • *.tfstate files store the current state of your infrastructure, which can include sensitive data like IP addresses and resource IDs.
  • .terraform.lock.hcl locks provider versions for a specific OS and can cause issues across different machines.
  • Save .gitignore.
Initialize Git and commit your code

With your .gitignore in place, you can safely initialize a Git repository without worrying about accidentally committing state files or provider binaries.

  • Make sure your terminal is in the terraform-multi-env folder, then initialize a Git repository by running this command:
git init
  • Stage all your Terraform files by running:
git add .
  • Confirm the right files are staged by running:
git status

You should see providers.tf, variables.tf, main.tf, outputs.tf, dev.tfvars, prod.tfvars, and .gitignore listed in green. You should NOT see .terraform/ or any .tfstate files.

  • Commit your staged files by running:
git commit -m "Initial commit: multi-env Terraform project"

Seeing 'Author identity unknown'?

If Git asks you to set your name and email, run these two commands (replacing the placeholder values with your own):

git config --global user.email "your-email@example.com"
git config --global user.name "Your Name"

Then run the git commit command again. Help me fix my Git configuration.

Create a GitHub repository and push your code

Your local Git repository is ready. Now you need a remote repository on GitHub to store your code in the cloud and enable collaboration.

  • Open your browser and go to github.com.
  • Click the + icon in the top right corner.
  • Select New repository.
  • Enter terraform-multi-env as the Repository name.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization boxes (no README, no .gitignore, no license). You already have local commits ready to push.
  • Click Create repository.

GitHub shows a quick setup page with commands for pushing an existing repository. You will use those commands now.

  • Back in your terminal, add the GitHub remote by running this command (replace the URL with the one GitHub shows you):
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/terraform-multi-env.git"]]
  • Rename your default branch to main by running:
git branch -M main
  • Push your code to GitHub by running:
git push -u origin main

What does -u origin main do?

The -u flag sets origin main as the default upstream branch. After this, future pushes only need git push without specifying the remote or branch.

Authentication failed?

GitHub no longer accepts passwords for Git operations. If you see an authentication error, you need to use either a Personal Access Token or SSH key.

  • Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic).
  • Generate a new token with the repo scope.
  • Use the token as your password when Git prompts for credentials.

Help me authenticate Git with GitHub.

Verify your files on GitHub
  • Refresh the GitHub repository page in your browser.

You should see all seven files listed: .gitignore, dev.tfvars, main.tf, outputs.tf, prod.tfvars, providers.tf, and variables.tf.

  • Confirm that you do NOT see a .terraform/ folder or any .tfstate files in the repository.

Why keep .tfvars in version control?

Your .tfvars files only contain non-sensitive configuration values like CIDR ranges and instance types. In production, teams often commit these files so everyone deploys with the same parameters.

If your .tfvars contained secrets (database passwords, API keys), you would add *.tfvars to your .gitignore and use a secrets manager instead.

✔️ Awesome, I've got everything!

Great. Double check you have all seven files visible in your GitHub repository before moving on.

ⓧ I'd like to double check the full code

Here is the .gitignore file that should be in your repository:

.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl

Secret mission

Add CI/CD with GitHub Actions

Your Terraform code is on GitHub, but there's no safety net catching misconfigurations before they merge. In this secret mission, you'll add a GitHub Actions workflow that automatically validates your Terraform formatting and syntax on every pull request. This is the exact pattern DevOps teams use to prevent broken infrastructure from reaching production.

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 and VPC resources in this project are AWS Free Tier eligible, but they will cost money if left running beyond your free tier limits.

Cost warning

The t2.micro instance is free for 750 hours/month during your first 12 months on AWS. If you have exceeded your free tier window or are running other instances, these resources will incur charges.

If you followed Step 6 fully and destroyed the prod environment at the end, your resources are already gone and no action is needed.

Resources you used:

  • AWS VPC with public subnet.
  • Internet gateway and route table.
  • Security group (HTTP and SSH inbound).
  • EC2 instance (t2.micro) running a web server.
  • Local Terraform project files on your Desktop.
  • GitHub repository with your Terraform code.

Keep everything running

No action needed. Choose this if you are still actively experimenting with your Terraform configuration and want to keep your web server accessible.

  • Your EC2 t2.micro instance is free tier eligible for 750 hours/month during your first 12 months on AWS.
  • Monitor your usage in the AWS Billing Dashboard to avoid unexpected charges.
  • Your GitHub repository and local project files remain as they are.

Pause - I'll come back to this later

Destroy the AWS resources to stop any potential charges. Your Terraform code stays on your machine and GitHub, so you can redeploy anytime with a single command.

  • Run the destroy command for whichever environment is currently deployed:
terraform destroy -var-file="dev.tfvars"

Or if you last deployed with prod:

terraform destroy -var-file="prod.tfvars"
  • Type yes when prompted to confirm destruction.
  • Your local project files and GitHub repository are unaffected. When you want to redeploy, run terraform apply -var-file="dev.tfvars" again.

Delete - I don't want to use this again

Remove all AWS resources and clean up your local project files entirely.

  • Destroy the AWS infrastructure for whichever environment is currently deployed:
terraform destroy -var-file="dev.tfvars"
  • Type yes when prompted to confirm destruction.
  • Delete the local terraform-multi-env folder from your Desktop.
  • Optionally delete the GitHub repository from your GitHub account under Settings > General > Danger Zone > Delete this repository.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-style Terraform project the way platform engineers do it at real companies.

You've learned how to:

  • Built a reusable Terraform configuration with proper file structure that deploys a complete AWS networking stack (VPC, subnet, internet gateway, security group) and an EC2 web server.
  • Parameterized infrastructure with variables and .tfvars files to deploy the same code across dev and prod environments.
  • Practiced the full Terraform workflow (init, plan, apply, destroy) and pushed infrastructure code to GitHub with a proper .gitignore.
  • Secret Mission: Added a CI/CD pipeline with GitHub Actions that validates Terraform formatting and syntax on every pull request.

Ready to quiz yourself?